Triton upstream #425
@@ -10,9 +10,11 @@ import java.nio.ByteBuffer
|
||||
* [Sc2BleLink]) and one of two consumers:
|
||||
*
|
||||
* **Stream mode** (`router != null`, owned by StreamScreen):
|
||||
* - **Raw plane (the point):** every input report is forwarded verbatim
|
||||
* - **Raw plane (the point):** every input report is forwarded byte-for-byte
|
||||
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
|
||||
* Steam Input drives like the physical controller.
|
||||
* Steam Input drives like the physical controller — with ONE exception: [Sc2ImuGate] zeroes a
|
||||
* frozen (gyro-off) IMU block out of state reports, so a stale resting sample can't drive
|
||||
* Steam's desktop gyro-mouse (the cursor-fly the bench debugged 2026-06-08).
|
||||
* - **Typed mirror:** buttons/sticks/triggers are ALSO diffed onto the ordinary per-transition
|
||||
* plane, so the emergency exit chord works, and a host that degraded the kind (no UHID → the
|
||||
* Xbox 360 pad) still gets a playable controller.
|
||||
@@ -47,6 +49,10 @@ class Sc2Capture(
|
||||
|
||||
private var pad: GamepadRouter.ExternalPad? = null
|
||||
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
|
||||
|
||||
/** Zeroes a frozen (gyro-off) IMU block out of forwarded state reports — see [Sc2ImuGate]. */
|
||||
private val imuGate = Sc2ImuGate()
|
||||
|
||||
/** Puck connect arrives before its first state report (and therefore before a wire pad exists).
|
||||
* Preserve it so the native virtual Puck slot sees the same connect edge before state. */
|
||||
private val pendingWireless = ByteArray(2)
|
||||
@@ -193,6 +199,10 @@ class Sc2Capture(
|
||||
|
||||
private fun forwardRaw(report: ByteArray, len: Int) {
|
||||
val p = pad ?: return
|
||||
// Both links hand over buffers that are dead once this call returns (the USB reader
|
||||
// refills its scratch, BLE frames a fresh array per notification) and the typed mirror
|
||||
// reads only bytes 0..17, all below the IMU block — so the gate may zero in place.
|
||||
imuGate.apply(report, len)
|
||||
val n = len.coerceAtMost(rawBuf.capacity())
|
||||
rawBuf.clear()
|
||||
rawBuf.put(report, 0, n)
|
||||
@@ -296,6 +306,9 @@ class Sc2Capture(
|
||||
wireButtons = 0
|
||||
lastAxis.fill(Int.MIN_VALUE)
|
||||
pendingWirelessLen = 0
|
||||
// Every teardown funnels through here (stop, link drop, Puck power-off), so whatever
|
||||
// connects next re-proves its IMU live before the block passes through again.
|
||||
imuGate.reset()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* IMU liveness gate for the raw SC2 state-report feed — the client-side half of the policy
|
||||
* proven against real hardware on the bench (2026-06-08).
|
||||
*
|
||||
* The controller streams gyro/accel only after the host writes `SETTING_IMU_MODE` (reg 0x30);
|
||||
* until then the IMU block — including its leading u32 timestamp — is FROZEN at a stale non-zero
|
||||
* resting sample (byte-frozen across 600 frames in the 2026-06-08 capture). Forwarded verbatim,
|
||||
* that constant non-zero gyro reads to Steam's desktop config as a *constant* rotation and flies
|
||||
* the cursor. So: pass the IMU through only while its timestamp is advancing, and zero the whole
|
||||
* block (timestamp included) while frozen. Self-correcting, no hardcoded gyro-enable: on the
|
||||
* desktop the IMU stays off → frozen → zeros → calm cursor; a gyro game makes Steam send the
|
||||
* enable (feature `01 87 03 30 18 00`, replayed to the pad by the existing [Sc2Capture.onHidRaw]
|
||||
* raw-return path) → the timestamp starts ticking → live data flows.
|
||||
*
|
||||
* Gated shapes: `0x42` (USB state, 54 B wire) and `0x45` (BLE state, 46 B wire) — both are
|
||||
* `[report id][pack(1) TritonMTUNoQuat_t]`, so the IMU block (u32 timestamp + 3× i16 accel +
|
||||
* 3× i16 gyro) sits at wire offset 30 (struct offset 29 + the id byte) in both. `0x47` is
|
||||
* deliberately NOT gated: its layout diverges from byte 18 (inserted trackpad timestamp), no
|
||||
* capture pins its IMU offset down, and the Windows host driver drops that id anyway (it is not
|
||||
* in the wired descriptor).
|
||||
*
|
||||
* Single-threaded by contract: [apply] runs on the link thread; [reset] runs from the same
|
||||
* teardown paths that already touch the slot state (the [Sc2Capture] threading contract).
|
||||
*/
|
||||
class Sc2ImuGate {
|
||||
private var lastTs = 0
|
||||
private var haveTs = false
|
||||
private var stale = 0
|
||||
|
||||
/** Re-arm (forget the timestamp history) — called on link drop / capture stop, so whatever
|
||||
* connects next must re-prove its IMU live before the block passes through. */
|
||||
fun reset() {
|
||||
lastTs = 0
|
||||
haveTs = false
|
||||
stale = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate [report] (its first [len] bytes) in place, before it is forwarded. Non-state ids and
|
||||
* reports too short to carry a full IMU block pass through untouched; a state report whose
|
||||
* IMU timestamp has not advanced for [STALE_LIMIT] consecutive frames — or that has no
|
||||
* history yet (unknown until it moves, so treated as frozen) — gets its IMU block zeroed.
|
||||
* A live stream tolerates short repeats (report rate can exceed the IMU sample rate).
|
||||
*/
|
||||
fun apply(report: ByteArray, len: Int) {
|
||||
if (len < IMU_OFFSET + IMU_LEN) return // short/truncated: no full IMU block on board
|
||||
when (report[0].toInt() and 0xFF) {
|
||||
Sc2Device.ID_STATE, Sc2Device.ID_STATE_BLE -> {}
|
||||
else -> return
|
||||
}
|
||||
val ts = (report[IMU_OFFSET].toInt() and 0xFF) or
|
||||
((report[IMU_OFFSET + 1].toInt() and 0xFF) shl 8) or
|
||||
((report[IMU_OFFSET + 2].toInt() and 0xFF) shl 16) or
|
||||
((report[IMU_OFFSET + 3].toInt() and 0xFF) shl 24)
|
||||
val live: Boolean
|
||||
if (!haveTs) {
|
||||
haveTs = true
|
||||
stale = STALE_LIMIT // unknown until it moves → treat as frozen
|
||||
live = false
|
||||
} else if (ts != lastTs) {
|
||||
stale = 0
|
||||
live = true
|
||||
} else {
|
||||
if (stale < STALE_LIMIT) stale++
|
||||
live = stale < STALE_LIMIT
|
||||
}
|
||||
lastTs = ts
|
||||
if (!live) report.fill(0, IMU_OFFSET, IMU_OFFSET + IMU_LEN)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Wire offset of `TritonMTUNoQuat_t.imu` — struct offset 29 + 1 report-id byte;
|
||||
* identical in the 0x42 and 0x45 shapes (both carry the same pack(1) struct). */
|
||||
const val IMU_OFFSET = 30
|
||||
|
||||
/** u32 timestamp + 3× i16 accel + 3× i16 gyro. */
|
||||
const val IMU_LEN = 16
|
||||
|
||||
/** Unchanged-timestamp frames before declaring the IMU frozen (bench-tuned 2026-06-08:
|
||||
* three repeats still pass, the fourth freezes). */
|
||||
const val STALE_LIMIT = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM tests of [Sc2ImuGate], the IMU liveness gate (the frozen/live/refrozen cases below
|
||||
* are the ones that matter on hardware, including a bench-captured frozen frame).
|
||||
* Offsets per the 2026-06-07 USB capture: both
|
||||
* state shapes are `[id][pack(1) TritonMTUNoQuat_t]`, IMU block (u32 timestamp + 6× i16) at
|
||||
* wire offset 30. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class Sc2ImuGateTest {
|
||||
private val off = Sc2ImuGate.IMU_OFFSET // 30
|
||||
private val imuLen = Sc2ImuGate.IMU_LEN // 16
|
||||
|
||||
/** A 46-byte BLE-shape state report (`[0x45][45-byte payload]`) with IMU timestamp [ts]. */
|
||||
private fun bleState(ts: Int, mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(46).also {
|
||||
it[0] = Sc2Device.ID_STATE_BLE.toByte()
|
||||
it[off] = ts.toByte()
|
||||
it[off + 1] = (ts ushr 8).toByte()
|
||||
it[off + 2] = (ts ushr 16).toByte()
|
||||
it[off + 3] = (ts ushr 24).toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
private fun imuIsZero(r: ByteArray): Boolean =
|
||||
(off until off + imuLen).all { r[it] == 0.toByte() }
|
||||
|
||||
// FROZEN: frames whose IMU timestamp never advances (gyro disabled on the
|
||||
// controller, the real default). The stale non-zero IMU must come out zeroed on EVERY frame
|
||||
// (the first is zeroed too: no history = unknown until it moves), while non-IMU fields
|
||||
// still pass through.
|
||||
@Test
|
||||
fun frozenTimestampZeroesTheImuBlock() {
|
||||
val gate = Sc2ImuGate()
|
||||
repeat(8) { // > STALE_LIMIT
|
||||
val r = bleState(0) {
|
||||
for (i in 0 until imuLen) it[off + i] = (0xC0 + i).toByte() // constant IMU
|
||||
it[10] = 0xAB.toByte() // live sLeftStickX low byte (struct offset 9)
|
||||
}
|
||||
gate.apply(r, r.size)
|
||||
assertEquals(0xAB.toByte(), r[10]) // non-IMU field preserved
|
||||
assertTrue(imuIsZero(r)) // frozen IMU zeroed
|
||||
}
|
||||
}
|
||||
|
||||
// LIVE: the timestamp advances each frame (gyro enabled, e.g. Steam wrote
|
||||
// SETTING_IMU_MODE). The IMU must pass through so real motion reaches Steam — the
|
||||
// regression an unconditional zeroing would wrongly clobber.
|
||||
@Test
|
||||
fun advancingTimestampPassesTheImuThrough() {
|
||||
val gate = Sc2ImuGate()
|
||||
for (frame in 0 until 8) {
|
||||
val ts = 0x1000 + frame * 0x40
|
||||
val r = bleState(ts) {
|
||||
it[off + 4] = 0x11 // accel sample bytes
|
||||
it[off + 5] = 0x22
|
||||
}
|
||||
gate.apply(r, r.size)
|
||||
if (frame == 0) {
|
||||
assertTrue(imuIsZero(r)) // no history yet — armed until it moves
|
||||
} else {
|
||||
assertEquals(ts.toByte(), r[off]) // timestamp survived
|
||||
assertEquals(0x11.toByte(), r[off + 4]) // accel survived
|
||||
assertEquals(0x22.toByte(), r[off + 5])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A live stream tolerates up to STALE_LIMIT-1 consecutive repeats (the report rate can
|
||||
// exceed the IMU sample rate); the STALE_LIMITth unchanged frame is declared frozen.
|
||||
@Test
|
||||
fun staleLimitBoundsRepeatTolerance() {
|
||||
val gate = Sc2ImuGate()
|
||||
gate.apply(bleState(0x100), 46) // arm
|
||||
gate.apply(bleState(0x140), 46) // advance → proven live
|
||||
repeat(Sc2ImuGate.STALE_LIMIT - 1) {
|
||||
val r = bleState(0x140) { it[off + 4] = 0x11 }
|
||||
gate.apply(r, r.size)
|
||||
assertEquals(0x11.toByte(), r[off + 4]) // repeat within tolerance: still live
|
||||
}
|
||||
val r = bleState(0x140) { it[off + 4] = 0x11 }
|
||||
gate.apply(r, r.size)
|
||||
assertTrue(imuIsZero(r)) // STALE_LIMITth consecutive repeat: frozen
|
||||
}
|
||||
|
||||
// reset() re-arms: after a reconnect the pad must re-prove liveness even if its first
|
||||
// timestamp happens to differ from the last pre-reset one.
|
||||
@Test
|
||||
fun resetRearmsTheGate() {
|
||||
val gate = Sc2ImuGate()
|
||||
gate.apply(bleState(0x100), 46)
|
||||
val live = bleState(0x140) { it[off + 4] = 0x11 }
|
||||
gate.apply(live, live.size)
|
||||
assertEquals(0x11.toByte(), live[off + 4]) // proven live
|
||||
|
||||
gate.reset()
|
||||
val first = bleState(0x180) { it[off + 4] = 0x11 }
|
||||
gate.apply(first, first.size)
|
||||
assertTrue(imuIsZero(first)) // history forgotten — frozen until it moves again
|
||||
val second = bleState(0x1C0) { it[off + 4] = 0x11 }
|
||||
gate.apply(second, second.size)
|
||||
assertEquals(0x11.toByte(), second[off + 4]) // advancing again → live
|
||||
}
|
||||
|
||||
// The USB 0x42 shape (54 B wire) carries the same pack(1) struct → same offset-30 gate.
|
||||
@Test
|
||||
fun usbStateShapeIsGatedAtTheSameOffset() {
|
||||
val gate = Sc2ImuGate()
|
||||
fun usb(ts: Int): ByteArray = ByteArray(54).also {
|
||||
it[0] = Sc2Device.ID_STATE.toByte()
|
||||
it[off] = ts.toByte()
|
||||
it[off + 1] = (ts ushr 8).toByte()
|
||||
it[off + 4] = 0x33
|
||||
}
|
||||
val r0 = usb(0x0500)
|
||||
gate.apply(r0, r0.size)
|
||||
assertTrue(imuIsZero(r0)) // first frame armed
|
||||
val r1 = usb(0x0540)
|
||||
gate.apply(r1, r1.size)
|
||||
assertEquals(0x33.toByte(), r1[off + 4]) // advancing → live
|
||||
}
|
||||
|
||||
// Non-state ids (battery 0x43, wireless 0x79) — and 0x47, whose layout diverges from
|
||||
// byte 18 (inserted trackpad timestamp) so offset 30 is NOT its IMU — pass untouched.
|
||||
@Test
|
||||
fun nonStateAndTimestampShapesAreUntouched() {
|
||||
val gate = Sc2ImuGate()
|
||||
val ids = intArrayOf(Sc2Device.ID_BATTERY, Sc2Device.ID_WIRELESS, Sc2Device.ID_STATE_TIMESTAMP)
|
||||
for (id in ids) {
|
||||
repeat(8) {
|
||||
val r = ByteArray(46) { 0x5A }
|
||||
r[0] = id.toByte()
|
||||
gate.apply(r, r.size)
|
||||
for (i in 1 until r.size) assertEquals(0x5A.toByte(), r[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A truncated state report (no full IMU block on board) passes untouched — len is the
|
||||
// report's byte count, not the (possibly larger) scratch buffer's.
|
||||
@Test
|
||||
fun shortReportsAreUntouched() {
|
||||
val gate = Sc2ImuGate()
|
||||
val r = ByteArray(64) { 0x5A }
|
||||
r[0] = Sc2Device.ID_STATE_BLE.toByte()
|
||||
gate.apply(r, 30) // ends right where the IMU block would start
|
||||
for (i in 1 until r.size) assertEquals(0x5A.toByte(), r[i])
|
||||
}
|
||||
|
||||
// A bench-captured controller frame (2026-06-08, live on hardware):
|
||||
// sticks/buttons live, IMU tail arrived FROZEN (C3 7A C3 13 …). The gamepad fields must
|
||||
// survive verbatim and the frozen IMU must zero out (no gyro-mouse cursor-fly).
|
||||
@Test
|
||||
fun realCapturedFrozenFrameIsScrubbedButPlayable() {
|
||||
val gate = Sc2ImuGate()
|
||||
val real = intArrayOf(
|
||||
0x45,
|
||||
0x00, 0x00, 0x10, 0x31, 0x00, 0x00, 0x00, 0x00, // seq, buttons (pressed)
|
||||
0xFD, 0x14, 0x41, 0xB9, 0x84, 0xFA, 0x01, 0x80, // triggers + sticks (off-center)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // pads
|
||||
0xC3, 0x7A, 0xC3, 0x13, 0x02, 0x10, 0xE9, 0x0F, 0x5C, 0x3C, 0x00, 0x00, // frozen IMU
|
||||
0xFF, 0xFF, 0x00, 0x00, 0x00,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
assertEquals(46, real.size)
|
||||
gate.apply(real, real.size)
|
||||
assertEquals(0x10.toByte(), real[3]) // buttons survive verbatim
|
||||
assertEquals(0x31.toByte(), real[4])
|
||||
assertEquals(0xFD.toByte(), real[9]) // sticks survive verbatim
|
||||
assertEquals(0x14.toByte(), real[10])
|
||||
assertEquals(0x80.toByte(), real[16])
|
||||
assertEquals(0xC3.toByte(), real[29]) // last pre-IMU byte (struct offset 28) survives
|
||||
assertTrue(imuIsZero(real)) // the frozen C3 7A C3 13 … tail is zeroed
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- CoreBluetooth: the Steam Controller 2 as-is passthrough (Sc2BleLink) reads an OS-paired
|
||||
SC2's custom Valve GATT service. Both keys so older iOS releases (which read the
|
||||
Peripheral key) get a string too. Shared by
|
||||
all three app targets — harmless on tvOS, where the capture code is #if-gated out. -->
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Bluetooth lets Punktfunk read your Steam Controller and pass it through to the streaming PC.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>Bluetooth lets Punktfunk read your Steam Controller and pass it through to the streaming PC.</string>
|
||||
<!-- NOTE: there is deliberately NO NSAppTransportSecurity dict here. ATS stays fully ON.
|
||||
The host is self-signed at a user-supplied address, which default ATS can never accept
|
||||
(it exempts only .local, unqualified names, and RFC1918/link-local literals — notably NOT
|
||||
|
||||
@@ -35,9 +35,10 @@
|
||||
<true/>
|
||||
|
||||
<!-- Game controllers over Bluetooth via the GameController framework
|
||||
(GCController.startWirelessControllerDiscovery — Xbox/DualSense). No CoreBluetooth in
|
||||
the app, so no NSBluetoothAlwaysUsageDescription is required, but the sandbox still
|
||||
gates GameController's BT HID access on this key. -->
|
||||
(GCController.startWirelessControllerDiscovery — Xbox/DualSense), AND the Steam
|
||||
Controller 2 as-is passthrough's CoreBluetooth client (Sc2BleLink reads the SC2's
|
||||
custom Valve GATT service), whose NSBluetoothAlwaysUsageDescription lives in the
|
||||
shared Config/Info.plist. The sandbox gates both on this key. -->
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
|
||||
@@ -1160,6 +1160,13 @@ struct ContentView: View {
|
||||
MotionUnreachableBadge()
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
// The SC2 passthrough's claim edge (never true on tvOS — no capture
|
||||
// there). Same transient contract as the motion hint above; without it
|
||||
// the raw BLE capture engages with no visible trace anywhere in the app.
|
||||
if captureEnabled, model.sc2CapturedHint {
|
||||
Sc2CapturedBadge()
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
// The expiry-warning toast (T−5 m / T−1 m, per-client access §7) —
|
||||
// transient, every platform, every tier: "the pad just died" must
|
||||
// read as "the evening's access ended" while it can still be fixed.
|
||||
@@ -1200,6 +1207,11 @@ struct ContentView: View {
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
.animation(.easeOut(duration: 0.2), value: model.accessWarning)
|
||||
.animation(.easeOut(duration: 0.2), value: model.accessLimited)
|
||||
// The motion hint was the one badge missing from this cluster — its
|
||||
// `.transition` fired in an unanimated transaction and popped. One list,
|
||||
// so every badge in the stack enters and exits the same way.
|
||||
.animation(.easeOut(duration: 0.2), value: model.motionUnreachableKind)
|
||||
.animation(.easeOut(duration: 0.2), value: model.sc2CapturedHint)
|
||||
}
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||
|
||||
@@ -257,6 +257,19 @@ final class SessionModel: ObservableObject {
|
||||
/// How long the motion hint stays up — the start-of-stream shortcut banner's 6 s, since the
|
||||
/// two share the bottom-centre stack and a player reads them the same way.
|
||||
private static let motionHintSeconds: UInt64 = 6
|
||||
/// True while the "Steam Controller passing through" badge shows — set on the SC2
|
||||
/// capture's claim edge (stream start, or the pad powering on mid-session), auto-dropped
|
||||
/// after `motionHintSeconds` like the motion hint it stacks with, and dropped EARLY on a
|
||||
/// release edge so the badge can never outlive the passthrough it announces. The badge is
|
||||
/// the capture's ONLY UI surface: the raw BLE device never enters GameController, so the
|
||||
/// Controllers page cannot list it. Never true on tvOS (no `Sc2Capture` there).
|
||||
@Published private(set) var sc2CapturedHint = false
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Drops `sc2CapturedHint` — same contract as `motionHintTimer` (restart on a new claim,
|
||||
/// cancel on teardown rather than firing into a torn-down model). Gated with the capture
|
||||
/// itself: only `noteSc2Phase` and the disconnect teardown touch it.
|
||||
private var sc2HintTimer: Task<Void, Never>?
|
||||
#endif
|
||||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||
@@ -293,6 +306,13 @@ final class SessionModel: ObservableObject {
|
||||
private var audio: SessionAudio?
|
||||
private var gamepadCapture: GamepadCapture?
|
||||
private var gamepadFeedback: GamepadFeedback?
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The live session's Steam Controller 2 as-is passthrough (`settings.sc2Capture` &&
|
||||
/// `settings.gamepadForwarding`) — built beside GamepadCapture/GamepadFeedback in
|
||||
/// `beginStreaming`, torn down in `disconnect` in the Android order (unhook the hidRaw
|
||||
/// sink → feedback stops → capture stops, which also frees its wire index).
|
||||
private var sc2Capture: Sc2Capture?
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
/// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) — created
|
||||
/// by `beginStreaming` when the per-host toggle is on and the host advertises
|
||||
@@ -717,6 +737,27 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The SC2 passthrough's claim/release edges (`Sc2Capture.onPhaseChange`, delivered on
|
||||
/// main). A claim shows the badge briefly — motion-hint style; a release drops it at
|
||||
/// once, because a badge still saying "passing through" over a released slot would be
|
||||
/// exactly the silent lie the badge exists to prevent.
|
||||
private func noteSc2Phase(_ phase: Sc2Capture.Phase) {
|
||||
sc2HintTimer?.cancel()
|
||||
switch phase {
|
||||
case .captured:
|
||||
sc2CapturedHint = true
|
||||
sc2HintTimer = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.sc2CapturedHint = false
|
||||
}
|
||||
case .released:
|
||||
sc2CapturedHint = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute —
|
||||
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
|
||||
/// changed, the other still holds, so returning from the background can't un-mute a user who
|
||||
@@ -856,6 +897,22 @@ final class SessionModel: ObservableObject {
|
||||
// connection is still up); the feedback drain joins off-main like audio.
|
||||
gamepadCapture?.stop()
|
||||
gamepadCapture = nil
|
||||
#if os(iOS) || os(macOS)
|
||||
// Android's teardown order: unhook the hidRaw sink first (no raw replay onto a dying
|
||||
// capture), then the capture — its stop sends gamepadRemove and frees the wire index
|
||||
// while the connection is still up. The feedback drain joins off-main below.
|
||||
gamepadFeedback?.setHidRawSink(nil)
|
||||
sc2Capture?.stop()
|
||||
sc2Capture = nil
|
||||
// The stop path CANNOT rely on the capture's `.released` edge: `sc2Capture = nil`
|
||||
// above deallocates it before its main-queue release hop runs, so the weakly-held
|
||||
// callback is already gone. Clear the badge directly — same cancel-before-clear
|
||||
// discipline as the motion hint above, and same reason: a "passing through" badge
|
||||
// carried into the next stream would be a lie about a session that no longer exists.
|
||||
sc2HintTimer?.cancel()
|
||||
sc2HintTimer = nil
|
||||
sc2CapturedHint = false
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
remotePointer?.stop() // releases any held click while the connection is still up
|
||||
remotePointer = nil
|
||||
@@ -1048,6 +1105,31 @@ final class SessionModel: ObservableObject {
|
||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||
feedback.start()
|
||||
gamepadFeedback = feedback
|
||||
#if os(iOS) || os(macOS)
|
||||
// Steam Controller 2 as-is passthrough (opt-in): capture an OS-paired SC2's vendor GATT
|
||||
// service and forward its raw reports — the host mirrors a real 28DE:1302 that its
|
||||
// Steam drives directly, and Steam's rumble/settings writes come back through the
|
||||
// feedback drain's hidRaw sink onto the physical controller. Gated like Android
|
||||
// (StreamScreen's `sc2Capture && gamepadForwarding`); started after GamepadFeedback so
|
||||
// the sink's drain is already up. The wire slot is claimed lazily on the first state
|
||||
// report, so an absent controller costs nothing but the 2 s acquisition poll.
|
||||
// Also grant-gated (per-client access §7, the clipboard's "not asking keeps the UI
|
||||
// honest" rule): in a controller-excluded session the connection would silently drop
|
||||
// the arrival and every report — holding the BLE radio, claiming a wire slot, and
|
||||
// showing a "passing through" badge for a passthrough that cannot happen. A grant
|
||||
// added mid-session engages on the next stream, exactly like the clipboard.
|
||||
if settings.sc2Capture, settings.gamepadForwarding, conn.canSendGamepad {
|
||||
let sc2 = Sc2Capture(connection: conn, manager: .shared)
|
||||
// The same escape-chord contract as GamepadCapture — a captured SC2's raw feed
|
||||
// bypasses GC entirely, so it brings its own way out of the stream.
|
||||
sc2.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
// Claim/release → the bottom-stack badge (the capture's only UI surface).
|
||||
sc2.onPhaseChange = { [weak self] phase in self?.noteSc2Phase(phase) }
|
||||
feedback.setHidRawSink(sc2.onHidRaw)
|
||||
sc2.start()
|
||||
sc2Capture = sc2
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
|
||||
// hosts never see a ClipControl) AND granted to this device (per-client access §5 —
|
||||
|
||||
@@ -392,6 +392,31 @@ struct MotionUnreachableBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Steam Controller passthrough badge — the SC2 capture's claim edge made visible.
|
||||
/// It is the capture's ONLY UI surface: the raw BLE device never enters GameController, so
|
||||
/// the Controllers page cannot list it, and without this the pad's arrival is indistinguishable
|
||||
/// from the setting being off. Transient like the motion hint it stacks with (shown on the
|
||||
/// claim — stream start or a mid-session power-on — dropped early on release).
|
||||
struct Sc2CapturedBadge: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.green)
|
||||
Text("Steam Controller passing through")
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(
|
||||
"Steam Controller connected — passing through to the host as itself.")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The session's access chip (per-client access §7) — "Controller only · ends in 1 h 58 m".
|
||||
/// Rides over the stream for the life of a LIMITED session, at every stats tier and with the
|
||||
|
||||
@@ -785,6 +785,19 @@ extension SettingsView {
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
// Steam Controller 2 as-is passthrough — device tier like the pad rows above
|
||||
// (EffectiveSettings.sc2Capture: deliberately not profileable, it is about
|
||||
// hardware THIS device captures). tvOS has no CoreBluetooth capture path.
|
||||
// The capture engages at the next stream; the in-stream badge announces it.
|
||||
// One clause of what it does + one of what it costs (the caption rule); the
|
||||
// opening clause is Android's word-for-word, the rider is the Apple-only cost.
|
||||
described("Stream a Steam Controller 2 as-is; needs Bluetooth access.",
|
||||
field: "sc2_capture") {
|
||||
Toggle("Steam Controller 2 passthrough", isOn: $sc2Capture)
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
described("The virtual pad the host creates — Automatic matches your controller.",
|
||||
field: "gamepad") {
|
||||
|
||||
@@ -119,6 +119,10 @@ struct SettingsView: View {
|
||||
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||||
@State var customMode = false
|
||||
#endif
|
||||
/// Steam Controller 2 passthrough (device tier — the controllers section's row; the row
|
||||
/// itself is #if os(iOS)||os(macOS), so the storage sits OUTSIDE the iOS-only block
|
||||
/// above — declared there it is invisible to the macOS build that reads it.
|
||||
@AppStorage(DefaultsKey.sc2Capture) var sc2Capture = false
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
||||
/// Cross-client `inhibit_shortcuts` — here, the ⌘-chord passthrough (⌘Q & co. reach the host
|
||||
|
||||
@@ -213,6 +213,23 @@ public extension PunktfunkConnection {
|
||||
}
|
||||
|
||||
public final class PunktfunkConnection {
|
||||
/// One-shot ABI version-equality gate (`static let` = dispatch_once), touched before the
|
||||
/// first C call a connection makes. This enforces the guard the v27 `PunktfunkHidOutput`
|
||||
/// widening's safety argument rests on: `punktfunk_abi_version()` mismatch has always meant
|
||||
/// "incompatible core", and a mismatched header/library pairing must fail HERE, loudly —
|
||||
/// not later as a 19-vs-85-byte memory write when the core poll-fills the hidout out-slot.
|
||||
/// In-tree the xcframework bundles header + staticlib from one build, so this can only fire
|
||||
/// on a stale or hand-assembled bundle; out-of-tree embedders keep the documented checklist.
|
||||
private static let abiVersionGate: Void = {
|
||||
let linked = punktfunk_abi_version()
|
||||
let built = UInt32(PUNKTFUNK_ABI_VERSION)
|
||||
if linked != built {
|
||||
fatalError(
|
||||
"punktfunk-core ABI mismatch: header is v\(built), linked core is v\(linked)"
|
||||
+ " — rebuild the xcframework")
|
||||
}
|
||||
}()
|
||||
|
||||
private var handle: OpaquePointer?
|
||||
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
||||
/// next poll boundary and exit, so close() can't be starved by back-to-back polls
|
||||
@@ -813,6 +830,7 @@ public final class PunktfunkConnection {
|
||||
deviceName: String? = nil, // nil = this device's OS name (`DeviceName.current`)
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
_ = Self.abiVersionGate // version-equality guard — fail loudly before the first C call
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
// Why a failed connect failed (PunktfunkStatus): lets a typed host rejection
|
||||
@@ -1378,9 +1396,11 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
||||
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
||||
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
||||
/// One HID-output feedback event a game wrote to the host's virtual pad — replay it on the
|
||||
/// real controller (GCDeviceLight, GCControllerPlayerIndex, GCDualSenseAdaptiveTrigger for
|
||||
/// the DualSense kinds; the SC2 capture's GATT writes for `.hidRaw`). Only a `.dualSense`
|
||||
/// session emits the first three; only an as-is Steam Controller 2 passthrough pad emits
|
||||
/// `.hidRaw`.
|
||||
public enum HidOutputEvent: Sendable, Equatable {
|
||||
/// Lightbar color.
|
||||
case led(pad: UInt8, r: UInt8, g: UInt8, b: UInt8)
|
||||
@@ -1390,12 +1410,19 @@ public final class PunktfunkConnection {
|
||||
/// trigger parameter block (mode byte + params, ≤ 11 bytes) — parse with
|
||||
/// `DualSenseTriggerEffect`.
|
||||
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
|
||||
/// A raw report the host's hidraw consumer (Steam) wrote to an as-is passthrough pad,
|
||||
/// to replay verbatim on the physical device: `kind` is `PUNKTFUNK_HID_RAW_OUTPUT` (0,
|
||||
/// an OUTPUT report — Triton rumble/haptics) or `PUNKTFUNK_HID_RAW_FEATURE` (1, a
|
||||
/// SET_REPORT — lizard mode, IMU enable); `data` the full report, id byte first, ≤ 64
|
||||
/// bytes (feature frames arrive whole/zero-padded by design).
|
||||
case hidRaw(pad: UInt8, kind: UInt8, data: [UInt8])
|
||||
}
|
||||
|
||||
/// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive
|
||||
/// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the
|
||||
/// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's
|
||||
/// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) — poll with a
|
||||
/// Pull the next HID-output feedback event (lightbar / player LEDs / adaptive triggers —
|
||||
/// or a raw `.hidRaw` report on an SC2 passthrough pad); nil on timeout, throws `.closed`
|
||||
/// once the session ended. Drain from the (single) feedback thread, alongside `nextRumble`.
|
||||
/// Nothing arrives unless a pad's virtual device is a DualSense (the first three), a
|
||||
/// DualShock 4 (lightbar only), or an as-is Steam Controller 2 (`.hidRaw`) — poll with a
|
||||
/// short timeout, never spin.
|
||||
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
|
||||
feedbackLock.lock()
|
||||
@@ -1416,6 +1443,11 @@ public final class PunktfunkConnection {
|
||||
let len = Int(min(out.effect_len, UInt8(PUNKTFUNK_HID_EFFECT_MAX)))
|
||||
let effect = withUnsafeBytes(of: out.effect) { Array($0.prefix(len)) }
|
||||
return .triggerEffect(pad: out.pad, which: out.which, effect: effect)
|
||||
case PUNKTFUNK_HIDOUT_HID_RAW:
|
||||
// Same tuple-copy idiom for the raw report body (ABI v27).
|
||||
let len = Int(min(out.raw_len, UInt8(PUNKTFUNK_HID_REPORT_MAX)))
|
||||
let data = withUnsafeBytes(of: out.raw) { Array($0.prefix(len)) }
|
||||
return .hidRaw(pad: out.pad, kind: out.hid_kind, data: data)
|
||||
default:
|
||||
return nil // unknown kind from a newer host — skip (forward-compatible)
|
||||
}
|
||||
@@ -1705,6 +1737,26 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_send_rich_input(h, &rich)
|
||||
}
|
||||
|
||||
/// Send one raw HID input report from a client-captured controller — the as-is Steam
|
||||
/// Controller 2 passthrough's up direction (`[0xCC][0x04]` on the wire; ABI v27,
|
||||
/// `punktfunk_connection_send_hid_report`). `data` is the report id-first, exactly as the
|
||||
/// device produced it; the core clamps to `PUNKTFUNK_HID_REPORT_MAX` and copies before
|
||||
/// returning, so the pointer may target a caller-owned REUSABLE buffer — this runs at the
|
||||
/// controller's own report rate (~66 Hz over BLE) and must not allocate per call. Best-effort
|
||||
/// non-blocking enqueue (state reports are idempotent snapshots); pointless unless the pad
|
||||
/// declared `.steamController2` — the host drops it elsewhere. Thread-safe; silently dropped
|
||||
/// after close, and gated on the GAMEPAD grant like every other controller send.
|
||||
public func sendHidReport(pad: UInt8, _ data: UnsafeRawBufferPointer) {
|
||||
guard let base = data.baseAddress, !data.isEmpty else { return }
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
// Raw pad input rides the GAMEPAD grant (it IS controller input) — same gate as `send`.
|
||||
guard let h = handle, !closeRequested, granted(Self.grantGamepad, handle: h)
|
||||
else { return }
|
||||
_ = punktfunk_connection_send_hid_report(
|
||||
h, pad, base.assumingMemoryBound(to: UInt8.self), UInt(data.count))
|
||||
}
|
||||
|
||||
// MARK: - Shared clipboard (design/clipboard-and-file-transfer.md §5)
|
||||
|
||||
/// One advertised clipboard format in a lazy offer — the format list crosses the wire,
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
// queue; the drain thread itself touches neither (it routes rumble to the pad's renderer under a
|
||||
// lock and hops HID to main). When a controller leaves the forwarded set the old pad is reset
|
||||
// (triggers off, player index unset) and its renderer silenced.
|
||||
//
|
||||
// One 0xCD kind never reaches main at all: `.hidRaw` — Steam's raw writes to an as-is Steam
|
||||
// Controller 2 passthrough pad — routes on the drain thread straight to the registered
|
||||
// `setHidRawSink` (the SC2 capture's replay entry point), because no GameController profile is
|
||||
// involved and the rumble replay cadence (25–40 ms) should not queue behind main.
|
||||
|
||||
import Combine
|
||||
import CoreHaptics
|
||||
@@ -52,6 +57,14 @@ public final class GamepadFeedback {
|
||||
private let routingLock = NSLock()
|
||||
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
||||
|
||||
/// The raw-report sink for `.hidRaw` events — the Steam Controller 2 capture's
|
||||
/// `Sc2Capture.onHidRaw(pad:kind:data:)`, registered by the session owner. Guarded by
|
||||
/// `routingLock` (set on the main actor, read on the drain thread). Routed WITHOUT the
|
||||
/// main-actor hop the led/trigger path takes: Steam replays rumble at a 25–40 ms cadence,
|
||||
/// the sink filters by pad itself, and its GATT write hops to the BLE queue anyway. nil
|
||||
/// (no capture registered) drops the event — the shape every unroutable feedback takes.
|
||||
private var hidRawSink: ((_ pad: UInt8, _ kind: UInt8, _ data: [UInt8]) -> Void)?
|
||||
|
||||
/// Opt-in device mirror (`DefaultsKey.rumbleOnDevice`, iPhone only): rumble the host
|
||||
/// addresses to controller 1 (wire pad 0) is ALSO rendered on this device's own Taptic
|
||||
/// Engine — for phone-clip pads that ship without rumble motors, where the phone body is the
|
||||
@@ -211,6 +224,7 @@ public final class GamepadFeedback {
|
||||
let renderers = withRouting { () -> [RumbleRenderer] in
|
||||
let r = Array(rumbleByPad.values)
|
||||
rumbleByPad.removeAll()
|
||||
hidRawSink = nil // no raw replay onto a capture the session owner is tearing down
|
||||
return r
|
||||
}
|
||||
for r in renderers { r.stop() }
|
||||
@@ -250,7 +264,23 @@ public final class GamepadFeedback {
|
||||
return body()
|
||||
}
|
||||
|
||||
/// Register (or clear) the `.hidRaw` sink — the session owner wires `Sc2Capture.onHidRaw`
|
||||
/// here beside starting the capture, and clears it BEFORE stopping either side (the
|
||||
/// Android teardown order: unhook → feedback.stop → capture.stop).
|
||||
public func setHidRawSink(
|
||||
_ sink: ((_ pad: UInt8, _ kind: UInt8, _ data: [UInt8]) -> Void)?
|
||||
) {
|
||||
withRouting { hidRawSink = sink }
|
||||
}
|
||||
|
||||
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||
// Raw SC2 reports stay on the drain thread — no GameController profile is touched, and
|
||||
// the main-actor hop would only add latency to Steam's 25–40 ms rumble resends.
|
||||
if case let .hidRaw(pad, kind, data) = ev {
|
||||
let sink = withRouting { hidRawSink }
|
||||
sink?(pad, kind, data)
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
MainActor.assumeIsolated { self.apply(ev) }
|
||||
}
|
||||
@@ -275,6 +305,10 @@ public final class GamepadFeedback {
|
||||
if let trigger = adaptiveTrigger(slot.controller, which) {
|
||||
parsed.apply(to: trigger)
|
||||
}
|
||||
case .hidRaw:
|
||||
// Routed on the drain thread (`render`) straight to the registered sink — a raw
|
||||
// report never touches a GameController profile, so it never reaches this actor.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,13 @@ public final class GamepadManager: ObservableObject {
|
||||
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
|
||||
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
|
||||
|
||||
/// Wire pad indices reserved by EXTERNAL (non-GameController) captures — today the Steam
|
||||
/// Controller 2 BLE passthrough (`Sc2Capture`), whose device GameController never surfaces
|
||||
/// and so can never appear in the identity-keyed table above. Sharing ONE allocator
|
||||
/// (`takenIndices` feeds both `assignPadIndices` and `reserveExternalPadIndex`) is what
|
||||
/// makes a GC pad and an external capture unable to collide on an index.
|
||||
private var externalIndices: Set<UInt8> = []
|
||||
|
||||
/// The kind of the last controller that was actually attached — persisted under
|
||||
/// `DefaultsKey.lastGamepadKind` and deliberately NEVER cleared on disconnect. The gamepad
|
||||
/// UI's legends read it (through `GamepadGlyphs`) whenever `active` is nil, so a DualSense
|
||||
@@ -258,11 +265,32 @@ public final class GamepadManager: ObservableObject {
|
||||
for dc in next {
|
||||
let key = ObjectIdentifier(dc.controller)
|
||||
guard padIndexByController[key] == nil,
|
||||
let free = Self.lowestFreeIndex(Set(padIndexByController.values)) else { continue }
|
||||
let free = Self.lowestFreeIndex(takenIndices()) else { continue }
|
||||
padIndexByController[key] = free
|
||||
}
|
||||
}
|
||||
|
||||
/// Every index currently in use — the GC table's plus the external reservations. The one
|
||||
/// set both allocation paths consult.
|
||||
private func takenIndices() -> Set<UInt8> {
|
||||
Set(padIndexByController.values).union(externalIndices)
|
||||
}
|
||||
|
||||
/// Reserve the lowest free wire pad index for an external (non-GameController) capture —
|
||||
/// `Sc2Capture` claims through here on its first state report. Held until
|
||||
/// `releaseExternalPadIndex(_:)`; nil when all `GamepadWire.maxPads` indices are taken (the
|
||||
/// caller drops reports until one frees).
|
||||
public func reserveExternalPadIndex() -> UInt8? {
|
||||
guard let free = Self.lowestFreeIndex(takenIndices()) else { return nil }
|
||||
externalIndices.insert(free)
|
||||
return free
|
||||
}
|
||||
|
||||
/// Hand an external reservation back (link drop / capture stop). Idempotent.
|
||||
public func releaseExternalPadIndex(_ index: UInt8) {
|
||||
externalIndices.remove(index)
|
||||
}
|
||||
|
||||
/// The lowest wire pad index not already taken, or nil when all `GamepadWire.maxPads` are in
|
||||
/// use (pf-client-core's `lowest_free_index`).
|
||||
private static func lowestFreeIndex(_ taken: Set<UInt8>) -> UInt8? {
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
// CoreBluetooth transport for a Steam Controller 2 paired directly with this device — the thin
|
||||
// hardware shim under `Sc2Capture`, and the ONLY file here that imports CoreBluetooth (every
|
||||
// table/framing rule it applies lives device-free in `Sc2Device`, where tests reach it). The
|
||||
// acquisition, subscription and write paths are the ones proven against real hardware on the
|
||||
// bench (2026-06-08/09): OS-paired acquisition via the connected set, the per-report output
|
||||
// characteristics, and feature writes on 100F6C34.
|
||||
//
|
||||
// OS-paired is FINE: the controller is normally connected via iOS/macOS Settings, holding the
|
||||
// standard HID (0x1812) binding — we open our own handle to the CUSTOM Valve service, which
|
||||
// coexists with it (exactly what Steam Link does). "Lizard mode" is just the controller's
|
||||
// default reporting, cleared by the disable-lizard write — not a barrier to the vendor service.
|
||||
//
|
||||
// Simulator has no BLE radio — physical device only. tvOS is excluded for now (the shared
|
||||
// Info.plist carries the usage strings harmlessly; this file simply doesn't compile there).
|
||||
//
|
||||
// ⚠ DIFFER from Android's `Sc2BleLink.kt`: no `requestMtu(100)` / connection-priority-HIGH
|
||||
// equivalents — CoreBluetooth negotiates MTU and connection interval itself; the
|
||||
// `maximumWriteValueLength` census line is a sanity log only. The link itself sends
|
||||
// DISABLE_LIZARD on ready and re-sends it every ~3 s (Android's cadence — SDL's): the host's
|
||||
// virtual pad only relays what Steam sends AFTER it claims the pad, so until then nothing else
|
||||
// would feed the firmware watchdog and the controller would fall back to lizard mode. The client
|
||||
// NEVER self-enables the gyro — Steam's own forwarded write drives `Sc2ImuGate`.
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
private let log = ClientLog(category: "gamepad")
|
||||
|
||||
final class Sc2BleLink: NSObject {
|
||||
/// Per-frame / per-write diagnostics (raw hex, per-characteristic props). Lifecycle
|
||||
/// milestones (acquire/connect/census/ready/disconnect) always log; flip this only to debug
|
||||
/// the BLE seam.
|
||||
private static let verbose = false
|
||||
|
||||
private let serviceCB = CBUUID(string: Sc2Device.serviceUUID)
|
||||
private let inputCB = CBUUID(string: Sc2Device.inputCharUUID)
|
||||
private let reportCB = CBUUID(string: Sc2Device.reportCharUUID)
|
||||
/// Report 0x47's characteristic. Never subscribed (see `Sc2Device.timestampCharUUID`); held
|
||||
/// here only so the sweep can exclude a known-purpose characteristic by UUID.
|
||||
private let timestampCB = CBUUID(string: Sc2Device.timestampCharUUID)
|
||||
/// Device Information — every BLE device exposes it, which is what makes it the handle for
|
||||
/// `retrieveConnectedPeripherals` (the OS-paired controller is NOT advertising).
|
||||
private let deviceInfoCB = CBUUID(string: "180A")
|
||||
|
||||
/// The serial queue every delegate callback and every state mutation runs on — owned by
|
||||
/// `Sc2Capture`, USER_INTERACTIVE because SDL's hid.m warns BLE packets are silently dropped
|
||||
/// if the consumer stalls.
|
||||
private let queue: DispatchQueue
|
||||
/// One incoming report, already id-first framed (`Sc2Device.frameIncoming`) — on `queue`.
|
||||
private let onReport: ([UInt8]) -> Void
|
||||
/// The controller disconnected (powered off / out of range) — on `queue`. The link keeps
|
||||
/// re-acquiring by itself; this only tells the capture to release its slot.
|
||||
private let onClosed: () -> Void
|
||||
|
||||
// All state below is touched ONLY on `queue`.
|
||||
private var central: CBCentralManager?
|
||||
private var controller: CBPeripheral?
|
||||
private var inputChar: CBCharacteristic?
|
||||
private var reportChar: CBCharacteristic?
|
||||
/// uuid (lowercase) → characteristic, for the per-report output routing.
|
||||
private var allChars: [String: CBCharacteristic] = [:]
|
||||
/// Writable characteristics in discovery order — the sweep fallback for a firmware whose
|
||||
/// output chars are not at id+0x35.
|
||||
private var candidateChars: [CBCharacteristic] = []
|
||||
private var scanning = false
|
||||
private var polling = false
|
||||
private var ready = false
|
||||
private var lizardTimer: DispatchSourceTimer?
|
||||
private var lizardSends = 0
|
||||
private var sweepCounter = 0
|
||||
private var inCounter = 0
|
||||
/// Output report ids already reported as having no characteristic on this firmware — the
|
||||
/// unknown-firmware signal is worth exactly one line per id, not one per resend at 25-40 ms.
|
||||
private var unmappedIds: Set<UInt8> = []
|
||||
|
||||
init(
|
||||
queue: DispatchQueue,
|
||||
onReport: @escaping ([UInt8]) -> Void,
|
||||
onClosed: @escaping () -> Void
|
||||
) {
|
||||
self.queue = queue
|
||||
self.onReport = onReport
|
||||
self.onClosed = onClosed
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Start (or restart) acquisition. Idempotent; safe from any thread. Scanning begins once
|
||||
/// the central reports poweredOn.
|
||||
func start() {
|
||||
queue.async { [self] in
|
||||
guard central == nil else { return }
|
||||
scanning = false
|
||||
polling = false
|
||||
ready = false
|
||||
controller = nil
|
||||
central = CBCentralManager(delegate: self, queue: queue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop notifications, disconnect, and tear the central down. Idempotent; safe from any
|
||||
/// thread. Does not fire `onClosed` — the caller is the one tearing down.
|
||||
func stop() {
|
||||
queue.async { [self] in
|
||||
stopLizardTimer()
|
||||
if let inputChar, let controller {
|
||||
controller.setNotifyValue(false, for: inputChar)
|
||||
}
|
||||
if let controller {
|
||||
central?.cancelPeripheralConnection(controller)
|
||||
}
|
||||
central?.stopScan()
|
||||
controller = nil
|
||||
inputChar = nil
|
||||
reportChar = nil
|
||||
allChars.removeAll()
|
||||
candidateChars.removeAll()
|
||||
scanning = false
|
||||
polling = false
|
||||
ready = false
|
||||
central = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay one raw host report on the physical controller. `kind` is the C ABI's
|
||||
/// `PUNKTFUNK_HID_RAW_OUTPUT` (0) / `PUNKTFUNK_HID_RAW_FEATURE` (1); `frame` is id-first,
|
||||
/// exactly as Steam wrote it. Safe from any thread — `frame` is an owned copy, and the
|
||||
/// resolution + GATT write happen on the BLE queue.
|
||||
func writeRaw(kind: UInt8, frame: [UInt8]) {
|
||||
queue.async { [self] in
|
||||
guard let controller else { return }
|
||||
let target: CBCharacteristic?
|
||||
let payload: [UInt8]
|
||||
if kind == 0 {
|
||||
// OUTPUT: per-report characteristic, id stripped, payload trimmed to the
|
||||
// declared length (Sc2Device.outputWrite).
|
||||
guard let write = Sc2Device.outputWrite(frame: frame) else { return }
|
||||
payload = write.payload
|
||||
target = allChars[write.charUUID] ?? sweepCandidate(id: frame[0])
|
||||
// A miss means this firmware does not put report `id` at 100F6C<id+0x35>. There
|
||||
// is no safe way to recover it live — ten output characteristics share one
|
||||
// property mask and nothing distinguishes rumble from a trackpad pulse, so any
|
||||
// guess is as likely to drive the wrong actuator as the right one. Drop the
|
||||
// write and say so ONCE per id: with the connect-time GATT census above, a log
|
||||
// bundle from one affected pad is enough to add its ids to
|
||||
// `Sc2Device.outputCharUUID` — the map is the only reliable fix.
|
||||
if target == nil, unmappedIds.insert(frame[0]).inserted {
|
||||
let id = String(format: "%02x", frame[0])
|
||||
log.warning("SC2: firmware has no characteristic for output id 0x\(id) (expected \(write.charUUID)) — dropping it; that actuator stays silent on this pad")
|
||||
}
|
||||
} else {
|
||||
// FEATURE: strip the 0x01 channel report-id, write to 100F6C34 whole
|
||||
// (zero-padding included — the firmware accepts the padded form).
|
||||
guard let stripped = Sc2Device.featurePayload(frame: frame) else { return }
|
||||
payload = stripped
|
||||
target = reportChar
|
||||
}
|
||||
guard let target else { return }
|
||||
if Self.verbose {
|
||||
let hex = payload.prefix(13).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
log.debug("SC2 write kind=\(kind) id=0x\(String(format: "%02x", frame[0])) \(payload.count)B: \(hex)")
|
||||
}
|
||||
// OUTPUT prefers unacked writes (the 25-40 ms rumble resend rate must not queue
|
||||
// behind acks); FEATURE prefers acked. Either way honor what the char offers.
|
||||
let wantAck = kind != 0
|
||||
let type: CBCharacteristicWriteType
|
||||
if wantAck {
|
||||
type = target.properties.contains(.write) ? .withResponse : .withoutResponse
|
||||
} else {
|
||||
type = target.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
|
||||
}
|
||||
controller.writeValue(Data(payload), for: target, type: type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Firmware without a per-report characteristic at id+0x35: rotate through the writable
|
||||
/// candidates (~1 s each at the 25 Hz resend rate) to find where its outputs live. This is
|
||||
/// how the real map was found on the bench, and it stays for the next unknown firmware.
|
||||
///
|
||||
/// ⚠ BENCH TOOL — gated off in shipping builds, because it does not converge. Nothing here
|
||||
/// observes whether a write produced haptics, so there is no success signal to latch: the
|
||||
/// rotation runs for the life of the session, and on an unknown firmware MOST output frames
|
||||
/// land on the wrong characteristic forever rather than eventually settling on the right one
|
||||
/// (with N candidates the correct one is live 1 second in every N). That is not a fallback
|
||||
/// that degrades gracefully — losing haptics on such a pad is the better failure than feeding
|
||||
/// output payloads to parsers we have not identified. Flip `verbose` to map a new firmware,
|
||||
/// then add its ids to `Sc2Device.outputCharUUID`.
|
||||
private func sweepCandidate(id: UInt8) -> CBCharacteristic? {
|
||||
guard Self.verbose, !candidateChars.isEmpty else { return nil }
|
||||
let index = (sweepCounter / 25) % candidateChars.count
|
||||
if sweepCounter % 25 == 0 {
|
||||
log.info("SC2: no per-report char for id=0x\(String(format: "%02x", id)) — sweeping candidate \(index)")
|
||||
}
|
||||
sweepCounter += 1
|
||||
return candidateChars[index]
|
||||
}
|
||||
|
||||
// MARK: - Acquisition (mirroring Valve's own SDL hid.m)
|
||||
|
||||
/// PRIMARY: the controller is normally OS-paired and NOT advertising — find it in the
|
||||
/// OS-connected set (Device Information 0x180A, which every BLE device exposes) by name.
|
||||
/// FALLBACK: scan for a first-time/advertising controller. Plus the CRITICAL 2 s re-poll:
|
||||
/// an already-paired controller powered on MID-SESSION connects to the OS without
|
||||
/// advertising, so scan alone never sees it — only the re-polled connected set does.
|
||||
private func acquire() {
|
||||
guard let central, controller == nil else { return }
|
||||
let connected = central.retrieveConnectedPeripherals(withServices: [deviceInfoCB])
|
||||
if let match = connected.first(where: { Self.nameMatches($0.name) }) {
|
||||
log.info("SC2: found OS-connected controller '\(match.name ?? "?")'")
|
||||
controller = match // retain before connecting
|
||||
if scanning {
|
||||
central.stopScan()
|
||||
scanning = false
|
||||
}
|
||||
central.connect(match, options: nil)
|
||||
return
|
||||
}
|
||||
if !scanning {
|
||||
log.info("SC2: no OS-connected Steam controller; scanning + polling")
|
||||
central.scanForPeripherals(withServices: [serviceCB], options: nil)
|
||||
scanning = true
|
||||
}
|
||||
if !polling {
|
||||
polling = true
|
||||
queue.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.polling = false
|
||||
if self.controller == nil, self.central?.state == .poweredOn {
|
||||
self.acquire() // re-poll until the controller appears
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bench-proven "Steam" prefix plus Android's broader hint set — BLE exposes no PID
|
||||
/// here, so the name is all there is.
|
||||
static func nameMatches(_ name: String?) -> Bool {
|
||||
guard let name else { return false }
|
||||
if name.hasPrefix("Steam") { return true }
|
||||
let lowered = name.lowercased()
|
||||
return ["steam ctrl", "steam controller", "steamcontroller", "valve"]
|
||||
.contains { lowered.contains($0) }
|
||||
}
|
||||
|
||||
private func startLizardTimer() {
|
||||
guard lizardTimer == nil else { return }
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(
|
||||
deadline: .now(), repeating: Sc2Device.lizardRefreshSeconds, leeway: .milliseconds(200))
|
||||
timer.setEventHandler { [weak self] in self?.sendLizardOff() }
|
||||
timer.resume()
|
||||
lizardTimer = timer
|
||||
}
|
||||
|
||||
private func stopLizardTimer() {
|
||||
lizardTimer?.cancel()
|
||||
lizardTimer = nil
|
||||
}
|
||||
|
||||
/// Acked DISABLE_LIZARD to the report characteristic — the firmware watchdog re-enables
|
||||
/// lizard mode after a few seconds of silence, so the timer above re-sends on SDL's cadence.
|
||||
/// Framed through `Sc2Device.featurePayload`, the SAME path the host-forwarded feature
|
||||
/// writes take (`writeRaw` kind == 1): the characteristic VALUE carries no 0x01 channel
|
||||
/// report-id — the firmware parses byte 0 as the settings-command id (the hardware-proven
|
||||
/// bench-proven contract) — so writing `disableLizard` whole would arrive as command
|
||||
/// 0x01 instead of 0x87 and silently do nothing. Throttled logging (the write repeats
|
||||
/// every 3 s for the whole session).
|
||||
private func sendLizardOff() {
|
||||
guard let controller, let reportChar,
|
||||
let payload = Sc2Device.featurePayload(frame: Sc2Device.disableLizard)
|
||||
else { return }
|
||||
let type: CBCharacteristicWriteType =
|
||||
reportChar.properties.contains(.write) ? .withResponse : .withoutResponse
|
||||
controller.writeValue(Data(payload), for: reportChar, type: type)
|
||||
lizardSends += 1
|
||||
if lizardSends == 1 || lizardSends % 20 == 0 {
|
||||
log.info("SC2: lizard-off keepalive #\(lizardSends)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension Sc2BleLink: CBCentralManagerDelegate {
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
if central.state == .poweredOn {
|
||||
acquire()
|
||||
} else {
|
||||
log.info("SC2: BLE central state=\(central.state.rawValue) (need poweredOn)")
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(
|
||||
_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any], rssi RSSI: NSNumber
|
||||
) {
|
||||
guard controller == nil else { return }
|
||||
log.info("SC2: discovered '\(peripheral.name ?? "?")' (RSSI \(RSSI))")
|
||||
controller = peripheral // retain before connecting
|
||||
central.stopScan()
|
||||
scanning = false
|
||||
central.connect(peripheral, options: nil)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
log.info("SC2: connected; discovering services")
|
||||
peripheral.delegate = self
|
||||
peripheral.discoverServices([serviceCB])
|
||||
}
|
||||
|
||||
func centralManager(
|
||||
_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?
|
||||
) {
|
||||
log.warning("SC2: connect failed (\(error.map { String(describing: $0) } ?? "?")); retrying")
|
||||
controller = nil
|
||||
if central.state == .poweredOn { acquire() }
|
||||
}
|
||||
|
||||
func centralManager(
|
||||
_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral,
|
||||
error: Error?
|
||||
) {
|
||||
log.info("SC2: disconnected (\(error.map { String(describing: $0) } ?? "clean")) — releasing + re-acquiring")
|
||||
stopLizardTimer()
|
||||
ready = false
|
||||
controller = nil
|
||||
inputChar = nil
|
||||
reportChar = nil
|
||||
allChars.removeAll()
|
||||
candidateChars.removeAll()
|
||||
onClosed() // the capture releases its wire slot + re-arms the IMU gate
|
||||
if central.state == .poweredOn {
|
||||
acquire() // pads power-cycle many times per session — keep polling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension Sc2BleLink: CBPeripheralDelegate {
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
for service in peripheral.services ?? [] {
|
||||
peripheral.discoverCharacteristics(nil, for: service)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(
|
||||
_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
|
||||
error: Error?
|
||||
) {
|
||||
var writable = 0
|
||||
var notifying = 0
|
||||
for ch in service.characteristics ?? [] {
|
||||
let uuid = ch.uuid.uuidString.lowercased()
|
||||
allChars[uuid] = ch
|
||||
if !ch.properties.isDisjoint(with: [.write, .writeWithoutResponse]) {
|
||||
writable += 1
|
||||
// ⚠ A characteristic whose purpose is KNOWN is never a sweep candidate. 100F6C34
|
||||
// is writable (props 0x0a) and parses what it receives as a settings command
|
||||
// (`0x87` = write register/value, the lizard-off and gyro-enable path), so an
|
||||
// output payload swept onto it can persist an arbitrary firmware setting on the
|
||||
// user's own controller. Counted in the census, excluded from the rotation.
|
||||
if ch.uuid != reportCB && ch.uuid != inputCB && ch.uuid != timestampCB {
|
||||
candidateChars.append(ch)
|
||||
}
|
||||
}
|
||||
if ch.properties.contains(.notify) { notifying += 1 }
|
||||
if Self.verbose {
|
||||
log.debug("SC2 char \(uuid) props=0x\(String(ch.properties.rawValue, radix: 16))")
|
||||
}
|
||||
if ch.uuid == inputCB {
|
||||
inputChar = ch
|
||||
peripheral.setNotifyValue(true, for: ch)
|
||||
} else if ch.uuid == reportCB {
|
||||
reportChar = ch
|
||||
if ch.properties.contains(.notify) {
|
||||
peripheral.setNotifyValue(true, for: ch) // feature replies: logged + dropped
|
||||
}
|
||||
}
|
||||
// The 0x47 timestamp char is deliberately NOT subscribed — nothing rides it that the
|
||||
// punktfunk wire needs (the gyro streams inside the same 0x45 report once enabled).
|
||||
}
|
||||
// The on-device census (expected: 17 chars — 1× 0x0a report, 6× 0x12 read+notify,
|
||||
// 10× 0x0e read+write+writeNoResp) + the MTU sanity line CoreBluetooth negotiated.
|
||||
let mtu = peripheral.maximumWriteValueLength(for: .withoutResponse)
|
||||
log.info(
|
||||
"SC2 GATT census: \(allChars.count) chars (\(writable) writable, \(notifying) notify), maxWriteNoRsp=\(mtu)B — input=\(inputChar != nil) report=\(reportChar != nil)")
|
||||
if reportChar != nil {
|
||||
// READY enough to keep the firmware out of lizard mode; input flows once the
|
||||
// subscribe completes.
|
||||
startLizardTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(
|
||||
_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
||||
error: Error?
|
||||
) {
|
||||
if let error {
|
||||
log.warning("SC2: subscribe \(characteristic.uuid.uuidString) failed: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(
|
||||
_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
|
||||
error: Error?
|
||||
) {
|
||||
guard error == nil, let data = characteristic.value, !data.isEmpty else { return }
|
||||
if characteristic.uuid == inputCB {
|
||||
let framed = Sc2Device.frameIncoming([UInt8](data))
|
||||
inCounter += 1
|
||||
if !ready {
|
||||
ready = true
|
||||
log.info("SC2: first input report (\(data.count) B) — device live")
|
||||
}
|
||||
if Self.verbose, inCounter <= 8 || inCounter % 200 == 0 {
|
||||
let hex = data.prefix(32).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
log.debug("SC2 in #\(inCounter) len=\(data.count) raw: \(hex)")
|
||||
}
|
||||
onReport(framed)
|
||||
} else if characteristic.uuid == reportCB {
|
||||
// A feature reply. The HOST's virtual pad answers Steam's feature reads, and no
|
||||
// client→host reply plane exists — log and drop. (A stack whose synthetic pad lives
|
||||
// client-side would instead round-trip these to its own GET_FEATURE handler.)
|
||||
log.info("SC2: feature reply (\(data.count) B) — dropped (host answers Steam)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,355 @@
|
||||
// One captured Steam Controller 2 — the glue between the BLE transport (`Sc2BleLink`) and the
|
||||
// punktfunk wire, modeled on Android's `Sc2Capture.kt` with Apple idioms per `GamepadCapture`:
|
||||
//
|
||||
// - **Raw plane (the point):** every input report is forwarded byte-for-byte
|
||||
// (`PunktfunkConnection.sendHidReport` → the host's as-is virtual 28DE:1302 pad, which Steam
|
||||
// Input drives like the physical controller) — with ONE exception: `Sc2ImuGate` zeroes a
|
||||
// frozen (gyro-off) IMU block out of state reports, so a stale resting sample can't drive
|
||||
// Steam's desktop gyro-mouse (the cursor-fly the bench debugged 2026-06-08).
|
||||
// - **Typed mirror:** buttons/sticks/triggers are ALSO diffed onto the ordinary per-transition
|
||||
// plane, so the emergency exit chord works, and a host that degraded the kind still gets a
|
||||
// playable controller. No rich Motion/Touchpad is ever sent for an SC2 — its IMU rides inside
|
||||
// the opaque raw report; the capture never uses the motion plane.
|
||||
// - **Raw return:** the host's hidraw writes (Steam's 0x80 rumble outputs, lizard/IMU feature
|
||||
// settings) arrive via `GamepadFeedback`'s hidRaw sink → `onHidRaw` → the link, landing on the
|
||||
// real controller's motors/firmware.
|
||||
//
|
||||
// The wire slot is claimed LAZILY on the first parsed state report (`GamepadArrival` pref 9 —
|
||||
// `GamepadPref::SteamController2` — before any input; an idle radio stays invisible to the
|
||||
// host) and released on link drop / suspend / stop, so pad indices never leak. The index comes
|
||||
// from `GamepadManager.reserveExternalPadIndex()` — the SAME lowest-free allocator the
|
||||
// GameController slots use, so an SC2 and a GC pad can never collide.
|
||||
//
|
||||
// No global "SC2 is active" suppression flag exists here (the obvious such design mutes ALL
|
||||
// pads' normal feed — a known trap): on punktfunk there is no double feed by
|
||||
// construction — GameController never surfaces the raw Valve device on Apple, and the BLE
|
||||
// lizard-mode kb/mouse never produces gamepad events — so no suppression wiring exists here.
|
||||
// If GameController ever does surface it, the designed-in idioms are the per-plane source-drop
|
||||
// (the DeviceGyro precedent) and GamepadCapture's single computed `wire` nil-gate; wire one of
|
||||
// those rather than resurrecting a global flag.
|
||||
//
|
||||
// Threading: BLE reports arrive on the link's serial queue; the host's hidRaw replay arrives on
|
||||
// the feedback drain thread; start/stop/suspend run on the main actor. All mutable state sits
|
||||
// behind one lock (the Android port's slot-table contract); the pad-index reservation hops to
|
||||
// the main actor, and reports are dropped until the claim lands (a few frames at ~66 Hz —
|
||||
// idempotent state, nothing missed).
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#else
|
||||
import UIKit
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
private let log = ClientLog(category: "gamepad")
|
||||
|
||||
public final class Sc2Capture {
|
||||
private let connection: PunktfunkConnection
|
||||
private let manager: GamepadManager
|
||||
/// The BLE delegate/report queue — USER_INTERACTIVE (SDL's warning: BLE packets are
|
||||
/// silently dropped if the consumer stalls).
|
||||
private let queue = DispatchQueue(
|
||||
label: "io.unom.punktfunk.sc2-ble",
|
||||
qos: .userInteractive)
|
||||
private var link: Sc2BleLink!
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
/// Guards every field below (see the threading note in the header).
|
||||
private let lock = NSLock()
|
||||
private var padIndex: UInt8?
|
||||
private var claimPending = false
|
||||
private var stopped = false
|
||||
/// App inactive → BLE is released and the slot freed; resume re-acquires (the recommended
|
||||
/// backgrounding behavior for a CoreBluetooth central).
|
||||
private var suspended = false
|
||||
// Typed-mirror diff state (wire units).
|
||||
private var wireButtons: UInt32 = 0
|
||||
private var lastAxis = [Int32](repeating: Int32.min, count: 6)
|
||||
/// Zeroes a frozen (gyro-off) IMU block out of forwarded state reports — see `Sc2ImuGate`.
|
||||
private let imuGate = Sc2ImuGate()
|
||||
/// Reusable up-path buffer: the gated report is copied in and sent from here, so the raw
|
||||
/// plane costs no per-report allocation beyond the link's own framing.
|
||||
private var rawBuf = [UInt8](repeating: 0, count: 64)
|
||||
/// Armed while the escape chord is held (fires `onDisconnectRequest` on main).
|
||||
private var chordWork: DispatchWorkItem?
|
||||
|
||||
/// The cross-client controller escape chord, read off this capture's own typed mirror —
|
||||
/// MUST stay equal to `GamepadCapture.escapeChord` (pinned by `Sc2EscapeChordMirrorTests`;
|
||||
/// re-declared here because the original is main-actor-isolated and this class reads the
|
||||
/// mask on the BLE queue). Held `disconnectHold` it ends the session, so a captured SC2 —
|
||||
/// whose raw feed bypasses GamepadCapture entirely — can still exit the stream.
|
||||
static let escapeChord: UInt32 =
|
||||
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client (and the same value
|
||||
/// as GamepadCapture's private `disconnectHold`; the mirror test pins it).
|
||||
static let disconnectHold: TimeInterval = 1.5
|
||||
|
||||
/// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner
|
||||
/// disconnects (same contract as `GamepadCapture.onDisconnectRequest`).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
/// The capture's claim/release edges, for the stream surface. `captured` fires when the
|
||||
/// wire slot lands (the host is building its virtual SC2 — at stream start, or whenever
|
||||
/// the pad powers on mid-session); `released` on every teardown of a CLAIMED slot
|
||||
/// (power-off, background, stop) and never while unclaimed. Delivered ON MAIN, like
|
||||
/// `onDisconnectRequest` — the capture otherwise leaves no UI trace at all, since the
|
||||
/// device never enters the GameController world the Controllers page lists.
|
||||
public enum Phase: Equatable {
|
||||
case captured(pad: UInt8)
|
||||
case released
|
||||
}
|
||||
/// Fired ON MAIN on `Phase` edges — the session owner surfaces the passthrough badge.
|
||||
public var onPhaseChange: ((Phase) -> Void)?
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
link = Sc2BleLink(
|
||||
queue: queue,
|
||||
onReport: { [weak self] report in self?.handleReport(report) },
|
||||
onClosed: { [weak self] in
|
||||
// Controller powered off / out of range. Release the slot (the punktfunk
|
||||
// analogue of "Steam sees a REAL disconnect": the host tears down /
|
||||
// neutralizes its virtual pad) — the link keeps re-acquiring on its own 2 s
|
||||
// poll, and the next connection re-claims + re-proves its IMU live.
|
||||
self?.releaseSlot(reason: "link closed")
|
||||
})
|
||||
}
|
||||
|
||||
/// Begin acquisition (main actor: it registers the app-lifecycle observers). The wire slot
|
||||
/// is claimed later, on the first state report.
|
||||
@MainActor
|
||||
public func start() {
|
||||
lock.lock()
|
||||
stopped = false
|
||||
suspended = false
|
||||
lock.unlock()
|
||||
#if os(macOS)
|
||||
let resign = NSApplication.willResignActiveNotification
|
||||
let activate = NSApplication.didBecomeActiveNotification
|
||||
#else
|
||||
let resign = UIApplication.willResignActiveNotification
|
||||
let activate = UIApplication.didBecomeActiveNotification
|
||||
#endif
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: resign, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.lock.lock()
|
||||
self.suspended = true
|
||||
self.lock.unlock()
|
||||
// Release BLE while backgrounded (and the slot with it — a host pad frozen on the
|
||||
// last raw state would otherwise hold its buttons for the whole background stay).
|
||||
self.releaseSlot(reason: "app inactive")
|
||||
self.link.stop()
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: activate, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.lock.lock()
|
||||
self.suspended = false
|
||||
let dead = self.stopped
|
||||
self.lock.unlock()
|
||||
if !dead { self.link.start() } // reacquire; the first report re-claims a slot
|
||||
})
|
||||
link.start()
|
||||
}
|
||||
|
||||
/// Tear everything down: link stopped (unsubscribe, cancel, stop scanning), slot released,
|
||||
/// typed state cleared. Idempotent (main actor, like `start`).
|
||||
@MainActor
|
||||
public func stop() {
|
||||
lock.lock()
|
||||
let wasStopped = stopped
|
||||
stopped = true
|
||||
lock.unlock()
|
||||
guard !wasStopped else { return }
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
observers.removeAll()
|
||||
releaseSlot(reason: "stop")
|
||||
link.stop()
|
||||
}
|
||||
|
||||
/// Replay one host raw write on the physical pad — wire this to `GamepadFeedback`'s hidRaw
|
||||
/// sink. Called on the feedback drain thread; `kind` is `PUNKTFUNK_HID_RAW_OUTPUT` (0) /
|
||||
/// `PUNKTFUNK_HID_RAW_FEATURE` (1) and `data` the id-first frame. NO main-actor hop — the
|
||||
/// rumble replay runs at Steam's 25–40 ms resend cadence and the GATT write happens on the
|
||||
/// BLE queue anyway.
|
||||
public func onHidRaw(pad: UInt8, kind: UInt8, data: [UInt8]) {
|
||||
lock.lock()
|
||||
let claimed = padIndex
|
||||
lock.unlock()
|
||||
guard claimed == pad else { return } // addressed to some other controller
|
||||
link.writeRaw(kind: kind, frame: data)
|
||||
}
|
||||
|
||||
// MARK: - Report path (BLE queue)
|
||||
|
||||
private func handleReport(_ framed: [UInt8]) {
|
||||
guard let id = framed.first else { return }
|
||||
// Wireless status is authoritative only through a Puck dongle (USB — out of scope on
|
||||
// Apple); a BLE pad emits it too, truthfully saying "no radio link", and acting on it
|
||||
// tore the slot down 255 ms after creation on Android's first on-glass run. Swallow.
|
||||
if id == Sc2Device.idWireless || id == Sc2Device.idWirelessX { return }
|
||||
var state = Sc2Device.State()
|
||||
var report = framed
|
||||
let isState = Sc2Device.parseState(report, into: &state)
|
||||
lock.lock()
|
||||
if stopped || suspended {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
guard let pad = padIndex else {
|
||||
// Lazy slot claim on the FIRST parsed state report, BEFORE any input. The claim
|
||||
// hops to the main actor; reports (including this one) drop until it lands —
|
||||
// idempotent snapshots at ~66 Hz, nothing is missed. `claimPending` also clears on
|
||||
// a full table (all 16 indices taken), so a later report simply retries.
|
||||
let shouldClaim = isState && !claimPending
|
||||
if shouldClaim { claimPending = true }
|
||||
lock.unlock()
|
||||
if shouldClaim { claimSlot() }
|
||||
return
|
||||
}
|
||||
if !isState {
|
||||
// Battery/status and future report types still belong to the as-is stream.
|
||||
forwardRawLocked(&report, pad: pad)
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
forwardRawLocked(&report, pad: pad)
|
||||
mirrorTypedLocked(state, pad: pad)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Forward one id-first report on the raw plane: IMU-gate in place, copy into the reusable
|
||||
/// buffer, send. Caller holds `lock`.
|
||||
private func forwardRawLocked(_ report: inout [UInt8], pad: UInt8) {
|
||||
imuGate.apply(&report)
|
||||
let n = min(report.count, rawBuf.count)
|
||||
rawBuf.replaceSubrange(0 ..< n, with: report[0 ..< n])
|
||||
rawBuf.withUnsafeBytes { buf in
|
||||
connection.sendHidReport(pad: pad, UnsafeRawBufferPointer(rebasing: buf[0 ..< n]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff the parsed state onto the per-transition plane (buttons + axes, on change only) and
|
||||
/// feed the escape chord. Caller holds `lock`.
|
||||
private func mirrorTypedLocked(_ state: Sc2Device.State, pad: UInt8) {
|
||||
let wired = Sc2Device.wireButtons(state.buttons)
|
||||
var changed = wired ^ wireButtons
|
||||
while changed != 0 {
|
||||
let bit = changed & (~changed &+ 1) // lowest changed bit
|
||||
connection.send(.gamepadButton(bit, down: wired & bit != 0, pad: UInt32(pad)))
|
||||
changed &= ~bit
|
||||
}
|
||||
wireButtons = wired
|
||||
axisLocked(GamepadWire.axisLSX, state.lsX, pad: pad)
|
||||
axisLocked(GamepadWire.axisLSY, state.lsY, pad: pad)
|
||||
axisLocked(GamepadWire.axisRSX, state.rsX, pad: pad)
|
||||
axisLocked(GamepadWire.axisRSY, state.rsY, pad: pad)
|
||||
axisLocked(GamepadWire.axisLT, state.lt, pad: pad)
|
||||
axisLocked(GamepadWire.axisRT, state.rt, pad: pad)
|
||||
updateChordLocked()
|
||||
}
|
||||
|
||||
private func axisLocked(_ id: UInt32, _ value: Int32, pad: UInt8) {
|
||||
let i = Int(id)
|
||||
guard lastAxis[i] != value else { return }
|
||||
lastAxis[i] = value
|
||||
connection.send(.gamepadAxis(id, value: value, pad: UInt32(pad)))
|
||||
}
|
||||
|
||||
/// Arm the disconnect timer while the full chord is held on the typed mirror, disarm on any
|
||||
/// release — GamepadCapture's rule, off this capture's own state. Caller holds `lock`.
|
||||
private func updateChordLocked() {
|
||||
let held = wireButtons & Self.escapeChord == Self.escapeChord
|
||||
if held, chordWork == nil {
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
MainActor.assumeIsolated { self.onDisconnectRequest?() }
|
||||
}
|
||||
chordWork = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.disconnectHold, execute: work)
|
||||
} else if !held, let work = chordWork {
|
||||
work.cancel()
|
||||
chordWork = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Slot lifecycle
|
||||
|
||||
/// Reserve a wire index on the main actor and finish the claim under the lock. On success
|
||||
/// sends `gamepadArrival(pref 9)` — the declaration the host builds the virtual SC2 from —
|
||||
/// before any input can flow on that index.
|
||||
private func claimSlot() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
MainActor.assumeIsolated {
|
||||
let index = self.manager.reserveExternalPadIndex()
|
||||
self.lock.lock()
|
||||
guard self.claimPending else {
|
||||
// The link closed between the report that scheduled this claim and the
|
||||
// hop landing: `releaseSlot` cleared the pending token (it is set nowhere
|
||||
// else while a claim is in flight). Completing now would arm a virtual
|
||||
// pad — and announce a badge — for a dead link, with no `.released` ever
|
||||
// coming. Hand the index straight back instead; a live link's next state
|
||||
// report simply schedules a fresh claim.
|
||||
self.lock.unlock()
|
||||
if let index { self.manager.releaseExternalPadIndex(index) }
|
||||
return
|
||||
}
|
||||
self.claimPending = false
|
||||
if self.stopped || self.suspended {
|
||||
self.lock.unlock()
|
||||
if let index { self.manager.releaseExternalPadIndex(index) }
|
||||
return
|
||||
}
|
||||
guard let index else {
|
||||
// All 16 wire indices taken — drop reports until one frees (a later
|
||||
// report retries).
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.padIndex = index
|
||||
self.lock.unlock()
|
||||
self.connection.send(.gamepadArrival(
|
||||
pref: PunktfunkConnection.GamepadType.steamController2.rawValue,
|
||||
pad: UInt32(index)))
|
||||
log.info("SC2 captured → wire pad \(index) (BLE passthrough, pref 9)")
|
||||
self.onPhaseChange?(.captured(pad: index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Free the wire slot: `gamepadRemove` (the host tears its virtual pad down — no stuck last
|
||||
/// frame), typed-diff state cleared, IMU gate re-armed so whatever connects next re-proves
|
||||
/// its IMU live, index handed back to the shared allocator. Every teardown funnels through
|
||||
/// here (stop, link drop, suspend). Safe from any thread; no-op while unclaimed.
|
||||
private func releaseSlot(reason: String) {
|
||||
lock.lock()
|
||||
let index = padIndex
|
||||
padIndex = nil
|
||||
claimPending = false
|
||||
wireButtons = 0
|
||||
for i in lastAxis.indices { lastAxis[i] = Int32.min }
|
||||
imuGate.reset()
|
||||
let chord = chordWork
|
||||
chordWork = nil
|
||||
lock.unlock()
|
||||
chord?.cancel()
|
||||
guard let index else { return }
|
||||
connection.send(.gamepadRemove(pad: UInt32(index)))
|
||||
DispatchQueue.main.async { [weak self, manager] in
|
||||
MainActor.assumeIsolated {
|
||||
manager.releaseExternalPadIndex(index)
|
||||
self?.onPhaseChange?(.released)
|
||||
}
|
||||
}
|
||||
log.info("SC2: wire pad \(index) released (\(reason))")
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,257 @@
|
||||
// Steam Controller 2 (2026, Valve "Ibex" / SDL "Triton", wired 28DE:1302) protocol constants +
|
||||
// every piece of pure SC2 logic the client needs — framing, the per-report characteristic map,
|
||||
// the feature commands, and the light state parser. Cross-client parity: this file is the Apple
|
||||
// sibling of Android's `Sc2Device.kt`, and the transport facts are the ones proven against
|
||||
// real hardware on the bench (on-glass 2026-06-08/09: live input end-to-end
|
||||
// after the report-id prepend fix, gyro-enable forwarded over GATT, and full multi-actuator
|
||||
// haptics via the per-report characteristic map).
|
||||
//
|
||||
// The GATT service is Valve's CUSTOM vendor service, NOT standard HID-over-GATT (0x1812) —
|
||||
// which is exactly why a third-party app may read it raw: iOS keeps its own 0x1812 binding (the
|
||||
// lizard-mode keyboard/mouse) while we open a second handle to the vendor service, the same
|
||||
// coexistence Steam Link relies on. The full report rides the punktfunk wire verbatim
|
||||
// (`PunktfunkConnection.sendHidReport` → the host's as-is virtual 28DE:1302 pad); the parser
|
||||
// here extracts only what the client itself consumes — the button word for the typed mirror +
|
||||
// exit chord, and sticks/triggers for the degrade path.
|
||||
//
|
||||
// Everything in this file is device-free by design (no CoreBluetooth import), so the tables and
|
||||
// framing rules are pinned by unit tests that run on any Mac — `Sc2DeviceTests` /
|
||||
// `Sc2FramingTests` — the same convention as `DualSenseHID`'s report builders.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Sc2Device {
|
||||
// MARK: - GATT topology (Valve vendor service; cf. Android's `Sc2BleLink.kt`)
|
||||
|
||||
/// The custom Valve service every SC2 exposes over BLE.
|
||||
static let serviceUUID = "100F6C32-1735-4313-B402-38567131E5F3"
|
||||
/// Input characteristic (notify): report 0x45, a bare 45-byte state payload — the
|
||||
/// characteristic VALUE carries NO report-id byte (the id is implied by the UUID), which is
|
||||
/// the framing root cause debugged on-glass 2026-06-09: without re-prepending 0x45 an
|
||||
/// id-keyed consumer drops ~every frame. See `frameIncoming`.
|
||||
static let inputCharUUID = "100F6C7A-1735-4313-B402-38567131E5F3"
|
||||
/// Timestamp characteristic (notify): report 0x47. Deliberately NOT subscribed — the bench
|
||||
/// stack subscribed it and then ignored every 0x47, Android never subscribes it, and the gyro is
|
||||
/// hardware-proven NOT to ride it (the IMU streams inside the SAME 0x45 report once enabled,
|
||||
/// bench 2026-06-08). The constant stays for the connect-time characteristic census log.
|
||||
static let timestampCharUUID = "100F6C7C-1735-4313-B402-38567131E5F3"
|
||||
/// Report/feature characteristic (write/read; notify on some firmware): where FEATURE
|
||||
/// commands land — the hardware-proven gyro-enable/lizard-off path. Props 0x0a on-device.
|
||||
static let reportCharUUID = "100F6C34-1735-4313-B402-38567131E5F3"
|
||||
|
||||
/// Per-OUTPUT-report characteristic: Valve routes each output report id 0xNN to its OWN
|
||||
/// characteristic `100F6C<NN+0x35>` (the id only SELECTS the characteristic and is stripped
|
||||
/// from the written payload). Lowercase, the form CoreBluetooth's `CBUUID.uuidString` is
|
||||
/// compared against case-insensitively. Verified per-actuator on-device 2026-06-09 — the
|
||||
/// 0x82 test buzz failed while mis-routed to the 0x80 characteristic.
|
||||
static func outputCharUUID(id: UInt8) -> String {
|
||||
String(format: "100f6c%02x-1735-4313-b402-38567131e5f3", (Int(id) + 0x35) & 0xFF)
|
||||
}
|
||||
|
||||
/// Declared STRIPPED payload length per output report id (wire length = stripped + 1). The
|
||||
/// hardware-verified table; its id-INCLUDED mirror is the host's
|
||||
/// `pf_driver_proto::triton::out_report_len`.
|
||||
///
|
||||
/// ⚠ The two tables are HAND-MIRRORED, not generated. `Sc2DeviceTests` pins `strippedLen + 1`
|
||||
/// against a Swift *transcription* of the host values, so it catches a drift made HERE — it
|
||||
/// cannot see a change made on the Rust side, which would leave this table silently stale and
|
||||
/// the GATT write trimmed to the wrong length. Editing either table means editing both (the
|
||||
/// Rust doc carries the same warning). `nil` = unknown id: clamp to what arrived minus the id
|
||||
/// byte, never guess-trim beyond that.
|
||||
static func strippedOutputLen(id: UInt8) -> Int? {
|
||||
switch id {
|
||||
case 0x80: return 9 // grip rumble → 100F6CB5 (left/right motor fields)
|
||||
case 0x81: return 7 // trackpad pulse → 100F6CB6 (side byte 01=L 02=R 03=both; one char)
|
||||
case 0x82: return 3 // haptic command → 100F6CB7 (Steam's ping/test buzz)
|
||||
case 0x83: return 9 // LFO tone → 100F6CB8
|
||||
case 0x84: return 8 // log sweep → 100F6CB9
|
||||
case 0x85: return 3 // script → 100F6CBA
|
||||
case 0x86: return 3 // vendor → 100F6CBB
|
||||
case 0x87, 0x88, 0x89: return 63 // vendor big → 100F6CBC/BD/BE
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input report ids (`ETritonReportIDTypes`)
|
||||
|
||||
static let idState: UInt8 = 0x42
|
||||
static let idBattery: UInt8 = 0x43
|
||||
static let idStateBLE: UInt8 = 0x45
|
||||
static let idWirelessX: UInt8 = 0x46
|
||||
static let idStateTimestamp: UInt8 = 0x47
|
||||
static let idWireless: UInt8 = 0x79
|
||||
|
||||
// MARK: - Feature commands (Sc2Device.kt; hardware-confirmed 2026-06-08)
|
||||
|
||||
/// The feature report that turns lizard mode (built-in keyboard/mouse emulation) off:
|
||||
/// `[report id 1][ID_SET_SETTINGS_VALUES 0x87][length 3][SETTING_LIZARD_MODE 9]
|
||||
/// [LIZARD_MODE_OFF u16]`, zero-padded to the 64-byte feature size (the firmware accepts the
|
||||
/// padded form — it is exactly what a Windows host's hidclass sends). The firmware watchdog
|
||||
/// re-enables lizard mode after a few seconds of silence, so this is re-sent every
|
||||
/// `lizardRefreshSeconds` (SDL's cadence) — and the host's Steam sends its own through the
|
||||
/// raw plane once it grabs the virtual pad, which lands on the same characteristic.
|
||||
static let disableLizard: [UInt8] = {
|
||||
var b = [UInt8](repeating: 0, count: 64)
|
||||
b[0] = 0x01 // feature report id
|
||||
b[1] = 0x87 // ID_SET_SETTINGS_VALUES
|
||||
b[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
||||
b[3] = 9 // SETTING_LIZARD_MODE
|
||||
// [4..6] = LIZARD_MODE_OFF (0) — already zero
|
||||
return b
|
||||
}()
|
||||
|
||||
/// The gyro-enable Steam itself sends — WRITE_REGISTER, reg 0x30 (GYRO_MODE), value 0x0018
|
||||
/// (raw accel | raw gyro); confirmed both ways on real hardware 2026-06-08. Kept ONLY for
|
||||
/// logging and tests: the client must NEVER self-enable the gyro (a permanent enable re-flies
|
||||
/// the desktop cursor) — Steam's own forwarded write is what opens `Sc2ImuGate`.
|
||||
static let gyroEnableReference: [UInt8] = [0x01, 0x87, 0x03, 0x30, 0x18, 0x00]
|
||||
|
||||
/// SDL's lizard-off refresh cadence.
|
||||
static let lizardRefreshSeconds: TimeInterval = 3.0
|
||||
|
||||
// MARK: - Button bits in the state report's u32 (SDL `TritonButtons`)
|
||||
|
||||
static let btnA: UInt32 = 0x0000_0001
|
||||
static let btnB: UInt32 = 0x0000_0002
|
||||
static let btnX: UInt32 = 0x0000_0004
|
||||
static let btnY: UInt32 = 0x0000_0008
|
||||
static let btnQAM: UInt32 = 0x0000_0010
|
||||
static let btnR3: UInt32 = 0x0000_0020
|
||||
static let btnView: UInt32 = 0x0000_0040
|
||||
static let btnR4: UInt32 = 0x0000_0080
|
||||
static let btnR5: UInt32 = 0x0000_0100
|
||||
static let btnRB: UInt32 = 0x0000_0200
|
||||
static let btnDpadDown: UInt32 = 0x0000_0400
|
||||
static let btnDpadRight: UInt32 = 0x0000_0800
|
||||
static let btnDpadLeft: UInt32 = 0x0000_1000
|
||||
static let btnDpadUp: UInt32 = 0x0000_2000
|
||||
static let btnMenu: UInt32 = 0x0000_4000
|
||||
static let btnL3: UInt32 = 0x0000_8000
|
||||
static let btnSteam: UInt32 = 0x0001_0000
|
||||
static let btnL4: UInt32 = 0x0002_0000
|
||||
static let btnL5: UInt32 = 0x0004_0000
|
||||
static let btnLB: UInt32 = 0x0008_0000
|
||||
static let btnRPadClick: UInt32 = 0x0040_0000
|
||||
|
||||
/// Wire mapping: SC2 button bit → punktfunk `GamepadWire` bit, the inverse of the host's
|
||||
/// typed-fallback mapping (`triton_proto::from_gamepad`): paddles R4/L4/R5/L5 =
|
||||
/// PADDLE1/2/3/4, QAM = MISC1, right-pad click = the touchpad wire bit. Same pairs, same
|
||||
/// order, as Android's `Sc2Device.WIRE_MAP`.
|
||||
static let wireMap: [(sc2: UInt32, wire: UInt32)] = [
|
||||
(btnA, GamepadWire.a),
|
||||
(btnB, GamepadWire.b),
|
||||
(btnX, GamepadWire.x),
|
||||
(btnY, GamepadWire.y),
|
||||
(btnLB, GamepadWire.leftShoulder),
|
||||
(btnRB, GamepadWire.rightShoulder),
|
||||
(btnView, GamepadWire.back),
|
||||
(btnMenu, GamepadWire.start),
|
||||
(btnSteam, GamepadWire.guide),
|
||||
(btnL3, GamepadWire.leftStickClick),
|
||||
(btnR3, GamepadWire.rightStickClick),
|
||||
(btnDpadUp, GamepadWire.dpadUp),
|
||||
(btnDpadDown, GamepadWire.dpadDown),
|
||||
(btnDpadLeft, GamepadWire.dpadLeft),
|
||||
(btnDpadRight, GamepadWire.dpadRight),
|
||||
(btnQAM, GamepadWire.misc1),
|
||||
(btnR4, GamepadWire.paddle1),
|
||||
(btnL4, GamepadWire.paddle2),
|
||||
(btnR5, GamepadWire.paddle3),
|
||||
(btnL5, GamepadWire.paddle4),
|
||||
(btnRPadClick, GamepadWire.touchpadClick),
|
||||
]
|
||||
|
||||
/// Translate an SC2 button word into the wire `GamepadWire` bitmask.
|
||||
static func wireButtons(_ sc2: UInt32) -> UInt32 {
|
||||
var out: UInt32 = 0
|
||||
for (bit, wire) in wireMap where sc2 & bit != 0 {
|
||||
out |= wire
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - State parser (typed mirror + exit chord only; the raw report is the product)
|
||||
|
||||
/// The typed-mirror fields of one state report (buttons/sticks/triggers only).
|
||||
struct State: Equatable {
|
||||
var buttons: UInt32 = 0 // SC2 bit layout
|
||||
var lsX: Int32 = 0 // i16, +y = up (device convention = wire convention)
|
||||
var lsY: Int32 = 0
|
||||
var rsX: Int32 = 0
|
||||
var rsY: Int32 = 0
|
||||
var lt: Int32 = 0 // 0...255 (device 0...32767 scaled down)
|
||||
var rt: Int32 = 0
|
||||
}
|
||||
|
||||
/// Parse the client-consumed fields out of a state report (`0x42`/`0x45`/`0x47` — identical
|
||||
/// offsets for everything read here) into `out`. Returns false for non-state/short reports.
|
||||
/// Offsets are id-first wire offsets: buttons u32 @2, triggers i16 @6/@8 (`>>7` → 0...255),
|
||||
/// sticks i16 @10/@12/@14/@16 — Android's `parseState`, byte for byte.
|
||||
static func parseState(_ report: [UInt8], into out: inout State) -> Bool {
|
||||
guard report.count >= 18 else { return false }
|
||||
switch report[0] {
|
||||
case idState, idStateBLE, idStateTimestamp: break
|
||||
default: return false
|
||||
}
|
||||
func i16(_ o: Int) -> Int32 {
|
||||
Int32(Int16(bitPattern: UInt16(report[o]) | (UInt16(report[o + 1]) << 8)))
|
||||
}
|
||||
out.buttons = UInt32(report[2]) | (UInt32(report[3]) << 8)
|
||||
| (UInt32(report[4]) << 16) | (UInt32(report[5]) << 24)
|
||||
out.lt = min(max(i16(6), 0), 32767) >> 7
|
||||
out.rt = min(max(i16(8), 0), 32767) >> 7
|
||||
out.lsX = i16(10)
|
||||
out.lsY = i16(12)
|
||||
out.rsX = i16(14)
|
||||
out.rsY = i16(16)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Framing (pure; the BLE shim calls these — see Sc2FramingTests)
|
||||
|
||||
/// Incoming (up-path): a GATT characteristic VALUE is the raw payload with NO HID report-id
|
||||
/// byte, so re-prepend `0x45` for state-sized (≥ 40 B) payloads — the wire then carries the
|
||||
/// same id-first framing as USB, which is punktfunk's contract (the host's virtual pad does
|
||||
/// the rest; no 0x45→0x42 rewrite and no 54-byte zero-pad — those belong to a
|
||||
/// synthetic-USB queue contract, not ours). Short payloads (battery/status) pass through
|
||||
/// unmodified. Observed live rate ~66 Hz, len 45.
|
||||
static func frameIncoming(_ payload: [UInt8]) -> [UInt8] {
|
||||
guard payload.count >= 40 else { return payload }
|
||||
return [idStateBLE] + payload
|
||||
}
|
||||
|
||||
/// One resolved OUTPUT write: which per-report characteristic, and the bare payload to put
|
||||
/// on it.
|
||||
struct OutputWrite: Equatable {
|
||||
/// Lowercase characteristic UUID (`outputCharUUID`).
|
||||
let charUUID: String
|
||||
let payload: [UInt8]
|
||||
}
|
||||
|
||||
/// Outgoing OUTPUT (`kind == 0`, HID_RAW_OUTPUT): the frame arrives id-first
|
||||
/// `[0xNN][payload…]`; the id SELECTS the per-report characteristic and is STRIPPED, and the
|
||||
/// payload is trimmed to the declared stripped length, clamped to what arrived. The clamp is
|
||||
/// redundant-but-kept: current Windows hosts already trim each drained OUTPUT frame to
|
||||
/// `out_report_len(id)` before the HidRaw push, but older hosts pad to 64 B — and the GATT
|
||||
/// write must carry exactly the declared length either way. Unknown id: the whole id-stripped
|
||||
/// payload (never guess-trim). `nil` for a frame too short to carry a payload.
|
||||
static func outputWrite(frame: [UInt8]) -> OutputWrite? {
|
||||
guard frame.count >= 2 else { return nil }
|
||||
let id = frame[0]
|
||||
let declared = strippedOutputLen(id: id) ?? frame.count - 1
|
||||
let n = min(declared, frame.count - 1)
|
||||
return OutputWrite(charUUID: outputCharUUID(id: id), payload: Array(frame[1 ..< 1 + n]))
|
||||
}
|
||||
|
||||
/// Outgoing FEATURE (`kind == 1`, HID_RAW_FEATURE): the frame is `[0x01][0x87 …]` — strip
|
||||
/// the leading 0x01 channel report-id and write the remainder to `100F6C34` (the
|
||||
/// hardware-proven gyro/lizard path). FEATURE frames deliberately arrive WHOLE from the host
|
||||
/// (64 B, un-trimmed), so trailing zero-padding is passed through — the firmware accepts the
|
||||
/// zero-padded form (Android sends `disableLizard` padded to 64 B the same way). NO 0xC0
|
||||
/// segment wrapper: proven unnecessary on-device for both feature and output writes.
|
||||
/// `nil` for a frame too short to carry a command.
|
||||
static func featurePayload(frame: [UInt8]) -> [UInt8]? {
|
||||
guard frame.count >= 2 else { return nil }
|
||||
return Array(frame.dropFirst())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// IMU liveness gate for the raw SC2 state-report feed — the policy proven against real hardware
|
||||
// on the bench (2026-06-08), kept aligned with the Kotlin sibling (`Sc2ImuGate.kt`); the two
|
||||
// implementations must not drift.
|
||||
//
|
||||
// The controller streams gyro/accel only after the host writes `SETTING_IMU_MODE` (reg 0x30);
|
||||
// until then the IMU block — including its leading u32 timestamp — is FROZEN at a stale non-zero
|
||||
// resting sample (byte-frozen across 600 frames in the 2026-06-08 capture). Forwarded verbatim,
|
||||
// that constant non-zero gyro reads to Steam's desktop config as a *constant* rotation and flies
|
||||
// the cursor (hardware-confirmed both directions 2026-06-08). So: pass the IMU through only
|
||||
// while its timestamp is advancing, and zero the whole block (timestamp included) while frozen.
|
||||
// Self-correcting, no hardcoded gyro-enable: on the desktop the IMU stays off → frozen → zeros →
|
||||
// calm cursor; a gyro game makes Steam send the enable (feature `01 87 03 30 18 00`, replayed to
|
||||
// the pad by `Sc2Capture.onHidRaw`) → the timestamp starts ticking → live data flows.
|
||||
//
|
||||
// Gated shapes: `0x42` (USB state, 54 B wire) and `0x45` (BLE state, 46 B wire) — both are
|
||||
// `[report id][pack(1) TritonMTUNoQuat_t]`, so the IMU block (u32 timestamp + 3× i16 accel +
|
||||
// 3× i16 gyro) sits at wire offset 30 (struct offset 29 + the id byte) in both. `0x47` is
|
||||
// deliberately NOT gated: its layout diverges from byte 18 (inserted trackpad timestamp), no
|
||||
// capture pins its IMU offset down, and the char it rides is not even subscribed here.
|
||||
//
|
||||
// Single-threaded by contract: `apply` runs where the reports are handled; `reset` runs from the
|
||||
// same teardown paths that already touch the slot state (the `Sc2Capture` locking contract).
|
||||
// Pure logic, no CoreBluetooth — `Sc2ImuGateTests` pins the whole state machine.
|
||||
|
||||
import Foundation
|
||||
|
||||
final class Sc2ImuGate {
|
||||
/// Wire offset of `TritonMTUNoQuat_t.imu` — struct offset 29 + 1 report-id byte; identical
|
||||
/// in the 0x42 and 0x45 shapes (both carry the same pack(1) struct).
|
||||
static let imuOffset = 30
|
||||
|
||||
/// u32 timestamp + 3× i16 accel + 3× i16 gyro.
|
||||
static let imuLen = 16
|
||||
|
||||
/// Unchanged-timestamp frames before declaring the IMU frozen (bench-tuned 2026-06-08:
|
||||
/// three repeats still pass, the fourth freezes).
|
||||
static let staleLimit = 4
|
||||
|
||||
private var lastTs: UInt32 = 0
|
||||
private var haveTs = false
|
||||
private var stale = 0
|
||||
|
||||
/// Re-arm (forget the timestamp history) — called at capture start, on BLE disconnect, and
|
||||
/// on every slot teardown, so whatever connects next must re-prove its IMU live before the
|
||||
/// block passes through.
|
||||
func reset() {
|
||||
lastTs = 0
|
||||
haveTs = false
|
||||
stale = 0
|
||||
}
|
||||
|
||||
/// Gate `report` in place, before it is forwarded. Non-state ids and reports too short to
|
||||
/// carry a full IMU block pass through untouched; a state report whose IMU timestamp has not
|
||||
/// advanced for `staleLimit` consecutive frames — or that has no history yet (unknown until
|
||||
/// it moves, so treated as frozen) — gets its IMU block zeroed. A live stream tolerates
|
||||
/// short repeats (the report rate can exceed the IMU sample rate).
|
||||
func apply(_ report: inout [UInt8]) {
|
||||
guard report.count >= Self.imuOffset + Self.imuLen else { return }
|
||||
switch report[0] {
|
||||
case Sc2Device.idState, Sc2Device.idStateBLE: break
|
||||
default: return
|
||||
}
|
||||
let o = Self.imuOffset
|
||||
let ts = UInt32(report[o]) | (UInt32(report[o + 1]) << 8)
|
||||
| (UInt32(report[o + 2]) << 16) | (UInt32(report[o + 3]) << 24)
|
||||
let live: Bool
|
||||
if !haveTs {
|
||||
haveTs = true
|
||||
stale = Self.staleLimit // unknown until it moves → treat as frozen
|
||||
live = false
|
||||
} else if ts != lastTs {
|
||||
stale = 0
|
||||
live = true
|
||||
} else {
|
||||
if stale < Self.staleLimit { stale += 1 }
|
||||
live = stale < Self.staleLimit
|
||||
}
|
||||
lastTs = ts
|
||||
if !live {
|
||||
for i in o ..< o + Self.imuLen {
|
||||
report[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,19 @@ public enum DefaultsKey {
|
||||
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
|
||||
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
|
||||
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
|
||||
/// Steam Controller 2 as-is passthrough (CoreBluetooth capture of a paired SC2's vendor
|
||||
/// GATT service → the host's virtual 28DE:1302, which the host's Steam drives directly).
|
||||
/// The cross-client `sc2_capture` key — same gate as Android's `settings.sc2Capture`, read at
|
||||
/// connect beside `gamepadForwarding`: both must be on for `SessionModel` to build an
|
||||
/// `Sc2Capture`. iOS/macOS only (tvOS has no CoreBluetooth capture; the code is `#if`-gated
|
||||
/// out there).
|
||||
///
|
||||
/// ⚠ The DEFAULT deliberately differs from Android's, which is ON: engaging this on Apple
|
||||
/// raises a CoreBluetooth permission prompt, so a default-on toggle would ask every user for
|
||||
/// the radio whether or not they own an SC2. Android's capture needs no prompt for an
|
||||
/// already-attached pad, so it can default on and cost nothing when none is present. Do not
|
||||
/// "align" the two without moving the prompt.
|
||||
public static let sc2Capture = "punktfunk.sc2Capture"
|
||||
/// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming:
|
||||
/// `"auto"` | `"forward"` | `"local"` — the cross-client `system_buttons` key. Auto
|
||||
/// forwards on every Apple platform: the local Game Overlay is the OS's business (and on
|
||||
|
||||
@@ -42,6 +42,10 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var inhibitShortcuts = true
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// Steam Controller 2 as-is passthrough (`DefaultsKey.sc2Capture`, default off). Read at
|
||||
/// connect beside `gamepadForwarding`. Deliberately NOT profileable (no overlay field): the
|
||||
/// toggle is about hardware this device captures, not about how a host is streamed.
|
||||
public var sc2Capture = false
|
||||
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
||||
public var systemButtons = "auto"
|
||||
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
|
||||
@@ -109,6 +113,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
inhibitShortcuts = bool(DefaultsKey.inhibitShortcuts, inhibitShortcuts)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
sc2Capture = bool(DefaultsKey.sc2Capture, sc2Capture)
|
||||
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
||||
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
|
||||
statsVerbosity = Self.storedStatsVerbosity(defaults)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// The Steam Controller 2 protocol tables: the per-report characteristic map, the stripped-length
|
||||
// table's parity with the host's id-INCLUDED `pf_driver_proto::triton::out_report_len`, the
|
||||
// feature-command bytes (hardware-confirmed 2026-06-08), the state parser and the WIRE_MAP —
|
||||
// all pure statics on `Sc2Device` (the DualSenseHIDTests convention: pin the wire layout
|
||||
// without a physical pad). Plus the escape-chord invariant mirror (GamepadEscapeChordTests
|
||||
// pattern): `Sc2Capture` re-declares the chord because the original is main-actor-isolated,
|
||||
// and the two must not drift.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class Sc2DeviceTests: XCTestCase {
|
||||
func testOutputCharUUIDIsIdPlus0x35() {
|
||||
// Valve routes output report id 0xNN to characteristic 100F6C<NN+0x35> (verified
|
||||
// per-actuator on-device 2026-06-09).
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x80), "100f6cb5-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x81), "100f6cb6-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x82), "100f6cb7-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x83), "100f6cb8-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x84), "100f6cb9-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x85), "100f6cba-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x86), "100f6cbb-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x87), "100f6cbc-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x88), "100f6cbd-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0x89), "100f6cbe-1735-4313-b402-38567131e5f3")
|
||||
// The +0x35 wraps modulo 256 rather than overflowing.
|
||||
XCTAssertEqual(Sc2Device.outputCharUUID(id: 0xF0), "100f6c25-1735-4313-b402-38567131e5f3")
|
||||
}
|
||||
|
||||
func testStrippedLenPlusOneMatchesTheHostTableForEveryKnownId() {
|
||||
// The host's id-INCLUDED wire lengths, verbatim from
|
||||
// `pf_driver_proto::triton::out_report_len` (crates/pf-driver-proto/src/lib.rs): a
|
||||
// Swift-side edit that drifts from the Rust contract fails here, GamepadWireTests-style.
|
||||
let hostLen: [UInt8: Int] = [
|
||||
0x80: 10, 0x81: 8, 0x82: 4, 0x83: 10, 0x84: 9, 0x85: 4, 0x86: 4,
|
||||
0x87: 64, 0x88: 64, 0x89: 64,
|
||||
]
|
||||
for (id, host) in hostLen {
|
||||
let stripped = Sc2Device.strippedOutputLen(id: id)
|
||||
XCTAssertNotNil(stripped, "id 0x\(String(id, radix: 16)) missing from the client table")
|
||||
XCTAssertEqual(
|
||||
(stripped ?? -999) + 1, host,
|
||||
"stripped+1 must equal the host wire length for id 0x\(String(id, radix: 16))")
|
||||
}
|
||||
// Unknown ids answer nil — clamp to what arrived, never guess a length (the host's
|
||||
// default arm is 64 = no trim, the same "never guess" policy from the other side).
|
||||
XCTAssertNil(Sc2Device.strippedOutputLen(id: 0x8A))
|
||||
XCTAssertNil(Sc2Device.strippedOutputLen(id: 0x42))
|
||||
XCTAssertNil(Sc2Device.strippedOutputLen(id: 0x00))
|
||||
}
|
||||
|
||||
func testFeatureCommandBytesVerbatim() {
|
||||
// DISABLE_LIZARD: [1][0x87 ID_SET_SETTINGS_VALUES][3][9 SETTING_LIZARD_MODE][0 0 u16],
|
||||
// zero-padded to the 64-byte feature size (Android sends the identical frame).
|
||||
XCTAssertEqual(Sc2Device.disableLizard.count, 64)
|
||||
XCTAssertEqual(
|
||||
Array(Sc2Device.disableLizard[0 ..< 6]), [0x01, 0x87, 0x03, 0x09, 0x00, 0x00])
|
||||
XCTAssertTrue(Sc2Device.disableLizard[6...].allSatisfy { $0 == 0 })
|
||||
// The gyro-enable REFERENCE (WRITE_REGISTER reg 0x30 GYRO_MODE val 0x0018) — kept for
|
||||
// logging/tests only; nothing in the client may ever send it unprompted.
|
||||
XCTAssertEqual(Sc2Device.gyroEnableReference, [0x01, 0x87, 0x03, 0x30, 0x18, 0x00])
|
||||
// SDL's lizard-off refresh cadence.
|
||||
XCTAssertEqual(Sc2Device.lizardRefreshSeconds, 3.0)
|
||||
}
|
||||
|
||||
/// One 46-byte BLE-shaped state report with the client-consumed fields planted.
|
||||
private func stateReport(
|
||||
id: UInt8 = Sc2Device.idStateBLE, buttons: UInt32 = 0,
|
||||
lt: Int16 = 0, rt: Int16 = 0,
|
||||
lsX: Int16 = 0, lsY: Int16 = 0, rsX: Int16 = 0, rsY: Int16 = 0
|
||||
) -> [UInt8] {
|
||||
var r = [UInt8](repeating: 0, count: 46)
|
||||
r[0] = id
|
||||
r[1] = 0x42 // seq — parseState must not read it
|
||||
func put32(_ v: UInt32, at o: Int) {
|
||||
r[o] = UInt8(v & 0xFF)
|
||||
r[o + 1] = UInt8((v >> 8) & 0xFF)
|
||||
r[o + 2] = UInt8((v >> 16) & 0xFF)
|
||||
r[o + 3] = UInt8((v >> 24) & 0xFF)
|
||||
}
|
||||
func put16(_ v: Int16, at o: Int) {
|
||||
let u = UInt16(bitPattern: v)
|
||||
r[o] = UInt8(u & 0xFF)
|
||||
r[o + 1] = UInt8(u >> 8)
|
||||
}
|
||||
put32(buttons, at: 2)
|
||||
put16(lt, at: 6)
|
||||
put16(rt, at: 8)
|
||||
put16(lsX, at: 10)
|
||||
put16(lsY, at: 12)
|
||||
put16(rsX, at: 14)
|
||||
put16(rsY, at: 16)
|
||||
return r
|
||||
}
|
||||
|
||||
func testParseStateTruthTable() {
|
||||
var out = Sc2Device.State()
|
||||
// Buttons LE u32 @2; triggers i16 @6/@8 clamped to 0...32767 then >>7; sticks i16
|
||||
// @10..16 — Android's parseState, byte for byte.
|
||||
let report = stateReport(
|
||||
buttons: Sc2Device.btnA | Sc2Device.btnSteam | Sc2Device.btnRPadClick,
|
||||
lt: 32767, rt: -100, lsX: -32768, lsY: 32767, rsX: 1234, rsY: -1234)
|
||||
XCTAssertTrue(Sc2Device.parseState(report, into: &out))
|
||||
XCTAssertEqual(out.buttons, Sc2Device.btnA | Sc2Device.btnSteam | Sc2Device.btnRPadClick)
|
||||
XCTAssertEqual(out.lt, 255) // 32767 >> 7
|
||||
XCTAssertEqual(out.rt, 0) // negative clamps to 0
|
||||
XCTAssertEqual(out.lsX, -32768)
|
||||
XCTAssertEqual(out.lsY, 32767)
|
||||
XCTAssertEqual(out.rsX, 1234)
|
||||
XCTAssertEqual(out.rsY, -1234)
|
||||
// All three state shapes parse (identical offsets for everything read here)…
|
||||
XCTAssertTrue(Sc2Device.parseState(stateReport(id: Sc2Device.idState), into: &out))
|
||||
XCTAssertTrue(
|
||||
Sc2Device.parseState(stateReport(id: Sc2Device.idStateTimestamp), into: &out))
|
||||
// …and non-state / short reports answer false.
|
||||
XCTAssertFalse(Sc2Device.parseState([Sc2Device.idBattery, 0, 0], into: &out))
|
||||
var short = stateReport()
|
||||
short.removeSubrange(17...)
|
||||
XCTAssertFalse(Sc2Device.parseState(short, into: &out))
|
||||
}
|
||||
|
||||
func testWireMapMatchesAndroidPairForPair() {
|
||||
// The full SC2-bit → GamepadWire-bit table (Sc2Device.kt WIRE_MAP): paddles R4/L4/R5/L5
|
||||
// = PADDLE1..4, QAM = MISC1, right-pad click = the touchpad wire bit.
|
||||
let expected: [(UInt32, UInt32)] = [
|
||||
(Sc2Device.btnA, GamepadWire.a),
|
||||
(Sc2Device.btnB, GamepadWire.b),
|
||||
(Sc2Device.btnX, GamepadWire.x),
|
||||
(Sc2Device.btnY, GamepadWire.y),
|
||||
(Sc2Device.btnLB, GamepadWire.leftShoulder),
|
||||
(Sc2Device.btnRB, GamepadWire.rightShoulder),
|
||||
(Sc2Device.btnView, GamepadWire.back),
|
||||
(Sc2Device.btnMenu, GamepadWire.start),
|
||||
(Sc2Device.btnSteam, GamepadWire.guide),
|
||||
(Sc2Device.btnL3, GamepadWire.leftStickClick),
|
||||
(Sc2Device.btnR3, GamepadWire.rightStickClick),
|
||||
(Sc2Device.btnDpadUp, GamepadWire.dpadUp),
|
||||
(Sc2Device.btnDpadDown, GamepadWire.dpadDown),
|
||||
(Sc2Device.btnDpadLeft, GamepadWire.dpadLeft),
|
||||
(Sc2Device.btnDpadRight, GamepadWire.dpadRight),
|
||||
(Sc2Device.btnQAM, GamepadWire.misc1),
|
||||
(Sc2Device.btnR4, GamepadWire.paddle1),
|
||||
(Sc2Device.btnL4, GamepadWire.paddle2),
|
||||
(Sc2Device.btnR5, GamepadWire.paddle3),
|
||||
(Sc2Device.btnL5, GamepadWire.paddle4),
|
||||
(Sc2Device.btnRPadClick, GamepadWire.touchpadClick),
|
||||
]
|
||||
XCTAssertEqual(Sc2Device.wireMap.count, expected.count)
|
||||
for (sc2, wire) in expected {
|
||||
XCTAssertEqual(Sc2Device.wireButtons(sc2), wire, "sc2 bit 0x\(String(sc2, radix: 16))")
|
||||
}
|
||||
// Every mapped bit at once, and nothing else.
|
||||
let allSc2 = expected.reduce(UInt32(0)) { $0 | $1.0 }
|
||||
let allWire = expected.reduce(UInt32(0)) { $0 | $1.1 }
|
||||
XCTAssertEqual(Sc2Device.wireButtons(allSc2), allWire)
|
||||
// Unmapped SC2 bits translate to nothing.
|
||||
XCTAssertEqual(Sc2Device.wireButtons(~allSc2), 0)
|
||||
XCTAssertEqual(Sc2Device.wireButtons(0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// `Sc2Capture` re-declares the escape chord (the original is `@MainActor`-isolated and the
|
||||
/// capture reads its mask on the BLE queue) — this pins the two masks and the hold duration
|
||||
/// together, because the failure of a drift is invisible until someone can't leave a stream
|
||||
/// with a captured SC2 in their hands (the GamepadEscapeChordTests rationale, one class over).
|
||||
@MainActor
|
||||
final class Sc2EscapeChordMirrorTests: XCTestCase {
|
||||
func testChordMaskMirrorsGamepadCapture() {
|
||||
XCTAssertEqual(Sc2Capture.escapeChord, GamepadCapture.escapeChord)
|
||||
}
|
||||
|
||||
func testHoldMirrorsTheCrossClientDisconnectHold() {
|
||||
// pf-client-core's DISCONNECT_HOLD — 1.5 s on every client (GamepadCapture's own copy
|
||||
// is private; the value is the cross-client contract being pinned).
|
||||
XCTAssertEqual(Sc2Capture.disconnectHold, 1.5)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,138 @@
|
||||
// SC2 framing: the report-id prepend on the way up (the 2026-06-09 root cause — a GATT
|
||||
// characteristic VALUE carries no id byte, and without re-prepending 0x45 an id-keyed consumer
|
||||
// drops ~every frame), the OUTPUT strip+trim and FEATURE 0x01-strip on the way down, and the
|
||||
// wire-constant pins against the regenerated C header (GamepadWireTests pattern: a Swift-side
|
||||
// edit that drifts from the Rust contract fails CI).
|
||||
|
||||
import PunktfunkCore
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class Sc2FramingTests: XCTestCase {
|
||||
// MARK: - Incoming (up-path)
|
||||
|
||||
func testStateSizedPayloadGetsThe0x45Prepend() {
|
||||
// The live shape: a 45-byte TritonMTUNoQuat_t payload → a 46-byte id-first frame.
|
||||
var payload = [UInt8](repeating: 0, count: 45)
|
||||
payload[0] = 0xE5 // seq — the byte the queue used to mistake for a report id
|
||||
let framed = Sc2Device.frameIncoming(payload)
|
||||
XCTAssertEqual(framed.count, 46)
|
||||
XCTAssertEqual(framed[0], Sc2Device.idStateBLE)
|
||||
XCTAssertEqual(Array(framed[1...]), payload)
|
||||
// The rule's floor: exactly 40 bytes still counts as state-sized.
|
||||
XCTAssertEqual(Sc2Device.frameIncoming([UInt8](repeating: 1, count: 40)).count, 41)
|
||||
}
|
||||
|
||||
func testShortPayloadPassesThroughUnmodified() {
|
||||
// Battery/status payloads keep whatever framing the firmware gave them — the host's
|
||||
// virtual pad handles the rest. (No 0x45→0x42 rewrite and no 54-byte pad on ANY path:
|
||||
// that belongs to a synthetic-USB queue contract, not punktfunk's.)
|
||||
let battery: [UInt8] = [Sc2Device.idBattery, 0x64, 0x01]
|
||||
XCTAssertEqual(Sc2Device.frameIncoming(battery), battery)
|
||||
XCTAssertEqual(
|
||||
Sc2Device.frameIncoming([UInt8](repeating: 2, count: 39)).count, 39)
|
||||
}
|
||||
|
||||
// MARK: - Outgoing OUTPUT (kind 0): id selects the char, is stripped, payload trimmed
|
||||
|
||||
func testOutputStripAndTrimPerId() {
|
||||
// A native-length 0x80 grip rumble (10 B wire = 1 id + 9 payload) → char B5, 9 bytes.
|
||||
let rumble: [UInt8] = [0x80, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
|
||||
let write = Sc2Device.outputWrite(frame: rumble)
|
||||
XCTAssertEqual(write?.charUUID, "100f6cb5-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(write?.payload, Array(rumble[1...]))
|
||||
// 0x82 — Steam's ping/test buzz — rides B7 with a 3-byte payload (the historical
|
||||
// mis-route regression: it failed while pointed at B5).
|
||||
let buzz: [UInt8] = [0x82, 0x03, 0x01, 0xFF]
|
||||
let buzzWrite = Sc2Device.outputWrite(frame: buzz)
|
||||
XCTAssertEqual(buzzWrite?.charUUID, "100f6cb7-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(buzzWrite?.payload, [0x03, 0x01, 0xFF])
|
||||
}
|
||||
|
||||
func testOutputToleratesAnOldHosts64BytePadding() {
|
||||
// Current Windows hosts pre-trim OUTPUT frames to out_report_len(id); an older host
|
||||
// pads to 64 B. The client clamp is therefore redundant-but-kept — the GATT write must
|
||||
// carry exactly the declared stripped length either way.
|
||||
var padded = [UInt8](repeating: 0, count: 64)
|
||||
padded[0] = 0x80
|
||||
for i in 1 ... 9 { padded[i] = UInt8(i) }
|
||||
padded[10] = 0xEE // padding garbage past the declared length — must be dropped
|
||||
let write = Sc2Device.outputWrite(frame: padded)
|
||||
XCTAssertEqual(write?.payload, [1, 2, 3, 4, 5, 6, 7, 8, 9])
|
||||
}
|
||||
|
||||
func testOutputClampsToWhatArrived() {
|
||||
// A frame shorter than the declared length clamps to what arrived (never over-reads).
|
||||
let short: [UInt8] = [0x80, 0x01, 0x02]
|
||||
XCTAssertEqual(Sc2Device.outputWrite(frame: short)?.payload, [0x01, 0x02])
|
||||
// Too short to carry any payload → nil (the len<2 guard).
|
||||
XCTAssertNil(Sc2Device.outputWrite(frame: [0x80]))
|
||||
XCTAssertNil(Sc2Device.outputWrite(frame: []))
|
||||
}
|
||||
|
||||
func testUnknownOutputIdKeepsTheWholePayload() {
|
||||
// Unknown id: len-1 clamp only — never guess-trim beyond the id strip. The char is
|
||||
// still computed at id+0x35 (the firmware's scheme, whatever the id).
|
||||
let unknown: [UInt8] = [0x90, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||
let write = Sc2Device.outputWrite(frame: unknown)
|
||||
XCTAssertEqual(write?.charUUID, "100f6cc5-1735-4313-b402-38567131e5f3")
|
||||
XCTAssertEqual(write?.payload, Array(unknown[1...]))
|
||||
}
|
||||
|
||||
// MARK: - Outgoing FEATURE (kind 1): strip the 0x01 channel id, pass the rest whole
|
||||
|
||||
func testFeatureStripsTheChannelIdAndKeepsThePadding() {
|
||||
// FEATURE frames deliberately arrive WHOLE from the host (64 B, un-trimmed): strip the
|
||||
// 0x01, keep everything else — the firmware accepts the zero-padded form (Android sends
|
||||
// DISABLE_LIZARD padded to 64 B the same way).
|
||||
var lizard = [UInt8](repeating: 0, count: 64)
|
||||
lizard.replaceSubrange(0 ..< 6, with: [0x01, 0x87, 0x03, 0x09, 0x00, 0x00])
|
||||
let payload = Sc2Device.featurePayload(frame: lizard)
|
||||
XCTAssertEqual(payload?.count, 63)
|
||||
XCTAssertEqual(Array(payload?.prefix(5) ?? []), [0x87, 0x03, 0x09, 0x00, 0x00])
|
||||
// The short (un-padded) form works too — what the client's own keepalive would look
|
||||
// like before padding.
|
||||
XCTAssertEqual(
|
||||
Sc2Device.featurePayload(frame: [0x01, 0x87, 0x03, 0x30, 0x18, 0x00]),
|
||||
[0x87, 0x03, 0x30, 0x18, 0x00])
|
||||
// Too short to carry a command → nil.
|
||||
XCTAssertNil(Sc2Device.featurePayload(frame: [0x01]))
|
||||
XCTAssertNil(Sc2Device.featurePayload(frame: []))
|
||||
}
|
||||
|
||||
func testLizardOffKeepaliveLeadsWithTheSettingsCommand() {
|
||||
// The client's own keepalive (`Sc2BleLink.sendLizardOff`) writes EXACTLY this frame:
|
||||
// `featurePayload(disableLizard)` — the same framing as a host-forwarded feature write,
|
||||
// so BOTH 100F6C34 writers share one code path. The characteristic VALUE carries no
|
||||
// 0x01 channel report-id (the firmware parses byte 0 as the settings-command id), so
|
||||
// the write MUST lead with 0x87 (ID_SET_SETTINGS_VALUES): unstripped, the frame reads
|
||||
// as command 0x01 and lizard-off silently fails for the whole pre-claim window.
|
||||
let write = Sc2Device.featurePayload(frame: Sc2Device.disableLizard)
|
||||
XCTAssertEqual(write?.first, 0x87) // ID_SET_SETTINGS_VALUES — never the 0x01 channel id
|
||||
XCTAssertEqual(write, Array(Sc2Device.disableLizard.dropFirst()))
|
||||
XCTAssertEqual(write?.count, 63) // the 64-byte frame minus the channel id, padding kept
|
||||
XCTAssertEqual(
|
||||
Array(write?.prefix(5) ?? []),
|
||||
[0x87, 0x03, 0x09, 0x00, 0x00]) // SET_SETTINGS len=3 {LIZARD_MODE, OFF u16}
|
||||
}
|
||||
|
||||
// MARK: - Wire-constant pins against the regenerated C header (ABI v27)
|
||||
|
||||
func testWireConstantsMatchTheCABIVerbatim() {
|
||||
// The up-path datagram: [0xCC][0x04][pad][len][data…].
|
||||
XCTAssertEqual(Int(PUNKTFUNK_RICH_INPUT_MAGIC), 0xCC)
|
||||
XCTAssertEqual(Int(PUNKTFUNK_RICH_HID_REPORT), 0x04)
|
||||
// The down-path plane tag ([0xCD]; the HidRaw wire kind byte 0x05 is core-internal —
|
||||
// the ABI surfaces it as the struct kind below, which is what Swift consumes).
|
||||
XCTAssertEqual(Int(PUNKTFUNK_HIDOUT_MAGIC), 0xCD)
|
||||
XCTAssertEqual(Int(PUNKTFUNK_HIDOUT_HID_RAW), 6)
|
||||
// The device-channel kinds Sc2BleLink branches on, and the report bound.
|
||||
XCTAssertEqual(Int(PUNKTFUNK_HID_RAW_OUTPUT), 0)
|
||||
XCTAssertEqual(Int(PUNKTFUNK_HID_RAW_FEATURE), 1)
|
||||
XCTAssertEqual(Int(PUNKTFUNK_HID_REPORT_MAX), 64)
|
||||
// The pad kind the capture declares in its arrival (GamepadPref::SteamController2).
|
||||
XCTAssertEqual(Int(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2), 9)
|
||||
XCTAssertEqual(PunktfunkConnection.GamepadType.steamController2.rawValue, 9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// The frozen-timestamp IMU gate's full truth table — the state machine proven against real
|
||||
// hardware (2026-06-08: a frozen non-zero IMU block drives Steam's desktop gyro-mouse; the
|
||||
// timestamp-gated passthrough is the fix), re-pinned here for the Apple side. The two
|
||||
// implementations (Sc2ImuGate.kt / Sc2ImuGate.swift) must not drift:
|
||||
// first sample frozen, ts-change → live, STALE_LIMIT (4) unchanged frames → refrozen, reset
|
||||
// re-arms, and only the 0x42/0x45 state shapes are ever touched.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class Sc2ImuGateTests: XCTestCase {
|
||||
/// A 46-byte state report whose IMU block (wire offset 30, 16 bytes) is a nonzero resting
|
||||
/// sample with `ts` planted in its leading u32 — the frozen-capture shape.
|
||||
private func report(id: UInt8 = Sc2Device.idStateBLE, ts: UInt32, count: Int = 46) -> [UInt8] {
|
||||
var r = [UInt8](repeating: 0, count: count)
|
||||
r[0] = id
|
||||
guard count >= Sc2ImuGate.imuOffset + Sc2ImuGate.imuLen else { return r }
|
||||
let o = Sc2ImuGate.imuOffset
|
||||
for i in o ..< o + Sc2ImuGate.imuLen {
|
||||
r[i] = 0xAA // a non-zero "resting accel" fill — what must never leak while frozen
|
||||
}
|
||||
r[o] = UInt8(ts & 0xFF)
|
||||
r[o + 1] = UInt8((ts >> 8) & 0xFF)
|
||||
r[o + 2] = UInt8((ts >> 16) & 0xFF)
|
||||
r[o + 3] = UInt8((ts >> 24) & 0xFF)
|
||||
return r
|
||||
}
|
||||
|
||||
private func imuBlock(_ r: [UInt8]) -> [UInt8] {
|
||||
Array(r[Sc2ImuGate.imuOffset ..< Sc2ImuGate.imuOffset + Sc2ImuGate.imuLen])
|
||||
}
|
||||
|
||||
func testFirstSampleIsFrozen() {
|
||||
let gate = Sc2ImuGate()
|
||||
var r = report(ts: 0x1234_5678)
|
||||
gate.apply(&r)
|
||||
// Unknown until it moves → treated as frozen: the whole 16-byte block (timestamp
|
||||
// included) is zeroed, and nothing outside it is touched.
|
||||
XCTAssertEqual(imuBlock(r), [UInt8](repeating: 0, count: Sc2ImuGate.imuLen))
|
||||
XCTAssertEqual(r[0], Sc2Device.idStateBLE)
|
||||
XCTAssertEqual(Array(r[1 ..< Sc2ImuGate.imuOffset]), [UInt8](repeating: 0, count: 29))
|
||||
}
|
||||
|
||||
func testAdvancingTimestampPassesThrough() {
|
||||
let gate = Sc2ImuGate()
|
||||
var first = report(ts: 100)
|
||||
gate.apply(&first)
|
||||
var second = report(ts: 101)
|
||||
gate.apply(&second)
|
||||
XCTAssertEqual(second, report(ts: 101), "an advancing timestamp must pass untouched")
|
||||
}
|
||||
|
||||
func testRefreezesAfterStaleLimitUnchangedFrames() {
|
||||
let gate = Sc2ImuGate()
|
||||
var r = report(ts: 100)
|
||||
gate.apply(&r) // first sample: frozen
|
||||
r = report(ts: 101)
|
||||
gate.apply(&r) // advanced: live
|
||||
XCTAssertEqual(r, report(ts: 101))
|
||||
// A live stream tolerates short repeats (report rate > IMU sample rate): the first
|
||||
// STALE_LIMIT-1 unchanged frames still pass…
|
||||
for i in 1 ..< Sc2ImuGate.staleLimit {
|
||||
var same = report(ts: 101)
|
||||
gate.apply(&same)
|
||||
XCTAssertEqual(same, report(ts: 101), "repeat \(i) of \(Sc2ImuGate.staleLimit) must pass")
|
||||
}
|
||||
// …and the STALE_LIMITth declares it frozen again.
|
||||
var frozen = report(ts: 101)
|
||||
gate.apply(&frozen)
|
||||
XCTAssertEqual(imuBlock(frozen), [UInt8](repeating: 0, count: Sc2ImuGate.imuLen))
|
||||
// Self-correcting: the next advance re-opens the gate at once.
|
||||
var revived = report(ts: 102)
|
||||
gate.apply(&revived)
|
||||
XCTAssertEqual(revived, report(ts: 102))
|
||||
}
|
||||
|
||||
func testResetRearmsTheFirstSampleRule() {
|
||||
let gate = Sc2ImuGate()
|
||||
var r = report(ts: 1)
|
||||
gate.apply(&r)
|
||||
r = report(ts: 2)
|
||||
gate.apply(&r)
|
||||
XCTAssertEqual(r, report(ts: 2)) // live
|
||||
gate.reset()
|
||||
// Whatever connects next must re-prove its IMU live — even an "advancing" timestamp is
|
||||
// history-less after reset and starts frozen.
|
||||
var next = report(ts: 3)
|
||||
gate.apply(&next)
|
||||
XCTAssertEqual(imuBlock(next), [UInt8](repeating: 0, count: Sc2ImuGate.imuLen))
|
||||
}
|
||||
|
||||
func testTimestampShapeAndNonStateIdsAreExempt() {
|
||||
let gate = Sc2ImuGate()
|
||||
// 0x47 diverges from byte 18 (inserted trackpad timestamp) — never gated, AND never
|
||||
// consumes gate history: the 0x45 after it is still the first sample.
|
||||
var ts47 = report(id: Sc2Device.idStateTimestamp, ts: 7)
|
||||
gate.apply(&ts47)
|
||||
XCTAssertEqual(ts47, report(id: Sc2Device.idStateTimestamp, ts: 7))
|
||||
var battery = report(id: Sc2Device.idBattery, ts: 7)
|
||||
gate.apply(&battery)
|
||||
XCTAssertEqual(battery, report(id: Sc2Device.idBattery, ts: 7))
|
||||
var first45 = report(ts: 7)
|
||||
gate.apply(&first45)
|
||||
XCTAssertEqual(
|
||||
imuBlock(first45), [UInt8](repeating: 0, count: Sc2ImuGate.imuLen),
|
||||
"0x47/battery must not have seeded the timestamp history")
|
||||
}
|
||||
|
||||
func testShortReportPassesUntouched() {
|
||||
let gate = Sc2ImuGate()
|
||||
// One byte short of a full IMU block — no gating, no zeroing, no history.
|
||||
var short = report(ts: 9, count: Sc2ImuGate.imuOffset + Sc2ImuGate.imuLen - 1)
|
||||
let before = short
|
||||
gate.apply(&short)
|
||||
XCTAssertEqual(short, before)
|
||||
}
|
||||
}
|
||||
@@ -822,6 +822,10 @@ pub mod gamepad {
|
||||
/// exclusively, so extra buttons declared here may be invisible to every consumer anyway.
|
||||
/// That needs measuring before it is built.
|
||||
pub const DEVTYPE_XBOX_ELITE: u8 = 6;
|
||||
/// Steam Controller 2 ("Triton", 2026): wired identity `28DE:1302`. Raw-passthrough pad —
|
||||
/// the host feeds the client's captured reports verbatim and the driver answers Steam's
|
||||
/// feature query-dance in-driver (see the `triton` module).
|
||||
pub const DEVTYPE_TRITON: u8 = 7;
|
||||
|
||||
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
|
||||
/// the host's positive "driver is alive on this section" signal (health check + version audit).
|
||||
@@ -1371,6 +1375,245 @@ pub mod gamepad {
|
||||
};
|
||||
}
|
||||
|
||||
/// Steam Controller 2 (Triton) wire tables shared by the UMDF driver (answers Steam
|
||||
/// synchronously) and the host/inject side (Linux usbip leg + tests). Everything here is
|
||||
/// pure byte-packing so it tests on any host.
|
||||
pub mod triton {
|
||||
/// Feature-1 command bytes of the Valve query dance.
|
||||
pub const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83;
|
||||
pub const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
|
||||
pub const ID_GET_FIRMWARE_INFO: u8 = 0xF2;
|
||||
/// Output report id Steam rumbles with (`80 | type | intensity16 | Lspeed16 Lgain | Rspeed16 Rgain`).
|
||||
pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80;
|
||||
|
||||
/// The wired Steam Controller 2 identity (`28DE:1302`) — the Triton half of
|
||||
/// [`crate::gamepad::DEVTYPE_TRITON`]'s `0x83` attributes reply.
|
||||
const WIRED_PRODUCT: u32 = 0x1302;
|
||||
|
||||
/// Firmware build time (unix epoch) served as attribute tag `4`
|
||||
/// (`ATTRIB_FIRMWARE_BUILD_TIME`, per SDL's `controller_constants.h` — the same tag map the
|
||||
/// Phase-0 bench responder used) in the `0x83` attributes reply, and mirrored at bytes 4..8
|
||||
/// of the `0xF2` firmware-info reply (the two carried the same value before this constant
|
||||
/// existed and must keep doing so).
|
||||
///
|
||||
/// BENCH FINDING (2026-08-21, the first Windows Steam claim): the previous synthetic value
|
||||
/// (`unit_id ^ 0x0296_DAF9` ≈ Feb 2016) read as decade-old firmware and Steam offered to
|
||||
/// "update" the virtual pad — a flow whose SET_REPORTs would be forwarded toward a REAL
|
||||
/// controller on the client path, which nobody wants. The synthetic attributes are this
|
||||
/// pad's permanent identity (the in-driver query dance answers Steam itself; a captured
|
||||
/// physical pad's firmware version never reaches Steam), so the value is ours to pin.
|
||||
/// `0x6A6D_3700` = 2026-08-01T00:00:00Z. Bump it when Steam learns a newer shipping
|
||||
/// firmware and starts prompting again.
|
||||
pub const FW_BUILD_TIME: u32 = 0x6A6D_3700;
|
||||
|
||||
/// Bit 31 of an out-ring slot's `len` marks the frame as a FEATURE set (vs interrupt/output).
|
||||
/// Only the Triton devtype's producer (driver) and consumer (triton_windows drain) interpret
|
||||
/// it; every other devtype writes plain lengths, so the bit is additive.
|
||||
pub const OUT_FEATURE_BIT: u32 = 0x8000_0000;
|
||||
#[inline]
|
||||
pub const fn out_len(raw: u32) -> u32 {
|
||||
raw & !OUT_FEATURE_BIT
|
||||
}
|
||||
#[inline]
|
||||
pub const fn out_is_feature(raw: u32) -> bool {
|
||||
raw & OUT_FEATURE_BIT != 0
|
||||
}
|
||||
|
||||
/// Wire length (id byte included) of each input report the wired descriptor declares.
|
||||
/// hidclass sizes its read buffer from the largest (0x42 → 54) and refuses over-long
|
||||
/// completions, so the driver trims every served report to this. `None` = undeclared id,
|
||||
/// drop it (0x47 is the BLE timestamp report — BLE-only, not in the 372-byte descriptor).
|
||||
pub const fn input_len(report_id: u8) -> Option<usize> {
|
||||
match report_id {
|
||||
0x42 => Some(54),
|
||||
0x45 => Some(46),
|
||||
0x43 => Some(15),
|
||||
0x44 => Some(6),
|
||||
0x79 => Some(2),
|
||||
0x7B => Some(13),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Declared wire length (id byte INCLUDED) of each OUTPUT report the wired descriptor
|
||||
/// declares. The mirror of [`input_len`] for the write direction: hidclass pads every output
|
||||
/// write to `OutputReportByteLength` (64) before the driver rings it, so the HOST trims each
|
||||
/// drained OUTPUT frame to this before forwarding — the client replays the bare declared
|
||||
/// payload over GATT, matching the Linux leg's native-length forwarding (a 0x80 rumble is
|
||||
/// 10 bytes there, not 64). An unknown id returns 64 — no trim, never guess a length.
|
||||
/// Provenance: the captured 372-byte descriptor ([`RDESC`]) plus the per-id output
|
||||
/// characteristic map, verified per-actuator against real hardware (bench 2026-06-09).
|
||||
///
|
||||
/// ⚠ HAND-MIRRORED on the Apple client as `Sc2Device.strippedOutputLen` (id-EXCLUDED, so
|
||||
/// `stripped + 1` == the value here), which trims the GATT write. Its test transcribes these
|
||||
/// numbers, so it cannot catch a change made HERE — edit this table and you must edit that one.
|
||||
pub const fn out_report_len(id: u8) -> usize {
|
||||
match id {
|
||||
0x80 => 10,
|
||||
0x81 => 8,
|
||||
0x82 => 4,
|
||||
0x83 => 10,
|
||||
0x84 => 9,
|
||||
0x85 => 4,
|
||||
0x86 => 4,
|
||||
// 0x87/0x88/0x89 are declared full-length (63-byte payload) blobs.
|
||||
_ => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-pad unit id ("TRI\0" | index — same value the Linux leg uses).
|
||||
pub const fn unit_id(index: u8) -> u32 {
|
||||
0x5452_4900 | index as u32
|
||||
}
|
||||
|
||||
/// ASCII serial `FVPF1302<idx:02>D03` — "FVPF" because Steam rejects a "PF"-leading
|
||||
/// serial, and the FVPF prefix is what the host's physical-conflict gate excludes.
|
||||
pub fn serial(index: u8, out: &mut [u8; 13]) {
|
||||
const D: &[u8; 10] = b"0123456789";
|
||||
out.copy_from_slice(b"FVPF130200D03");
|
||||
out[8] = D[(index / 10 % 10) as usize];
|
||||
out[9] = D[(index % 10) as usize];
|
||||
}
|
||||
|
||||
/// The wired Triton's captured 372-byte report descriptor — MOVED VERBATIM from
|
||||
/// crates/pf-inject/src/inject/proto/triton_proto.rs:52-85 (`TRITON_RDESC`), including its
|
||||
/// comment block. Byte-identical to the Phase-0 sysfs capture (programmatically diffed).
|
||||
#[rustfmt::skip]
|
||||
pub static RDESC: [u8; 372] = [
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
|
||||
0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
|
||||
0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
|
||||
0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
|
||||
0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
|
||||
0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
|
||||
0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
|
||||
0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
|
||||
0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
|
||||
0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
|
||||
0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
|
||||
0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
|
||||
0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
|
||||
0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
|
||||
0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
|
||||
0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
|
||||
0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
|
||||
0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
|
||||
0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Build the reply to a feature GET_REPORT — the answer half of the Valve query dance. Steam's
|
||||
/// `GetControllerInfo` SETs a query (`0x83` attributes / `0xAE` string) and then GETs the answer;
|
||||
/// **the reply's command byte must echo the LAST SET's command** or Steam treats the pad as
|
||||
/// broken and never adopts it (confirmed on-glass 2026-07-15: answering every GET with a serial
|
||||
/// blob left the virtual pad unpicked). The frame rides feature report id **1**
|
||||
/// (`[0x01][cmd][len][payload…]`, matching SDL's send framing for this device), and the `0x83`
|
||||
/// blob carries the Triton's product id. The attribute VALUES beyond the product id mirror the
|
||||
/// Deck's hidraw capture (same firmware family conventions) — replace them with a capture from
|
||||
/// a physical pad if Steam still balks.
|
||||
///
|
||||
/// `last_set` is the id-first SET payload (`[0x01, cmd, …]`); a stack that already stripped the
|
||||
/// id byte (`[cmd, …]`, cmd ≥ 0x80) is handled too.
|
||||
///
|
||||
/// MOVED VERBATIM from `crates/pf-inject/src/inject/proto/triton_proto.rs:290-365`
|
||||
/// (fn `triton_feature_reply`) — do not re-derive it. Two mechanical fixups the move needed:
|
||||
/// (1) the body referenced the module const `TRITON_WIRED_PRODUCT` (triton_proto.rs:31) —
|
||||
/// repointed to this module's [`WIRED_PRODUCT`];
|
||||
/// (2) `ATTRIB_STR_UNIT_SERIAL` and the `ID_*` consts were declared INSIDE the fn body —
|
||||
/// the `ID_*` consts now come from this module (shadow-free); `ATTRIB_STR_UNIT_SERIAL`
|
||||
/// stays fn-local, since it isn't part of this module's public surface.
|
||||
pub fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; 64] {
|
||||
const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
|
||||
|
||||
let body = match last_set {
|
||||
[0x01, rest @ ..] => rest,
|
||||
d => d,
|
||||
};
|
||||
let cmd = body.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
|
||||
|
||||
let mut r = [0u8; 64];
|
||||
r[0] = 0x01;
|
||||
match cmd {
|
||||
ID_GET_ATTRIBUTES_VALUES => {
|
||||
// Captured controller response: 25-byte payload containing five id/u32 attributes.
|
||||
r[1] = ID_GET_ATTRIBUTES_VALUES;
|
||||
r[2] = 0x19;
|
||||
let attrs = [
|
||||
(0x01, WIRED_PRODUCT),
|
||||
(0x02, 0),
|
||||
(0x0A, unit_id),
|
||||
// Tag 4 = ATTRIB_FIRMWARE_BUILD_TIME. Was `unit_id ^ 0x0296_DAF9` (≈ Feb
|
||||
// 2016 as an epoch) — Steam read it as ancient firmware and prompted to
|
||||
// update the virtual pad. See [`FW_BUILD_TIME`].
|
||||
(0x04, FW_BUILD_TIME),
|
||||
(0x09, 0x49),
|
||||
];
|
||||
let mut o = 3;
|
||||
for (id, val) in attrs {
|
||||
r[o] = id;
|
||||
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
|
||||
o += 5;
|
||||
}
|
||||
}
|
||||
ID_GET_STRING_ATTRIBUTE => {
|
||||
// Captured replies always declare 20 bytes: attribute id plus a 19-byte padded string.
|
||||
let attr = body.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().min(19);
|
||||
r[..4].copy_from_slice(&[0x01, ID_GET_STRING_ATTRIBUTE, 0x14, attr]);
|
||||
r[4..4 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
ID_GET_FIRMWARE_INFO => {
|
||||
let index = body.get(2).copied().unwrap_or(0);
|
||||
r[1] = ID_GET_FIRMWARE_INFO;
|
||||
r[3] = index;
|
||||
match index {
|
||||
0 => {
|
||||
r[2] = 0x29;
|
||||
// Mirrors the 0x83 reply's tag-4 attribute — the two build-time fields
|
||||
// carried the same value before [`FW_BUILD_TIME`] existed and must keep
|
||||
// agreeing (Steam may cross-check them).
|
||||
r[4..8].copy_from_slice(&FW_BUILD_TIME.to_le_bytes());
|
||||
r[8] = 0x49;
|
||||
r[12..24].copy_from_slice(b"603f69218a85");
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().min(16);
|
||||
r[28..28 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
1 => {
|
||||
r[2] = 0x22;
|
||||
r[4..37].copy_from_slice(&[
|
||||
0x00, 0x57, 0xD0, 0x18, 0x6A, 0x37, 0x30, 0x35, 0x34, 0x32, 0x35, 0x37,
|
||||
0x64, 0x32, 0x64, 0x61, 0x37, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x6D, 0x02, 0x00,
|
||||
]);
|
||||
}
|
||||
_ => {
|
||||
r[2] = 0x09;
|
||||
r[4..12].copy_from_slice(&[0x7C, 0x4F, 0x01, 0x00, 0x01, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let n = body.len().min(63);
|
||||
r[1..1 + n].copy_from_slice(&body[..n]);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtual-pointer shared-memory layout (host ↔ the UMDF HID-mouse minidriver `pf_mouse`).
|
||||
///
|
||||
/// Why a virtual mouse exists at all: with no pointing device present (a headless Windows host —
|
||||
@@ -2048,4 +2291,97 @@ mod tests {
|
||||
assert!(!DECK_PROOF_CMD.starts_with(&[0x83]) && !DECK_PROOF_CMD.starts_with(&[0xAE]));
|
||||
assert!(!DECK_PROOF_CMD.starts_with(&[0xEB]) && !DECK_PROOF_CMD.starts_with(&[0x8F]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triton_devtype_is_the_next_free_slot() {
|
||||
assert_eq!(gamepad::DEVTYPE_TRITON, 7);
|
||||
}
|
||||
|
||||
/// The GET reply echoes the LAST SET's command — the Valve query dance Steam's
|
||||
/// GetControllerInfo runs; a mismatched command type makes Steam drop the pad
|
||||
/// (on-glass 2026-07-15, and the Phase-0 static-zeros run looped forever).
|
||||
#[test]
|
||||
fn triton_feature_reply_echoes_the_queried_command() {
|
||||
// Settings write (lizard-off) reads back as a mirror.
|
||||
let set = [0x01, 0x87, 0x03, 0x09, 0x00, 0x00];
|
||||
let r = triton::feature_reply(&set, "FVPF130200D03", 0x5452_4900);
|
||||
assert_eq!(r[0], 0x01);
|
||||
assert_eq!(&r[1..6], &[0x87, 0x03, 0x09, 0x00, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triton_feature_reply_synthesizes_attributes_for_0x83() {
|
||||
let set = [0x01, 0x83, 0x00];
|
||||
let r = triton::feature_reply(&set, "FVPF130200D03", 0x5452_4900);
|
||||
assert_eq!(&r[..3], &[0x01, 0x83, 0x19]); // 25-byte TLV payload
|
||||
assert_eq!(r[3], 0x01); // first attribute id: product id
|
||||
// Tag-4 TLV (ATTRIB_FIRMWARE_BUILD_TIME) carries FW_BUILD_TIME — the Bench-1 regression
|
||||
// pin: a stale epoch here makes Steam prompt to "update" the virtual pad's firmware.
|
||||
assert_eq!(r[18], 0x04);
|
||||
assert_eq!(r[19..23], triton::FW_BUILD_TIME.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triton_firmware_info_build_time_agrees_with_the_attributes_reply() {
|
||||
let set = [0x01, 0xF2, 0x00, 0x00];
|
||||
let r = triton::feature_reply(&set, "FVPF130200D03", 0x5452_4900);
|
||||
assert_eq!(&r[..4], &[0x01, 0xF2, 0x29, 0x00]);
|
||||
// Bytes 4..8 mirror the 0x83 reply's tag-4 build time — Steam may cross-check the two.
|
||||
assert_eq!(r[4..8], triton::FW_BUILD_TIME.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triton_input_len_matches_the_descriptor() {
|
||||
assert_eq!(triton::input_len(0x42), Some(54));
|
||||
assert_eq!(triton::input_len(0x45), Some(46));
|
||||
assert_eq!(triton::input_len(0x43), Some(15));
|
||||
assert_eq!(triton::input_len(0x44), Some(6));
|
||||
assert_eq!(triton::input_len(0x79), Some(2));
|
||||
assert_eq!(triton::input_len(0x7B), Some(13));
|
||||
assert_eq!(triton::input_len(0x47), None); // BLE-only id, not in the wired descriptor
|
||||
assert_eq!(triton::input_len(0x01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triton_out_report_len_matches_the_descriptor_and_bench_table() {
|
||||
assert_eq!(triton::out_report_len(0x80), 10);
|
||||
assert_eq!(triton::out_report_len(0x81), 8);
|
||||
assert_eq!(triton::out_report_len(0x82), 4);
|
||||
assert_eq!(triton::out_report_len(0x83), 10);
|
||||
assert_eq!(triton::out_report_len(0x84), 9);
|
||||
assert_eq!(triton::out_report_len(0x85), 4);
|
||||
assert_eq!(triton::out_report_len(0x86), 4);
|
||||
assert_eq!(triton::out_report_len(0x87), 64);
|
||||
assert_eq!(triton::out_report_len(0x88), 64);
|
||||
assert_eq!(triton::out_report_len(0x89), 64);
|
||||
// Undeclared ids stay whole (64 = no trim) — never guess a length.
|
||||
assert_eq!(triton::out_report_len(0x00), 64);
|
||||
assert_eq!(triton::out_report_len(0x8A), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triton_serial_shape_dodges_the_pf_prefix_rejection() {
|
||||
let mut s = [0u8; 13];
|
||||
triton::serial(3, &mut s);
|
||||
assert_eq!(&s, b"FVPF130203D03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triton_rdesc_is_the_372_byte_capture() {
|
||||
assert_eq!(triton::RDESC.len(), 372);
|
||||
// Mouse TLC opens it: Usage Page Generic Desktop, Usage Mouse, Collection App, Report ID 0x40.
|
||||
assert_eq!(
|
||||
&triton::RDESC[..8],
|
||||
&[0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_feature_bit_round_trips() {
|
||||
let tagged = 64u32 | triton::OUT_FEATURE_BIT;
|
||||
assert_eq!(triton::out_len(tagged), 64);
|
||||
assert!(triton::out_is_feature(tagged));
|
||||
assert!(!triton::out_is_feature(64));
|
||||
assert_eq!(triton::out_len(64), 64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,40 +49,12 @@ pub const TRITON_STATE_LEN: usize = 54;
|
||||
/// inputs `0x40`–`0x45`/`0x79`/`0x7B`, outputs `0x80`–`0x89`, and feature channels `1` and `2`.
|
||||
/// In particular, Puck connection and bond queries use feature report 2; an unnumbered minimal
|
||||
/// descriptor makes hidraw frame those queries incorrectly and Steam eventually closes the device.
|
||||
#[rustfmt::skip]
|
||||
pub const TRITON_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
|
||||
0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
|
||||
0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
|
||||
0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
|
||||
0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
|
||||
0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
|
||||
0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
|
||||
0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
|
||||
0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
|
||||
0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
|
||||
0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
|
||||
0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
|
||||
0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
|
||||
0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
|
||||
0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
|
||||
0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
|
||||
0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
|
||||
0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
|
||||
0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
|
||||
];
|
||||
///
|
||||
/// The bytes now live once in [`pf_driver_proto::triton::RDESC`]; this is a reference to that
|
||||
/// array rather than a direct `pub use` re-export so the type stays `&[u8]` (not `[u8; 372]`) —
|
||||
/// the Linux usbip/uhid legs pass this straight into `copy_from_slice(&[u8])`, which an owned
|
||||
/// array wouldn't coerce into at the call site.
|
||||
pub const TRITON_RDESC: &[u8] = &pf_driver_proto::triton::RDESC;
|
||||
|
||||
/// Triton button bits in the state report's `buttons` u32 — transcribed verbatim from SDL's
|
||||
/// `TritonButtons`. Only the bits the typed fallback synthesizes are named; the raw path carries
|
||||
@@ -263,7 +235,7 @@ pub fn strip_report_prefix(data: &[u8]) -> &[u8] {
|
||||
|
||||
/// Per-instance unit id stamped into the fake `0x83` attributes (`'T','R','I'` + index).
|
||||
pub fn triton_unit_id(index: u8) -> u32 {
|
||||
0x5452_4900 | index as u32
|
||||
pf_driver_proto::triton::unit_id(index)
|
||||
}
|
||||
|
||||
/// The virtual pad's serial, FVPF-prefixed: the physical-Steam-controller conflict gate
|
||||
@@ -288,80 +260,7 @@ pub fn triton_serial(index: u8) -> String {
|
||||
/// `last_set` is the id-first SET payload (`[0x01, cmd, …]`); a stack that already stripped the
|
||||
/// id byte (`[cmd, …]`, cmd ≥ 0x80) is handled too.
|
||||
pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; 64] {
|
||||
const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83;
|
||||
const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
|
||||
const ID_GET_FIRMWARE_INFO: u8 = 0xF2;
|
||||
const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
|
||||
|
||||
let body = match last_set {
|
||||
[0x01, rest @ ..] => rest,
|
||||
d => d,
|
||||
};
|
||||
let cmd = body.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
|
||||
|
||||
let mut r = [0u8; 64];
|
||||
r[0] = 0x01;
|
||||
match cmd {
|
||||
ID_GET_ATTRIBUTES_VALUES => {
|
||||
// Captured controller response: 25-byte payload containing five id/u32 attributes.
|
||||
r[1] = ID_GET_ATTRIBUTES_VALUES;
|
||||
r[2] = 0x19;
|
||||
let attrs = [
|
||||
(0x01, TRITON_WIRED_PRODUCT),
|
||||
(0x02, 0),
|
||||
(0x0A, unit_id),
|
||||
(0x04, unit_id ^ 0x0296_DAF9),
|
||||
(0x09, 0x49),
|
||||
];
|
||||
let mut o = 3;
|
||||
for (id, val) in attrs {
|
||||
r[o] = id;
|
||||
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
|
||||
o += 5;
|
||||
}
|
||||
}
|
||||
ID_GET_STRING_ATTRIBUTE => {
|
||||
// Captured replies always declare 20 bytes: attribute id plus a 19-byte padded string.
|
||||
let attr = body.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().min(19);
|
||||
r[..4].copy_from_slice(&[0x01, ID_GET_STRING_ATTRIBUTE, 0x14, attr]);
|
||||
r[4..4 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
ID_GET_FIRMWARE_INFO => {
|
||||
let index = body.get(2).copied().unwrap_or(0);
|
||||
r[1] = ID_GET_FIRMWARE_INFO;
|
||||
r[3] = index;
|
||||
match index {
|
||||
0 => {
|
||||
r[2] = 0x29;
|
||||
r[4..8].copy_from_slice(&(unit_id ^ 0x0296_DAF9).to_le_bytes());
|
||||
r[8] = 0x49;
|
||||
r[12..24].copy_from_slice(b"603f69218a85");
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().min(16);
|
||||
r[28..28 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
1 => {
|
||||
r[2] = 0x22;
|
||||
r[4..37].copy_from_slice(&[
|
||||
0x00, 0x57, 0xD0, 0x18, 0x6A, 0x37, 0x30, 0x35, 0x34, 0x32, 0x35, 0x37,
|
||||
0x64, 0x32, 0x64, 0x61, 0x37, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x6D, 0x02, 0x00,
|
||||
]);
|
||||
}
|
||||
_ => {
|
||||
r[2] = 0x09;
|
||||
r[4..12].copy_from_slice(&[0x7C, 0x4F, 0x01, 0x00, 0x01, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let n = body.len().min(63);
|
||||
r[1..1 + n].copy_from_slice(&body[..n]);
|
||||
}
|
||||
}
|
||||
r
|
||||
pf_driver_proto::triton::feature_reply(last_set, serial, unit_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -446,4 +345,13 @@ mod tests {
|
||||
let r = triton_feature_reply(&[0x01, 0x87, 3, 9, 0, 0], &serial, uid);
|
||||
assert_eq!(&r[..6], &[0x01, 0x87, 3, 9, 0, 0]);
|
||||
}
|
||||
|
||||
/// Pad indices are < 100 by construction; the no_std helper wraps mod 100 where format!
|
||||
/// would grow to 3 digits — this test pins agreement over the real range.
|
||||
#[test]
|
||||
fn serial_string_matches_the_no_std_bytes() {
|
||||
let mut b = [0u8; 13];
|
||||
pf_driver_proto::triton::serial(7, &mut b);
|
||||
assert_eq!(triton_serial(7).as_bytes(), &b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,9 +152,30 @@ fn accept(
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a SET→GET command reply into a validated pid. The driver answers with the payload
|
||||
/// `[DECK_PROOF_CMD, ChannelProof, zeros…]`; Windows hands it back either as served (offset 0) or
|
||||
/// one byte in, behind the report-id slot (the unnumbered-report marshalling). Accept either
|
||||
/// placement rather than pinning a marshalling detail that differs between the descriptors this
|
||||
/// one driver serves.
|
||||
fn proof_from_reply(reply: &[u8], expect: u32, rejected: &mut Option<&'static str>) -> Option<u32> {
|
||||
for off in [0usize, 1] {
|
||||
let Some(body) = reply.get(off..) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(tail) = body.strip_prefix(&DECK_PROOF_CMD[..])
|
||||
&& let Some(pid) = accept(ChannelProof::from_bytes(tail), expect, rejected)
|
||||
{
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The PS identities answer on the declared-but-unserved report `0x85`; the Deck answers its
|
||||
/// unnumbered report after a private SET_FEATURE command. Both are tried — one driver binary serves
|
||||
/// four identities and the host does not know which one this devnode became until the DATA section
|
||||
/// unnumbered report after a private SET_FEATURE command; the Triton answers that SAME command on
|
||||
/// its declared feature id `0x01` — a numbered-report collection, where hidclass refuses the other
|
||||
/// two frames outright (see the third leg below). All are tried — one driver binary serves every
|
||||
/// pad identity and the host does not know which one this devnode became until the DATA section
|
||||
/// is attached, which is precisely what we are trying to earn the right to do.
|
||||
///
|
||||
/// So neither answer can be trusted on shape alone: a Deck serves its ONE unnumbered feature report
|
||||
@@ -189,21 +210,38 @@ fn ask_feature(h: HANDLE, expect_pad_index: u32) -> Result<u32> {
|
||||
if set_ok {
|
||||
let mut reply = vec![0u8; buf_len];
|
||||
// SAFETY: as above.
|
||||
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), buf_len as u32) } {
|
||||
// The driver answers with the payload; on an unnumbered report Windows hands it back
|
||||
// one byte in, behind the report-id slot. Accept either placement rather than pinning a
|
||||
// marshalling detail that differs between the two descriptors this one driver serves.
|
||||
for off in [0usize, 1] {
|
||||
let Some(body) = reply.get(off..) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(tail) = body.strip_prefix(&DECK_PROOF_CMD[..]) {
|
||||
let p = ChannelProof::from_bytes(tail);
|
||||
if let Some(pid) = accept(p, expect_pad_index, &mut rejected) {
|
||||
return Ok(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), buf_len as u32) }
|
||||
&& let Some(pid) = proof_from_reply(&reply, expect_pad_index, &mut rejected)
|
||||
{
|
||||
return Ok(pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Numbered-report collections (Triton): the SAME SET→GET command contract, framed id-first.
|
||||
// hidclass rejects a `HidD_SetFeature`/`HidD_GetFeature` buffer whose byte 0 is not a declared
|
||||
// NONZERO feature report id (the gating the driver's XBOX_RDESC feature-report note records:
|
||||
// built without a declared feature id the pad "enumerates perfectly and then delivers
|
||||
// NOTHING"), and the Triton descriptor declares feature ids 0x01/0x02 while `0x85` is one of
|
||||
// its OUTPUT reports — so on that collection BOTH legs above die at hidclass before the driver
|
||||
// ever sees them. Ride the proof on declared id 0x01 instead: the driver strips a leading
|
||||
// 0x00 OR 0x01 before matching the command (`triton_proof_requested`), so this frame lands on
|
||||
// the same proof machine, and its `[DECK_PROOF_CMD, proof…]` answer parses through the same
|
||||
// offset-0/offset-1 logic as the Deck's. Defensive-in-depth, like the whole function: the legs
|
||||
// run in order and whichever one the transport accepts wins.
|
||||
let mut cmd = vec![0u8; buf_len];
|
||||
cmd[0] = 0x01;
|
||||
cmd[1..1 + DECK_PROOF_CMD.len()].copy_from_slice(&DECK_PROOF_CMD);
|
||||
// SAFETY: `h` is live; `cmd` is a valid `buf_len`-sized buffer.
|
||||
let set_ok_numbered = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), buf_len as u32) };
|
||||
if set_ok_numbered {
|
||||
let mut reply = vec![0u8; buf_len];
|
||||
// The GET buffer must name a declared feature id too — the same hidclass gate.
|
||||
reply[0] = 0x01;
|
||||
// SAFETY: as above.
|
||||
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), buf_len as u32) }
|
||||
&& let Some(pid) = proof_from_reply(&reply, expect_pad_index, &mut rejected)
|
||||
{
|
||||
return Ok(pid);
|
||||
}
|
||||
}
|
||||
// A well-formed answer that failed validation is a different fault from no answer at all, and
|
||||
@@ -212,14 +250,20 @@ fn ask_feature(h: HANDLE, expect_pad_index: u32) -> Result<u32> {
|
||||
bail!("{why}");
|
||||
}
|
||||
bail!(
|
||||
"this HID collection carries no channel proof (feature 0x{:02x}: no; Deck command: {}) — \
|
||||
the driver predates the proof (reinstall: punktfunk-host.exe driver install --gamepad)",
|
||||
"this HID collection carries no channel proof (feature 0x{:02x}: no; Deck command: {}; \
|
||||
numbered command: {}) — the driver predates the proof (reinstall: punktfunk-host.exe \
|
||||
driver install --gamepad)",
|
||||
HID_FEATURE_REPORT_CHANNEL_PROOF,
|
||||
if set_ok {
|
||||
"no matching reply"
|
||||
} else {
|
||||
"SET_FEATURE failed"
|
||||
},
|
||||
if set_ok_numbered {
|
||||
"no matching reply"
|
||||
} else {
|
||||
"SET_FEATURE failed"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -139,13 +139,34 @@ impl OutputDrain {
|
||||
}
|
||||
|
||||
/// Drain every output report published since the last call, oldest → newest, invoking
|
||||
/// `per_report` with each report's exact bytes. Returns `true` on ring OVERFLOW — more than
|
||||
/// the negotiated ring length landed since the last poll (or the driver lapped us mid-copy):
|
||||
/// the pending window was DISCARDED as possibly torn, the legacy latest-report slot was
|
||||
/// salvaged into ONE `per_report` call (the freshest coalesced state — the driver
|
||||
/// dual-publishes every report there), and the caller must still treat its downstream
|
||||
/// feedback state as unknown (`PadFeedback::resync`) for the planes that report didn't carry.
|
||||
pub(super) fn drain(&mut self, base: *mut u8, mut per_report: impl FnMut(&[u8])) -> bool {
|
||||
/// `per_report` with each report's exact bytes plus a `feature` flag. The flag is
|
||||
/// [`pf_driver_proto::triton::out_is_feature`] applied to the slot's raw ring length — bit 31
|
||||
/// marks a Triton FEATURE set (Steam's lizard-off / IMU-enable SETs) as opposed to an ordinary
|
||||
/// OUTPUT report (rumble); every non-Triton devtype's producer never sets the bit, so it reads
|
||||
/// as `false` there. [`pf_driver_proto::triton::out_len`] masks the bit back OUT of the raw
|
||||
/// value **before** the existing 64-byte slot-length clamp below, so a tagged slot's length
|
||||
/// still clamps on its real payload size, not on `raw_len | 0x8000_0000`.
|
||||
///
|
||||
/// Returns `true` on ring OVERFLOW — more than the negotiated ring length landed since the
|
||||
/// last poll (or the driver lapped us mid-copy): the pending window was DISCARDED as possibly
|
||||
/// torn, the legacy latest-report slot was salvaged into ONE `per_report` call (the freshest
|
||||
/// coalesced state — the driver dual-publishes every report there), and the caller must still
|
||||
/// treat its downstream feedback state as unknown (`PadFeedback::resync`) for the planes that
|
||||
/// report didn't carry.
|
||||
///
|
||||
/// **KNOWN LIMIT (accepted):** the overflow-salvage path just below and the legacy
|
||||
/// (pre-ring-driver) path at the bottom both read the section's single untagged
|
||||
/// latest-output slot, which carries no feature bit at all — every frame they deliver comes
|
||||
/// through `per_report` with `feature == false`, whatever it actually was. A FEATURE report
|
||||
/// that lands during a ring overflow, or on an old driver that never wrote the ring, therefore
|
||||
/// replays on the client as an OUTPUT report — one wrong BLE characteristic write. This heals
|
||||
/// itself: Steam re-sends the same settings SET roughly every 3 s, and the next ring-fed poll
|
||||
/// carries the correct tag.
|
||||
pub(super) fn drain_tagged(
|
||||
&mut self,
|
||||
base: *mut u8,
|
||||
mut per_report: impl FnMut(&[u8], bool),
|
||||
) -> bool {
|
||||
// SAFETY: base points at SHM_SIZE bytes; `OFF_RING_HEAD` (== 160) is 4-aligned off the
|
||||
// page-aligned base. The driver bumps `ring_head` AFTER writing the slot, so an Acquire
|
||||
// load orders the slot copies below — the same pairing the legacy `out_seq` idiom uses.
|
||||
@@ -178,15 +199,16 @@ impl OutputDrain {
|
||||
// when the window provably stayed inside the ring.
|
||||
let n = pending as usize;
|
||||
let mut bufs =
|
||||
[([0u8; 64], 0usize); pf_driver_proto::gamepad::OUT_RING_LEN_V22_USIZE];
|
||||
[([0u8; 64], 0usize, false); pf_driver_proto::gamepad::OUT_RING_LEN_V22_USIZE];
|
||||
for (k, buf) in bufs.iter_mut().enumerate().take(n) {
|
||||
let idx = (self.tail.wrapping_add(k as u32) % ring_len) as usize;
|
||||
let slot = OFF_OUT_RING + idx * OUT_SLOT_SIZE;
|
||||
// SAFETY: slot .. slot+OUT_SLOT_SIZE is inside the SHM_SIZE section (idx <
|
||||
// `ring_len` ≤ OUT_RING_LEN_V22, whose last slot ends at 4064 ≤ SHM_SIZE);
|
||||
// the len field is 4-aligned (`OFF_OUT_RING` == 256, `OUT_SLOT_SIZE` == 68).
|
||||
let len = unsafe { std::ptr::read_unaligned(base.add(slot) as *const u32) };
|
||||
buf.1 = (len as usize).min(64);
|
||||
let raw_len = unsafe { std::ptr::read_unaligned(base.add(slot) as *const u32) };
|
||||
buf.2 = pf_driver_proto::triton::out_is_feature(raw_len);
|
||||
buf.1 = (pf_driver_proto::triton::out_len(raw_len) as usize).min(64);
|
||||
// SAFETY: the slot's data region is slot+4 .. slot+4+64, inside the section;
|
||||
// `buf.0` is a live local 64-byte array.
|
||||
unsafe {
|
||||
@@ -198,9 +220,9 @@ impl OutputDrain {
|
||||
(*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire)
|
||||
};
|
||||
if head2.wrapping_sub(self.tail) <= ring_len {
|
||||
for (data, len) in bufs.iter().take(n) {
|
||||
for (data, len, feature) in bufs.iter().take(n) {
|
||||
if *len > 0 {
|
||||
per_report(&data[..*len]);
|
||||
per_report(&data[..*len], *feature);
|
||||
}
|
||||
}
|
||||
self.tail = head;
|
||||
@@ -216,19 +238,21 @@ impl OutputDrain {
|
||||
// value lasts one poll at storm rates, and the caller's resync still silences every
|
||||
// plane the salvaged report doesn't assert. A report that lands between the reload
|
||||
// and the copy is salvaged now AND drained next poll — harmless, reports are
|
||||
// valid-flag-gated state and the caller's dedup drops the repeat.
|
||||
// valid-flag-gated state and the caller's dedup drops the repeat. No feature tag lives
|
||||
// on this slot — see the KNOWN LIMIT above.
|
||||
// SAFETY: as the first `ring_head` load above.
|
||||
self.tail =
|
||||
unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) };
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: the legacy output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe { std::ptr::copy_nonoverlapping(base.add(OFF_OUTPUT), out.as_mut_ptr(), 64) };
|
||||
per_report(&out);
|
||||
per_report(&out, false);
|
||||
return true;
|
||||
}
|
||||
// Legacy driver (never wrote the ring): the latest-report slot + seq — exactly the old
|
||||
// single-slot semantics, coalescing and all; the rumble-keyed idle watchdog is the bound
|
||||
// there until the driver package is updated.
|
||||
// there until the driver package is updated. No feature tag lives on this slot either —
|
||||
// see the KNOWN LIMIT above.
|
||||
// SAFETY: `OFF_OUT_SEQ` (== 72) is 4-aligned off the page-aligned base; Acquire pairs with
|
||||
// the driver's publish-then-bump store order.
|
||||
let seq = unsafe { (*(base.add(OFF_OUT_SEQ) as *const AtomicU32)).load(Ordering::Acquire) };
|
||||
@@ -237,10 +261,17 @@ impl OutputDrain {
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe { std::ptr::copy_nonoverlapping(base.add(OFF_OUTPUT), out.as_mut_ptr(), 64) };
|
||||
per_report(&out);
|
||||
per_report(&out, false);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Thin [`Self::drain_tagged`] delegate for every existing caller (DualSense/DualShock4/Edge/
|
||||
/// Deck) that has no use for the Triton feature/output tag — discards the flag and forwards
|
||||
/// the bytes exactly as before.
|
||||
pub(super) fn drain(&mut self, base: *mut u8, mut per_report: impl FnMut(&[u8])) -> bool {
|
||||
self.drain_tagged(base, |b, _| per_report(b))
|
||||
}
|
||||
}
|
||||
|
||||
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
||||
@@ -796,6 +827,24 @@ mod drain_tests {
|
||||
ring_publish(buf, bytes, OUT_RING_LEN, false);
|
||||
}
|
||||
|
||||
/// Mimic the Triton devtype's producer tagging a ring slot as a FEATURE frame: the same v2.1
|
||||
/// dual write as `publish`, except the slot's stamped length carries
|
||||
/// `pf_driver_proto::triton::OUT_FEATURE_BIT` ORed in — the tag `drain_tagged` must strip back
|
||||
/// out and surface as its `feature` flag.
|
||||
fn publish_tagged(buf: &mut [u32], bytes: &[u8]) {
|
||||
legacy_publish(buf, bytes);
|
||||
let head = read32(buf, OFF_RING_HEAD);
|
||||
let slot = OFF_OUT_RING + (head % OUT_RING_LEN) as usize * OUT_SLOT_SIZE;
|
||||
write32(
|
||||
buf,
|
||||
slot,
|
||||
bytes.len() as u32 | pf_driver_proto::triton::OUT_FEATURE_BIT,
|
||||
);
|
||||
let b = bytes_mut(buf);
|
||||
b[slot + 4..slot + 4 + bytes.len()].copy_from_slice(bytes);
|
||||
write32(buf, OFF_RING_HEAD, head.wrapping_add(1));
|
||||
}
|
||||
|
||||
/// Mimic the v2.2 driver's dual write: same, with the long-ring slot math and the
|
||||
/// `out_ring_len` echo stamped before the head bump.
|
||||
fn v22_publish(buf: &mut [u32], bytes: &[u8]) {
|
||||
@@ -842,6 +891,24 @@ mod drain_tests {
|
||||
(got, resync)
|
||||
}
|
||||
|
||||
/// Triton feature frames ride the ring with bit 31 of `len` set; the tagged drain must strip
|
||||
/// the bit from the length and surface it as a flag. Untagged slots (every other devtype, and
|
||||
/// every plain `publish`) must come through with `feature == false`.
|
||||
#[test]
|
||||
fn tagged_drain_separates_feature_frames_from_output_frames() {
|
||||
let mut buf = section();
|
||||
publish(&mut buf, &[0x80, 0x00, 0xFF]); // plain output frame
|
||||
publish_tagged(&mut buf, &[0x01, 0x87, 0x03, 0x09, 0x00, 0x00]); // feature frame (len | bit31)
|
||||
let mut got = Vec::new();
|
||||
let mut d = OutputDrain::new(); // no Default impl exists — every drain test uses new()
|
||||
d.drain_tagged(base(&mut buf), |bytes, feature| {
|
||||
got.push((bytes.to_vec(), feature));
|
||||
});
|
||||
assert_eq!(got[0], (vec![0x80, 0x00, 0xFF], false));
|
||||
assert_eq!(got[1].0, vec![0x01, 0x87, 0x03, 0x09, 0x00, 0x00]);
|
||||
assert!(got[1].1);
|
||||
}
|
||||
|
||||
/// THE stop-coalesce repro (`design/rumble-root-fix.md` §A): a rumble-stop report followed by
|
||||
/// an LED-only report inside one poll window must yield BOTH, oldest first — on the legacy
|
||||
/// single slot the stop was overwritten and gone forever.
|
||||
@@ -1031,6 +1098,7 @@ mod drain_tests {
|
||||
WinDsIdentity::dualsense_edge().hwid,
|
||||
super::super::dualshock4_windows::DS4_HWID,
|
||||
super::super::steam_deck_windows::DECK_HWID,
|
||||
super::super::triton_windows::TRITON_HWID,
|
||||
]
|
||||
.into_iter()
|
||||
// Every Xbox identity, not just the first — a new one added to the table without its INF
|
||||
@@ -1167,7 +1235,7 @@ mod drain_tests {
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
7,
|
||||
8,
|
||||
"parsed {entries:?} out of the driver's table — the shape changed and this test went \
|
||||
vacuous; fix the parse rather than deleting the assert"
|
||||
);
|
||||
@@ -1194,6 +1262,10 @@ mod drain_tests {
|
||||
super::super::steam_deck_windows::DECK_HWID,
|
||||
pf_driver_proto::gamepad::DEVTYPE_STEAMDECK,
|
||||
),
|
||||
(
|
||||
super::super::triton_windows::TRITON_HWID,
|
||||
pf_driver_proto::gamepad::DEVTYPE_TRITON,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
// All three Xbox identities: they share a report descriptor, so a hwid→devtype slip does
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Virtual Steam Controller 2 (Triton, `28DE:1302`) on Windows over the pf_gamepad UMDF shm
|
||||
//! channel — the Windows analogue of the Linux UHID/usbip Triton backend (`super::steam_controller2`,
|
||||
//! Linux-only), sharing the whole transport-independent contract in [`crate::triton_proto`].
|
||||
//!
|
||||
//! Unlike the Deck backend ([`super::steam_deck_windows`]), this device is NOT re-synthesized
|
||||
//! from typed wire state: the client captures the physical controller (USB / wired / BLE) and
|
||||
//! forwards its raw input reports verbatim
|
||||
//! ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)); the host mirrors them into the
|
||||
//! section's input slot unchanged (the driver trims each to its declared report-id length before
|
||||
//! serving it to hidclass). Feedback runs the other way: everything Steam's hidraw consumer
|
||||
//! writes back — SET_REPORT features (lizard-off / IMU-enable / settings) and `0x80..` haptic
|
||||
//! OUTPUT reports — comes back on the section's out ring **kind-tagged** (bit 31 of the slot
|
||||
//! length, [`pf_driver_proto::triton::OUT_FEATURE_BIT`], drained by
|
||||
//! [`OutputDrain::drain_tagged`]) and is forwarded raw to the client as `HidOutput::HidRaw` for
|
||||
//! replay on the physical pad — FEATURE frames re-armed via `SET_REPORT(Feature)` / a GATT
|
||||
//! feature write, OUTPUT frames via the
|
||||
//! interrupt-OUT endpoint / a GATT characteristic write. Rumble is ALSO parsed out of the
|
||||
//! untagged OUTPUT plane onto the universal 0xCA plane, so a client's phone-mirror rumble path
|
||||
//! keeps working even without a raw feed.
|
||||
//!
|
||||
//! Transport = the same sealed shared-memory channel + `SwDeviceCreate` devnode shape the Deck
|
||||
//! backend uses (device-type [`DEVTYPE_TRITON`] instead of the Deck's `DEVTYPE_STEAMDECK`), with
|
||||
//! one identity delta: the real wired Triton is a **single-interface** USB device — its devnode
|
||||
//! carries no `MI_` token, and SDL's hidapi claim for `28DE:1302` matches on VID/PID alone, not on
|
||||
//! `bInterfaceNumber` the way the Deck's claim does — so `usb_mi` is `None` here (bench gate R2:
|
||||
//! if Steam still won't list the pad, A/B `Some(0)`; the Deck needed `Some(2)` for its
|
||||
//! multi-interface identity).
|
||||
|
||||
use super::dualsense_windows::{
|
||||
create_swdevice, publish_input, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO,
|
||||
OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
|
||||
};
|
||||
use super::gamepad_raii::{DriverAttach, PadChannel, ProofTransport, SwDevice};
|
||||
use crate::triton_proto::{
|
||||
parse_triton_rumble, serialize_triton_state, triton_serial, TritonState, TRITON_STATE_LEN,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use pf_driver_proto::gamepad::DEVTYPE_TRITON;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The hardware id this pad's devnode carries. Must be one `pf_gamepad.inx` declares — a package
|
||||
/// rename must never touch it.
|
||||
pub(super) const TRITON_HWID: &str = "pf_triton";
|
||||
|
||||
/// A single virtual Steam Controller 2: the `SwDeviceCreate`'d `pf_triton_<index>` devnode plus
|
||||
/// the sealed shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait).
|
||||
pub struct TritonWinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
_sw: Option<SwDevice>,
|
||||
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||
channel: PadChannel,
|
||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||
attach: DriverAttach,
|
||||
/// Synth-mode sequence counter — only advances for the typed fallback report; the raw path
|
||||
/// mirrors the physical pad's own report bytes (its own sequence byte included) unchanged.
|
||||
seq: u8,
|
||||
/// This pad's v2.3 input-seqlock generation — see `publish_input`.
|
||||
input_gen: u32,
|
||||
/// Output-plane cursor: the section's ring drain, kind-tagged for Triton's FEATURE/OUTPUT
|
||||
/// split (see [`OutputDrain::drain_tagged`]).
|
||||
drain: OutputDrain,
|
||||
}
|
||||
|
||||
impl TritonWinPad {
|
||||
/// Create the sealed channel, stamp `device_type = Triton` FIRST + the pad index + a neutral
|
||||
/// `0x42` report + the magic LAST, then spawn the `pf_triton_<index>` devnode.
|
||||
fn open(index: u8) -> Result<TritonWinPad> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = DEVTYPE_TRITON;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
// Ring capability `2` = "this host drains the v2.2 long ring", stamped before the
|
||||
// magic so the driver sees it on attach (see the DualSense open path + PadShm docs).
|
||||
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
|
||||
std::ptr::write_unaligned(
|
||||
base.add(OFF_INPUT) as *mut [u8; 64],
|
||||
neutral_triton_report(),
|
||||
);
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_triton_{index}");
|
||||
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: TRITON_HWID,
|
||||
usb_vid_pid: "VID_28DE&PID_1302",
|
||||
// The real wired Triton is single-interface — its devnode carries no MI_ token, and
|
||||
// SDL's claim for 0x1302 is VID/PID-only. Bench gate R2: if Steam balks, A/B Some(0)
|
||||
// here (the Deck needed Some(2) for its multi-interface model).
|
||||
usb_mi: None,
|
||||
description: "Punktfunk Virtual Steam Controller",
|
||||
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
|
||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
||||
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
|
||||
// the LocalService-writable mailbox names (security-review 2026-07-28).
|
||||
channel.bind_devnode(
|
||||
index as u32,
|
||||
instance_id.clone(),
|
||||
ProofTransport::HidFeatureReport,
|
||||
);
|
||||
let _sw = hsw.map(SwDevice::new);
|
||||
// Bounded eager delivery — the driver must read `device_type = Triton` before hidclass
|
||||
// asks it for descriptors, or the pad would enumerate with the default DualSense identity.
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(TritonWinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: DriverAttach::new(
|
||||
TRITON_HWID,
|
||||
"pf_gamepad.inf", // one driver package serves every identity
|
||||
"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
seq: 0,
|
||||
input_gen: 0,
|
||||
drain: OutputDrain::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirror one report into the section's input slot: the client's raw bytes verbatim in as-is
|
||||
/// mode, else a synthesized minimal `0x42` state report from the typed fallback fields —
|
||||
/// the same mirroring policy as the Linux backend's `TritonPad::write_state`.
|
||||
fn write_state(&mut self, st: &TritonState) {
|
||||
let mut r = [0u8; 64];
|
||||
if st.raw_len > 0 {
|
||||
let len = (st.raw_len as usize).min(st.raw.len()).min(r.len());
|
||||
r[..len].copy_from_slice(&st.raw[..len]);
|
||||
} else {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut s = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut s, st, self.seq);
|
||||
r[..TRITON_STATE_LEN].copy_from_slice(&s);
|
||||
}
|
||||
// SAFETY: same contract as DeckWinPad::write_state — the v2.3 input_gen seqlock.
|
||||
unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) };
|
||||
}
|
||||
|
||||
/// Drain Steam's writes: rumble for the universal 0xCA plane (parsed only out of an untagged
|
||||
/// OUTPUT report — a FEATURE frame is never a rumble command), everything raw and kind-tagged
|
||||
/// for the client's `[0xCD][0x05]` HidRaw plane. Also ticks the sealed-channel delivery and
|
||||
/// the driver-attach health watcher. Returns `(rumble, raw reports, resync)`; `resync` is the
|
||||
/// drain's ring-overflow flag and must reach `PadFeedback` unchanged — see
|
||||
/// `TritonWinProto::service`.
|
||||
fn service(&mut self, idx: u8) -> (Option<(u16, u16)>, Vec<HidOutput>, bool) {
|
||||
self.channel.pump();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let proto = unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||
};
|
||||
self.attach.observe(proto);
|
||||
let base = self.channel.data_base();
|
||||
let mut rumble = None;
|
||||
let mut hidout = Vec::new();
|
||||
let resync = self.drain.drain_tagged(base, |bytes, feature| {
|
||||
// Windows hidclass pads every output write to `OutputReportByteLength` (64) before
|
||||
// the driver rings it, whereas the Linux leg (`steam_controller2`) forwards native
|
||||
// lengths (a 0x80 rumble is 10 bytes). The client replays these bytes verbatim over
|
||||
// GATT, so trim each OUTPUT frame to its declared per-id wire length here — cross-OS
|
||||
// parity; no untested padding reaches the physical controller's firmware. FEATURE
|
||||
// frames deliberately stay whole: Steam SETs full-length feature reports, and both
|
||||
// the feature machine and the BLE feature replay consume whole frames. (The ring
|
||||
// path only delivers non-empty slices; the salvage/legacy paths deliver a fixed
|
||||
// 64-byte slice — an empty slice would pass through untrimmed.)
|
||||
let bytes = match (feature, bytes.first()) {
|
||||
(false, Some(&id)) => {
|
||||
&bytes[..bytes.len().min(pf_driver_proto::triton::out_report_len(id))]
|
||||
}
|
||||
_ => bytes,
|
||||
};
|
||||
if !feature {
|
||||
if let Some(r) = parse_triton_rumble(bytes) {
|
||||
rumble = Some(r);
|
||||
}
|
||||
}
|
||||
hidout.push(HidOutput::HidRaw {
|
||||
pad: idx,
|
||||
kind: if feature {
|
||||
HID_RAW_FEATURE
|
||||
} else {
|
||||
HID_RAW_OUTPUT
|
||||
},
|
||||
data: bytes.to_vec(),
|
||||
});
|
||||
});
|
||||
(rumble, hidout, resync)
|
||||
}
|
||||
}
|
||||
|
||||
/// A neutral wired-Triton `0x42` state report: report id plus an all-zero 53-byte payload — the
|
||||
/// Phase-0 canned shape a fresh or unplugged pad (re)starts from, before either a raw feed or the
|
||||
/// typed fallback publishes anything real.
|
||||
fn neutral_triton_report() -> [u8; 64] {
|
||||
let mut r = [0u8; 64];
|
||||
r[0] = 0x42;
|
||||
r
|
||||
}
|
||||
|
||||
/// The Windows-Triton half of the shared stateful manager (see [`PadProto`]): the sealed-channel
|
||||
/// open under the Triton identity, the same [`TritonState`] as-is mirroring + typed-fallback
|
||||
/// mappers the Linux backend uses, and the kind-tagged feedback poll. Lifecycle (slot table,
|
||||
/// unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`].
|
||||
///
|
||||
/// `Default` is REQUIRED: `UhidManager::new()` bounds `B: PadProto + Default`
|
||||
/// ([`crate::uhid_manager`]) — every backend's `Proto` derives it, this one included.
|
||||
#[derive(Default)]
|
||||
pub struct TritonWinProto;
|
||||
|
||||
impl PadProto for TritonWinProto {
|
||||
type Pad = TritonWinPad;
|
||||
type State = TritonState;
|
||||
const LABEL: &'static str = "Steam Controller 2/Windows";
|
||||
const DEVICE: &'static str = "Steam Controller 2";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<TritonWinPad> {
|
||||
let p = TritonWinPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
// The in-driver query-dance answers Steam's feature GET_REPORTs itself (unlike the
|
||||
// Linux UHID leg, which must round-trip through user-space); it derives this same
|
||||
// serial from the pad index with no host-side plumbing needed. Logged here purely as
|
||||
// an on-glass diagnostic breadcrumb — "what serial should Steam be showing".
|
||||
serial = %triton_serial(idx),
|
||||
"virtual Steam Controller 2 created (Windows UMDF shm channel, as-is raw passthrough)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> TritonState {
|
||||
TritonState::neutral()
|
||||
}
|
||||
|
||||
/// Typed fallback merge. Once raw reports flow (`raw_len > 0`) the frame only refreshes the
|
||||
/// typed fields for diagnostics — `write_state` keeps mirroring the raw report. Identical to
|
||||
/// the Linux `TritonProto::merge_frame` ("as-is mode is sticky").
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &TritonState,
|
||||
f: &punktfunk_core::input::GamepadFrame,
|
||||
) -> TritonState {
|
||||
let mut s = TritonState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
// As-is mode is sticky: a typed frame between two raw reports must not flap the pad back
|
||||
// to synth mode (the client sends BOTH planes — typed keeps the degrade paths alive).
|
||||
s.raw = prev.raw;
|
||||
s.raw_len = prev.raw_len;
|
||||
s
|
||||
}
|
||||
|
||||
fn apply_rich(&self, st: &mut TritonState, rich: RichInput) {
|
||||
if let RichInput::HidReport { len, data, .. } = rich {
|
||||
let len = (len as usize).min(data.len()).min(st.raw.len());
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
st.raw[..len].copy_from_slice(&data[..len]);
|
||||
st.raw_len = len as u8;
|
||||
}
|
||||
// Touchpad/Motion/TouchpadEx: nothing to fold — the raw feed carries pads + IMU natively,
|
||||
// and the synth fallback has no surface for them.
|
||||
}
|
||||
|
||||
// `neutralize_gyro` / `clear_rich` stay the no-op defaults — same rationale as the Linux
|
||||
// backend: this device never sees a `RichInput::Motion` to go stale, and its motion lives
|
||||
// inside an opaque passthrough report the trait has no business reaching into.
|
||||
|
||||
fn write_state(&self, pad: &mut TritonWinPad, st: &TritonState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Ack + forward Steam's writes: rumble on the universal 0xCA plane, everything raw
|
||||
/// (kind-tagged) on the `[0xCD][0x05]` HidRaw plane — mirrors the Linux `TritonProto::service`,
|
||||
/// plus forwarding the drain's own resync flag like `DeckWinProto::service` does (the Linux
|
||||
/// leg has no ring to overflow, hence its permanent `resync: false`; this backend's ring can,
|
||||
/// so hardcoding `false` here would silently swallow the Windows-only overflow signal).
|
||||
fn service(&self, pad: &mut TritonWinPad, idx: u8) -> PadFeedback {
|
||||
let (rumble, hidout, resync) = pad.service(idx);
|
||||
PadFeedback {
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
hidout,
|
||||
// Rumble-plane liveness: Steam is a hidraw writer here too, so the shared
|
||||
// abandoned-rumble force-off applies (the raw 0xCD passthrough plane is unaffected).
|
||||
rumble_drove: Some(rumble.is_some()),
|
||||
resync,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Steam Controller 2 pads of a Windows session — the analogue of the Linux
|
||||
/// `Triton2Manager`, with the same method surface (via the shared [`UhidManager`]) as the other
|
||||
/// Windows pad managers.
|
||||
pub type TritonWindowsManager = UhidManager<TritonWinProto>;
|
||||
@@ -677,14 +677,17 @@ pub mod steam_usbip;
|
||||
#[path = "inject/linux/switch_pro.rs"]
|
||||
pub mod switch_pro;
|
||||
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
|
||||
/// replies, used by the Linux UHID backend ([`switch_pro`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
/// replies, used by the Linux UHID backend (`switch_pro`). Deliberately NOT cfg-gated like
|
||||
/// `switch_pro` (and for the same reason as `triton_proto` below): pure byte-packing with no
|
||||
/// OS surface, so its layout tests — and the cross-backend motion **unit contract** in
|
||||
/// `tests/motion_contract.rs`, which pins this codec's IMU units alongside every other
|
||||
/// backend's — compile and run on any host, Windows included.
|
||||
#[path = "inject/proto/switch_proto.rs"]
|
||||
pub mod switch_proto;
|
||||
/// Transport-independent Steam Controller 2 (Triton) contract: descriptor, SDL-documented report
|
||||
/// layout, the typed fallback serializer, and the rumble-output parser. Linux-only consumer today
|
||||
/// ([`steam_controller2`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Transport-independent Steam Controller 2 (Triton) contract: state layout, feature
|
||||
/// query-dance, rumble parse. Deliberately NOT cfg-gated like `steam_controller2`: it is
|
||||
/// pure byte-packing with no OS surface, so its layout tests compile and run on any host —
|
||||
/// consumers are the Linux uhid/usbip leg and the Windows `triton_windows` backend.
|
||||
#[path = "inject/proto/triton_proto.rs"]
|
||||
pub mod triton_proto;
|
||||
/// Linux: virtual Steam Controller 2 over **USB/IP** — a real USB device byte-matched to the
|
||||
@@ -693,6 +696,13 @@ pub mod triton_proto;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/triton_usbip.rs"]
|
||||
pub mod triton_usbip;
|
||||
/// Windows: virtual Steam Controller 2 (Triton, `28DE:1302`) over the same UMDF minidriver +
|
||||
/// shared-memory channel (device-type 7) — as-is raw passthrough of a client-captured physical
|
||||
/// pad, with Steam's feature/output writes drained back kind-tagged (FEATURE vs OUTPUT) for
|
||||
/// replay on the client's real controller.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/triton_windows.rs"]
|
||||
pub mod triton_windows;
|
||||
/// Linux: the `/dev/uhid` event ABI shared by every UHID gamepad backend — the constants each
|
||||
/// used to transcribe for itself, plus the field accessors that read a payload's real length.
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -1170,13 +1170,24 @@ pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
|
||||
/// `PunktfunkHidOutput::kind` — a raw report the host's hidraw consumer (Steam) wrote to an
|
||||
/// as-is passthrough pad (`HidOutput::HidRaw`, the reverse of
|
||||
/// [`punktfunk_connection_send_hid_report`]): `hid_kind` (`PUNKTFUNK_HID_RAW_OUTPUT` /
|
||||
/// `PUNKTFUNK_HID_RAW_FEATURE`) + `raw`/`raw_len` valid. Replay it verbatim on the physical
|
||||
/// device — an OUTPUT report on the interrupt-OUT endpoint / per-report GATT characteristic
|
||||
/// (Triton rumble `0x80`, haptic pulse `0x81`, …), a FEATURE report as `SET_REPORT` / a GATT
|
||||
/// feature write (lizard mode, IMU enable). Only an as-is passthrough session
|
||||
/// (`PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2`) emits these; clients without such a capture drop them.
|
||||
pub const PUNKTFUNK_HIDOUT_HID_RAW: u8 = 6;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
/// One DualSense HID-output feedback event a game wrote to the host's virtual pad
|
||||
/// One HID-output feedback event a game wrote to the host's virtual pad
|
||||
/// ([`punktfunk_connection_next_hidout`]). `kind` selects which fields are meaningful — replay it
|
||||
/// on a real DualSense (lightbar color, player LEDs, or an adaptive-trigger effect via the
|
||||
/// platform's `GCDualSenseAdaptiveTrigger`-style API).
|
||||
/// on the real controller: DualSense feedback (lightbar color, player LEDs, an adaptive-trigger
|
||||
/// effect via the platform's `GCDualSenseAdaptiveTrigger`-style API), or — on an as-is Steam
|
||||
/// Controller 2 passthrough session — a raw report to forward verbatim
|
||||
/// (`PUNKTFUNK_HIDOUT_HID_RAW`).
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1202,15 +1213,26 @@ pub struct PunktfunkHidOutput {
|
||||
/// exported precisely so embedders can size their own buffers against it, and it declaring one
|
||||
/// number while the struct it describes hardcoded another was the whole hazard.
|
||||
pub effect: [u8; PUNKTFUNK_HID_EFFECT_MAX as usize],
|
||||
/// HidRaw: `PUNKTFUNK_HID_RAW_OUTPUT` (an OUTPUT report — a hidraw `write()`) or
|
||||
/// `PUNKTFUNK_HID_RAW_FEATURE` (a FEATURE report — `SET_REPORT`). Distinct from `kind`,
|
||||
/// which says this event IS a raw report; this says which device channel replays it.
|
||||
pub hid_kind: u8,
|
||||
/// HidRaw: number of valid bytes in `raw` (≤ `PUNKTFUNK_HID_REPORT_MAX`).
|
||||
pub raw_len: u8,
|
||||
/// HidRaw: the full report, id byte first — exactly what the host's hidraw consumer wrote
|
||||
/// (Steam writes feature frames whole, so trailing zero-padding is normal; OUTPUT frames
|
||||
/// arrive host-trimmed to the declared report length on current hosts). Sized off
|
||||
/// [`HID_REPORT_MAX`](crate::quic::HID_REPORT_MAX), the wire bound for the same bytes.
|
||||
pub raw: [u8; crate::quic::HID_REPORT_MAX],
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
impl PunktfunkHidOutput {
|
||||
/// `None` for a [`HidOutput::HidRaw`](crate::quic::HidOutput) — a raw passthrough report
|
||||
/// (up to 64 bytes) doesn't fit this struct's 11-byte `effect` buffer, and no C-ABI embedder
|
||||
/// declares the as-is SC2 kind that would receive one; the pull site skips it rather than
|
||||
/// truncating it into an unreplayable stub.
|
||||
fn from_hid(h: &crate::quic::HidOutput) -> Option<PunktfunkHidOutput> {
|
||||
/// Total since ABI v27: every [`HidOutput`](crate::quic::HidOutput) variant has a C
|
||||
/// representation. `HidRaw` used to map to `None` (the struct predated the as-is SC2
|
||||
/// passthrough and had no buffer for a 64-byte report; the pull site skipped it) — the
|
||||
/// Apple client now declares that kind, so the report rides `raw`/`raw_len`/`hid_kind`.
|
||||
fn from_hid(h: &crate::quic::HidOutput) -> PunktfunkHidOutput {
|
||||
use crate::quic::HidOutput;
|
||||
let mut out = PunktfunkHidOutput {
|
||||
kind: 0,
|
||||
@@ -1222,6 +1244,9 @@ impl PunktfunkHidOutput {
|
||||
which: 0,
|
||||
effect_len: 0,
|
||||
effect: [0u8; 11],
|
||||
hid_kind: 0,
|
||||
raw_len: 0,
|
||||
raw: [0u8; crate::quic::HID_REPORT_MAX],
|
||||
};
|
||||
match h {
|
||||
HidOutput::Led { pad, r, g, b } => {
|
||||
@@ -1261,7 +1286,16 @@ impl PunktfunkHidOutput {
|
||||
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
|
||||
out.effect_len = 6;
|
||||
}
|
||||
HidOutput::HidRaw { .. } => return None,
|
||||
HidOutput::HidRaw { pad, kind, data } => {
|
||||
out.kind = PUNKTFUNK_HIDOUT_HID_RAW;
|
||||
out.pad = *pad;
|
||||
out.hid_kind = *kind;
|
||||
// `decode` already bounds the Vec to HID_REPORT_MAX; clamp again so a
|
||||
// locally-constructed oversize value can never overrun the fixed body.
|
||||
let n = data.len().min(out.raw.len());
|
||||
out.raw[..n].copy_from_slice(&data[..n]);
|
||||
out.raw_len = n as u8;
|
||||
}
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
|
||||
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly
|
||||
@@ -1274,7 +1308,7 @@ impl PunktfunkHidOutput {
|
||||
out.effect_len = 6;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1353,6 +1387,12 @@ pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
||||
/// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
|
||||
/// `punktfunk_connection_send_rich_input2` (added with client capture).
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD_EX: u8 = 3;
|
||||
/// `RichInput::HidReport` kind on the wire (`[0xCC][0x04][pad][len][data…]`) — one raw HID input
|
||||
/// report from a client-captured controller, forwarded verbatim for the host's as-is virtual pad
|
||||
/// (the Steam Controller 2 passthrough, `PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2`). A C client sends it
|
||||
/// through [`punktfunk_connection_send_hid_report`], never by building the datagram itself; the
|
||||
/// constant exists so client-side tests can pin the wire byte against this header.
|
||||
pub const PUNKTFUNK_RICH_HID_REPORT: u8 = 4;
|
||||
|
||||
/// One rich client→host input for the host's virtual DualSense
|
||||
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
|
||||
@@ -1792,12 +1832,18 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_MISC1 == g::BTN_MISC1);
|
||||
};
|
||||
|
||||
// The additive M3 kinds (TouchpadEx / TrackpadHaptic) must never grow the legacy ABI structs —
|
||||
// they have no `struct_size` guard, so a layout change would corrupt old-built callers' buffers.
|
||||
// Neither struct has a `struct_size` guard, so a layout change corrupts old-built callers'
|
||||
// buffers — an ADDITIVE kind (the M3 TouchpadEx / TrackpadHaptic precedent) must never grow
|
||||
// them, and any deliberate widening has to arrive with an [`crate::ABI_VERSION`] bump so the
|
||||
// version equality check is what an old binary fails, not a memory write.
|
||||
// `PunktfunkRichInput` is frozen at its original 20 bytes. `PunktfunkHidOutput` was widened
|
||||
// ONCE, deliberately, with ABI v27 (19 → 85: the `hid_kind`/`raw_len`/`raw` tail for
|
||||
// `PUNKTFUNK_HIDOUT_HID_RAW`, appended so the pre-v27 prefix layout is unchanged) — see the v27
|
||||
// entry on [`crate::ABI_VERSION`] for why a new pull symbol was NOT the right shape there.
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
assert!(core::mem::size_of::<PunktfunkRichInput>() == 20);
|
||||
assert!(core::mem::size_of::<PunktfunkHidOutput>() == 19);
|
||||
assert!(core::mem::size_of::<PunktfunkHidOutput>() == 19 + 2 + crate::quic::HID_REPORT_MAX);
|
||||
};
|
||||
|
||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||
@@ -3665,8 +3711,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||
if f.opus.is_empty() || f.opus.len() > buf_len {
|
||||
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
|
||||
// as loss would synthesize concealment) or doesn't fit — report "nothing
|
||||
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
|
||||
// be undecodable anyway).
|
||||
// this poll" and let the embedder's poll loop continue (truncated Opus
|
||||
// would be undecodable anyway).
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
|
||||
@@ -4052,11 +4098,12 @@ pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger)
|
||||
/// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on
|
||||
/// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend
|
||||
/// emits these. Same threading rules as [`punktfunk_connection_next_rumble`] (one puller, may run
|
||||
/// alongside the other planes).
|
||||
/// Pull the next HID-output feedback event the host's virtual pad received from a game
|
||||
/// (DualSense lightbar / player LEDs / adaptive trigger — or, on an as-is Steam Controller 2
|
||||
/// passthrough session, a raw `PUNKTFUNK_HIDOUT_HID_RAW` report to replay verbatim), into
|
||||
/// `*out`. [`PunktfunkStatus::NoFrame`] on timeout, [`PunktfunkStatus::Closed`] once the session
|
||||
/// ended. Only the DualSense and SC2 host backends emit these. Same threading rules as
|
||||
/// [`punktfunk_connection_next_rumble`] (one puller, may run alongside the other planes).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHidOutput`.
|
||||
@@ -4082,17 +4129,12 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
|
||||
.inner
|
||||
.next_hidout(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(h) => match PunktfunkHidOutput::from_hid(&h) {
|
||||
Some(v) => {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this
|
||||
// path, written once by value.
|
||||
unsafe { *out = v };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
// A raw as-is passthrough report (no C representation) — report "nothing this
|
||||
// poll" and let the embedder's poll loop continue; see `from_hid`.
|
||||
None => PunktfunkStatus::NoFrame,
|
||||
},
|
||||
Ok(h) => {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this
|
||||
// path, written once by value.
|
||||
unsafe { *out = PunktfunkHidOutput::from_hid(&h) };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
@@ -4644,6 +4686,68 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
|
||||
})
|
||||
}
|
||||
|
||||
/// The clamp behind [`punktfunk_connection_send_hid_report`], split out so the tests reach it
|
||||
/// without a live connection: `pad` masked into the 16-pad wire space and the report bounded to
|
||||
/// [`HID_REPORT_MAX`](crate::quic::HID_REPORT_MAX) — the same rules the Android JNI shim applies
|
||||
/// (`clients/android/native/src/session/input.rs`, `nativeSendPadHidReport`), so the two client
|
||||
/// entry points can never disagree about what reaches the wire.
|
||||
#[cfg(feature = "quic")]
|
||||
fn hid_report_rich_input(pad: u8, report: &[u8]) -> crate::quic::RichInput {
|
||||
let n = report.len().min(crate::quic::HID_REPORT_MAX);
|
||||
let mut data = [0u8; crate::quic::HID_REPORT_MAX];
|
||||
data[..n].copy_from_slice(&report[..n]);
|
||||
crate::quic::RichInput::HidReport {
|
||||
pad: pad & 0xF,
|
||||
len: n as u8,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one raw HID input report from a client-captured controller — the as-is Steam Controller 2
|
||||
/// passthrough's up direction (`[0xCC][0x04]` on the wire, [`RichInput::HidReport`](crate::quic::RichInput))
|
||||
/// — as a QUIC datagram (non-blocking enqueue). `data[..len]` is the report exactly as the device
|
||||
/// produced it on its interrupt endpoint / GATT notify, id byte first (`0x42`/`0x45`/`0x47` state,
|
||||
/// `0x43` battery, …); `len` is clamped to `PUNKTFUNK_HID_REPORT_MAX` and `pad` masked into the
|
||||
/// 16-pad wire space. Best-effort/lossy by design — state reports are idempotent snapshots at the
|
||||
/// device's own rate, so a lost datagram self-heals on the next one. A no-op unless the pad
|
||||
/// declared `PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2` and the host runs the as-is backend.
|
||||
/// [`PunktfunkStatus::InvalidArg`] on an empty report.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `data` points to `len` readable bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_hid_report(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u8,
|
||||
data: *const u8,
|
||||
len: usize,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if data.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
if len == 0 {
|
||||
return PunktfunkStatus::InvalidArg;
|
||||
}
|
||||
// SAFETY: per the ABI contract - a caller-supplied pointer/length pair describing one
|
||||
// readable region, borrowed only for this call (the clamp copies before returning).
|
||||
let report =
|
||||
unsafe { std::slice::from_raw_parts(data, len.min(crate::quic::HID_REPORT_MAX)) };
|
||||
match c.inner.send_rich_input(hid_report_rich_input(pad, report)) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one stylus sample batch — `count` (`1..=PUNKTFUNK_PEN_BATCH_MAX`) state-full
|
||||
/// [`PunktfunkPenSample`]s, oldest first (a capture callback's coalesced samples) — as one
|
||||
/// `0xCC/0x05` pen datagram (non-blocking enqueue; design/pen-tablet-input.md). Split longer
|
||||
@@ -6163,6 +6267,21 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod abi_version_tests {
|
||||
/// Pin [`crate::ABI_VERSION`] — the value [`super::punktfunk_abi_version`] reports and every
|
||||
/// embedder equality-checks against its header's `PUNKTFUNK_ABI_VERSION` (the Apple client
|
||||
/// refuses to run on a mismatch; the v27 `PunktfunkHidOutput` widening's safety argument
|
||||
/// rests on that check). A bump must be DELIBERATE: it arrives with its own
|
||||
/// [`crate::ABI_VERSION`] doc entry AND this pin updated in the same change — the test
|
||||
/// exists so an accidental edit cannot drift the version silently.
|
||||
#[test]
|
||||
fn abi_version_is_pinned() {
|
||||
assert_eq!(crate::ABI_VERSION, 27);
|
||||
assert_eq!(super::punktfunk_abi_version(), 27);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod log_sink_tests {
|
||||
use super::*;
|
||||
@@ -6320,30 +6439,87 @@ mod tests {
|
||||
|
||||
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||
/// packing idiom — no per-kind struct growth; the v27 `raw` tail stays zero here).
|
||||
#[test]
|
||||
fn hidout_abi_maps_audio_ctl() {
|
||||
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0x17,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
|
||||
assert_eq!(out.pad, 3);
|
||||
assert_eq!(out.which, 0x17);
|
||||
assert_eq!(out.effect_len, 6);
|
||||
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
|
||||
assert_eq!(out.effect[6..], [0; 5]);
|
||||
// A raw passthrough report still has no C representation (skipped at the pull site).
|
||||
assert!(
|
||||
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: 0,
|
||||
data: vec![0x80],
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(out.raw_len, 0);
|
||||
}
|
||||
|
||||
/// The v27 inversion of the old HidRaw skip: a raw as-is passthrough report (Steam's hidraw
|
||||
/// write to the host's virtual SC2) now HAS a C representation — kind 6, the device channel
|
||||
/// in `hid_kind`, and the whole report in `raw`/`raw_len` — so the Apple client's SC2 capture
|
||||
/// can replay it on the physical controller instead of the pull site dropping it as NoFrame.
|
||||
#[test]
|
||||
fn hidout_abi_maps_hid_raw() {
|
||||
// An OUTPUT report (Triton grip rumble 0x80, host-trimmed to its declared 10 bytes).
|
||||
let rumble: Vec<u8> = vec![0x80, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
|
||||
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||
pad: 2,
|
||||
kind: crate::quic::HID_RAW_OUTPUT,
|
||||
data: rumble.clone(),
|
||||
});
|
||||
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_HID_RAW);
|
||||
assert_eq!(out.pad, 2);
|
||||
assert_eq!(out.hid_kind, crate::quic::HID_RAW_OUTPUT);
|
||||
assert_eq!(out.raw_len, 10);
|
||||
assert_eq!(out.raw[..10], rumble[..]);
|
||||
assert_eq!(out.raw[10..], [0; crate::quic::HID_REPORT_MAX - 10]);
|
||||
// The other fields stay zero — `kind` alone says which ones are meaningful.
|
||||
assert_eq!(out.effect_len, 0);
|
||||
|
||||
// A FEATURE frame arrives WHOLE (64 bytes, zero-padded — Steam sends settings frames
|
||||
// un-trimmed) and must round-trip whole; anything longer clamps instead of overrunning.
|
||||
let mut lizard = vec![0u8; crate::quic::HID_REPORT_MAX + 8];
|
||||
lizard[..6].copy_from_slice(&[0x01, 0x87, 0x03, 0x09, 0x00, 0x00]);
|
||||
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: crate::quic::HID_RAW_FEATURE,
|
||||
data: lizard.clone(),
|
||||
});
|
||||
assert_eq!(out.hid_kind, crate::quic::HID_RAW_FEATURE);
|
||||
assert_eq!(out.raw_len as usize, crate::quic::HID_REPORT_MAX);
|
||||
assert_eq!(out.raw[..], lizard[..crate::quic::HID_REPORT_MAX]);
|
||||
}
|
||||
|
||||
/// `punktfunk_connection_send_hid_report`'s clamp, via its pure core: `pad` masked to the
|
||||
/// 16-pad wire space and the report bounded to `HID_REPORT_MAX` — byte-for-byte the Android
|
||||
/// JNI shim's rules, so both clients put identical `[0xCC][0x04]` bodies on the wire.
|
||||
#[test]
|
||||
fn send_hid_report_clamps_like_the_android_shim() {
|
||||
// A BLE state report (0x45-first, 46 bytes) passes through unclamped.
|
||||
let mut state = vec![0u8; 46];
|
||||
state[0] = 0x45;
|
||||
state[1] = 0xE5; // seq
|
||||
match hid_report_rich_input(3, &state) {
|
||||
crate::quic::RichInput::HidReport { pad, len, data } => {
|
||||
assert_eq!(pad, 3);
|
||||
assert_eq!(len, 46);
|
||||
assert_eq!(data[..46], state[..]);
|
||||
assert_eq!(data[46..], [0; crate::quic::HID_REPORT_MAX - 46]);
|
||||
}
|
||||
other => panic!("expected HidReport, got {other:?}"),
|
||||
}
|
||||
// Oversize input truncates to the wire body; a pad above the wire space wraps into it.
|
||||
let big = vec![0xAB; 100];
|
||||
match hid_report_rich_input(0x17, &big) {
|
||||
crate::quic::RichInput::HidReport { pad, len, data } => {
|
||||
assert_eq!(pad, 0x7);
|
||||
assert_eq!(len as usize, crate::quic::HID_REPORT_MAX);
|
||||
assert_eq!(data, [0xAB; crate::quic::HID_REPORT_MAX]);
|
||||
}
|
||||
other => panic!("expected HidReport, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The legacy audio format: Opus on `0xC9`, 48 kHz, 16-bit, stereo. What every session ran
|
||||
|
||||
@@ -265,7 +265,29 @@ pub use stats::Stats;
|
||||
/// only in the struct from here on — appended behind its `struct_size` guard, zero meaning
|
||||
/// unspecified/auto — so they stop being ABI events at all. Client-local; [`WIRE_VERSION`] is
|
||||
/// unchanged.
|
||||
pub const ABI_VERSION: u32 = 26;
|
||||
///
|
||||
/// **v27** closes the two C-ABI gaps of the as-is Steam Controller 2 passthrough
|
||||
/// (`PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2`), whose Rust internals both directions already carried:
|
||||
/// `punktfunk_connection_send_hid_report` sends one raw captured report up as
|
||||
/// `RichInput::HidReport` (`[0xCC][0x04]`, the clamp rules shared verbatim with the Android JNI
|
||||
/// shim), and `punktfunk_connection_next_hidout` now SURFACES `HidOutput::HidRaw` (`[0xCD][0x05]`
|
||||
/// — Steam's hidraw write to the host's virtual SC2) instead of skipping it as NoFrame: a new
|
||||
/// `PUNKTFUNK_HIDOUT_HID_RAW` kind with the report in a `hid_kind`/`raw_len`/`raw[64]` tail
|
||||
/// appended to `PunktfunkHidOutput`.
|
||||
///
|
||||
/// ⚠ WIDENED, not just added — the first deliberate widening this surface has made, and the
|
||||
/// v18/v24 rule ("growing one in place breaks every out-of-tree embedder at once") is why it is
|
||||
/// spelled out here rather than slipped in. `PunktfunkHidOutput` grows 19 → 85 bytes (the
|
||||
/// pre-v27 prefix layout is byte-identical; the tail is appended), so a binary built against a
|
||||
/// v26 header passes a 19-byte out-slot that a v27 core would overrun. The version equality check IS the
|
||||
/// guard: `punktfunk_abi_version()` mismatch has always meant "incompatible core", and every
|
||||
/// in-tree embedder (the Apple xcframework, whose header and dylib build together) recompiles
|
||||
/// against the regenerated header. A second struct + second pull symbol was considered and
|
||||
/// rejected: the hidout plane has ONE puller by contract, and forking its drain loop across two
|
||||
/// symbols so one of them could stay 19 bytes would push the fork into every embedder forever,
|
||||
/// for a struct only poll-written by the core into caller memory. No wire change — both datagram
|
||||
/// forms shipped with the passthrough itself — so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 27;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -570,7 +570,10 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
|
||||
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
|
||||
// report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
|
||||
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
|
||||
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live. `--triton`
|
||||
// drives the Steam Controller 2 backend (device_type 7); with no raw report mirrored
|
||||
// (raw_len = 0) every typed frame takes the synthesized-0x42 fallback, so a visible
|
||||
// stick sweep IS that fallback working.
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let deck = args.iter().any(|a| a == "--deck");
|
||||
// `--idle-after N` drives normally for N seconds, then STOPS sending state frames while still
|
||||
@@ -596,7 +599,8 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let extra_buttons: u32 = if edge || deck {
|
||||
let triton = args.iter().any(|a| a == "--triton");
|
||||
let extra_buttons: u32 = if edge || deck || triton {
|
||||
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
|
||||
} else {
|
||||
0
|
||||
@@ -790,6 +794,11 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
crate::inject::steam_deck_windows::SteamDeckWindowsManager::new(),
|
||||
"Steam Deck"
|
||||
);
|
||||
} else if triton {
|
||||
drive!(
|
||||
crate::inject::triton_windows::TritonWindowsManager::new(),
|
||||
"Steam Controller 2"
|
||||
);
|
||||
} else {
|
||||
drive!(
|
||||
crate::inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||
|
||||
@@ -96,8 +96,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
// New Steam Controller (2026, `28DE:1302`): passed through as-is on Linux — the Triton
|
||||
// UHID backend mirrors the client's raw reports under the real identity and Steam on
|
||||
// the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the
|
||||
// consumer). No Windows backend; folds to Xbox360 there.
|
||||
// consumer).
|
||||
GamepadPref::SteamController2 if linux => GamepadPref::SteamController2,
|
||||
// Windows backend: DEVTYPE_TRITON via the pf_gamepad shm channel (triton_windows.rs).
|
||||
// The Puck stays folded — its 7-interface 28DE:1304 topology has no Windows synthesis.
|
||||
GamepadPref::SteamController2 if windows => GamepadPref::SteamController2,
|
||||
GamepadPref::SteamController2Puck if linux => GamepadPref::SteamController2Puck,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
@@ -568,7 +571,10 @@ mod tests {
|
||||
pick_gamepad(Auto, Some("ibex"), true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360);
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2, None, false, true),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360);
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2Puck, None, true, false),
|
||||
|
||||
@@ -75,6 +75,14 @@ impl PadState {
|
||||
/// manager caps actual pad creation at its own MAX_PADS.
|
||||
const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// The Steam Controller 2 (Triton) backend for the running OS: the Linux UHID/usbip passthrough,
|
||||
/// or the Windows UMDF minidriver over the `pf_gamepad` shm channel (device-type 7). One alias so
|
||||
/// the SC2 sites below share a single spelling instead of a per-OS manager path at each.
|
||||
#[cfg(target_os = "linux")]
|
||||
type Sc2Manager = pf_inject::steam_controller2::Triton2Manager;
|
||||
#[cfg(target_os = "windows")]
|
||||
type Sc2Manager = pf_inject::triton_windows::TritonWindowsManager;
|
||||
|
||||
/// Per-pad virtual-gamepad router: each pad index is served by a backend of that pad's declared
|
||||
/// kind ([`InputKind::GamepadArrival`](punktfunk_core::input::InputKind::GamepadArrival)), so ONE
|
||||
/// session can MIX controller types — pad 0 a DualSense, pad 1 an Xbox pad. A pad the client never
|
||||
@@ -129,8 +137,8 @@ struct Pads {
|
||||
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
steamctrl2: Option<Sc2Manager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -187,7 +195,7 @@ impl Pads {
|
||||
switchpro: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
steamctrl2: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2_puck: None,
|
||||
@@ -344,10 +352,10 @@ impl Pads {
|
||||
.steamctrl
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
GamepadPref::SteamController2 => self
|
||||
.steamctrl2
|
||||
.get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new)
|
||||
.get_or_insert_with(Sc2Manager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2Puck => self
|
||||
@@ -488,7 +496,7 @@ impl Pads {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
GamepadPref::SteamController2 => {
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.apply_rich(rich)
|
||||
@@ -532,8 +540,12 @@ impl Pads {
|
||||
/// cadence so PC-generated trackpad pulses do not sit for up to 4 ms and then arrive at the
|
||||
/// client in bursts. Other backends keep the lower-frequency poll to avoid idle churn.
|
||||
fn feedback_poll_interval(&self) -> std::time::Duration {
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
let sc2_active = self.steamctrl2.is_some();
|
||||
#[cfg(target_os = "linux")]
|
||||
if self.steamctrl2.is_some() || self.steamctrl2_puck.is_some() {
|
||||
let sc2_active = sc2_active || self.steamctrl2_puck.is_some();
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
if sc2_active {
|
||||
return std::time::Duration::from_millis(1);
|
||||
}
|
||||
std::time::Duration::from_millis(4)
|
||||
@@ -602,13 +614,16 @@ impl Pads {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2_puck {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
// SC2 (Triton) exists on both OSes (see `Sc2Manager`), so its pump sits outside the
|
||||
// per-OS blocks — one call serves the Linux UHID/usbip leg and the Windows UMDF leg.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// All three HID Xbox identities. Rumble only — an Xbox pad has no rich-feedback plane
|
||||
@@ -665,9 +680,12 @@ impl Pads {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
// SC2 (Triton) exists on both OSes (see `Sc2Manager`), so its heartbeat sits outside
|
||||
// the per-OS blocks — same 8 ms gap the blocks use.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.heartbeat(std::time::Duration::from_millis(8));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
||||
@@ -169,6 +169,18 @@ UI. The Apple and Android apps claim nothing, so their chords keep working; Andr
|
||||
DualSense and Steam Controller 2 USB captures, which do claim the device. The rows below grey out
|
||||
while this is off.
|
||||
|
||||
**Steam Controller passthrough** (`sc2_capture`) — *default: on for Android, off for Apple*. Reads
|
||||
an already-paired Steam Controller 2 directly and passes it to the host **as itself**: the host
|
||||
presents a real `28DE:1302` that its own Steam drives, so the trackpads, gyro and haptics behave as
|
||||
they do locally instead of being flattened into a generic pad. Android captures over USB, the Puck
|
||||
dongle, or Bluetooth; Apple over Bluetooth only. Needs *Forward controllers* on, and a Linux or
|
||||
Windows host — elsewhere the pad falls back to its ordinary type.
|
||||
|
||||
It defaults **off on Apple** because switching it on prompts for Bluetooth permission, which is a
|
||||
question worth asking only from a controller the app can see you own. Android needs no such prompt
|
||||
for a pad already attached, so it defaults on and simply does nothing when no SC2 is present. The
|
||||
capture engages at the next stream, and a badge confirms it.
|
||||
|
||||
**Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*,
|
||||
which matches each physical controller. Pickers offer Xbox 360, Xbox One, DualSense and DualShock 4
|
||||
everywhere, plus Steam Deck on Linux, Android and the console home. The host builds each virtual
|
||||
|
||||
@@ -144,7 +144,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
|
||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux and Windows; the Puck dongle's multi-pad identity stays Linux-only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
|
||||
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
||||
| `PUNKTFUNK_DUALSENSE_USBIP` | `1` · `0` *(default off)* | **(Linux, experimental)** Present the virtual DualSense as a **real USB device** over `vhci_hcd`, carrying its own USB Audio Class sound card, instead of as a UHID device. This is what lets a libScePad-style title pair the pad with its own speaker: wine derives a Windows ContainerId by walking sysfs to a `usb_device` parent, which a UHID pad does not have, so on the default path the pad and its speaker both register as `GUID_NULL` and the game never opens the haptic stream. It also gives GE-Proton the real ALSA card its raw-`snd_pcm_open` haptic path scans for. With this on, the pad's audio is captured from its isochronous endpoint and **no PipeWire sinks are minted** — PipeWire builds the real ones from the card. Needs `vhci_hcd` loaded and the `punktfunk` group's write on its sysfs `attach` (both shipped by packaging); degrades to UHID otherwise. |
|
||||
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. On Windows the pad's audio device is a pre-provisioned virtual endpoint; on Linux it is a per-pad PipeWire sink minted with the DualSense identity games match on — see [Controller speaker and haptics](/docs/controller-audio). |
|
||||
|
||||
@@ -485,6 +485,15 @@ Unlike every other pad Punktfunk presents, the Steam Controller 2 has exactly on
|
||||
state reports ride a vendor collection, so the pad produces no evdev node for anything else to read.
|
||||
If Steam can't open its `hidraw` node, you don't get a degraded controller, you get no controller.
|
||||
|
||||
**On a Windows host, stop here — the rest of this section is Linux only.** Windows has no `udev`
|
||||
and no permission gate to open: the pad is a UMDF device the `pf_gamepad` driver package serves, so
|
||||
Steam reaches it as soon as the devnode enumerates. An empty controller list there means the driver
|
||||
package is missing or stale instead. Reinstall it and reconnect:
|
||||
|
||||
```powershell
|
||||
punktfunk-host.exe driver install --gamepad
|
||||
```
|
||||
|
||||
The node is root-only until a udev rule says otherwise, and distro `steam-devices` rule sets are
|
||||
per-product-id: a host whose copy predates the SC2 (it shipped in 2026) never grants it. Punktfunk
|
||||
ships the rule itself from 0.30.0 on. On an older host, add it by hand:
|
||||
|
||||
@@ -480,6 +480,12 @@ punktfunk_connection_send_input(c, &ev);
|
||||
- `punktfunk_connection_send_rich_input(c, &rich)` — DualSense touchpad contact / motion sample.
|
||||
- `punktfunk_connection_send_rich_input2(c, &richEx)` — the forward-compatible superset (Steam
|
||||
trackpads, signed coords, pressure); set `struct_size = sizeof(PunktfunkRichInputEx)`.
|
||||
- `punktfunk_connection_send_hid_report(c, pad, data, len)` *(v27+)* — one raw HID input report
|
||||
from a controller **you** captured, forwarded verbatim for the host's as-is virtual pad. Only
|
||||
meaningful for a pad that declared `PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2`; the host drops it
|
||||
otherwise. `data` is the report id-first exactly as the device produced it, `len` is clamped to
|
||||
`PUNKTFUNK_HID_REPORT_MAX`. Lossy by design — state reports are idempotent snapshots, so a lost
|
||||
datagram self-heals on the next one. The return leg is `PUNKTFUNK_HIDOUT_HID_RAW` (§9).
|
||||
|
||||
### Stylus / pen
|
||||
|
||||
@@ -551,9 +557,18 @@ Pull these on your feedback thread (or poll with `timeout_ms = 0`). Same
|
||||
Nothing sources non-zero trigger levels end to end yet: only the Windows HID Xbox pad has the
|
||||
channel at all (XInput's `XINPUT_VIBRATION` and evdev's `FF_RUMBLE` each have exactly two
|
||||
members), and it is reachable only through GameInput/WGI.
|
||||
- **DualSense HID output** — `punktfunk_connection_next_hidout(c, &out, timeout)`. `out.kind` selects
|
||||
lightbar RGB / player LEDs / adaptive-trigger effect / trackpad haptic. Replay on a real DualSense
|
||||
via the platform's controller API. Only a DualSense-backend session emits these.
|
||||
- **HID output** — `punktfunk_connection_next_hidout(c, &out, timeout)`. `out.kind` selects
|
||||
lightbar RGB / player LEDs / adaptive-trigger effect / trackpad haptic — replay on a real
|
||||
DualSense via the platform's controller API. Only a DualSense-backend session emits those four.
|
||||
*(v27+)* `PUNKTFUNK_HIDOUT_HID_RAW` is the fifth kind, emitted only by an as-is Steam Controller 2
|
||||
passthrough session: `out.raw[..out.raw_len]` is a report the host's hidraw consumer (Steam) wrote,
|
||||
to replay verbatim on the physical pad — as an OUTPUT report or a `SET_REPORT` per `out.hid_kind`
|
||||
(`PUNKTFUNK_HID_RAW_OUTPUT` / `PUNKTFUNK_HID_RAW_FEATURE`). It is the return leg of
|
||||
`punktfunk_connection_send_hid_report` (§8). A client with no such capture ignores it.
|
||||
⚠ **v27 widened `PunktfunkHidOutput` 19 → 85 bytes** to carry that report. The pre-v27 prefix is
|
||||
byte-identical, so no field moved — but a binary built against a v26 header passes a 19-byte
|
||||
out-slot the core would overrun. The startup `punktfunk_abi_version()` equality check below is
|
||||
what makes that safe; do not skip it.
|
||||
- **HDR metadata** — `punktfunk_connection_next_hdr_meta(c, &meta, timeout)`. ST.2086 mastering
|
||||
display + content light level, in HDR10 SEI fixed-point units — ready to hand to DXGI
|
||||
`DXGI_HDR_METADATA_HDR10`, Apple `CAEDRMetadata`, or Android `KEY_HDR_STATIC_INFO`. Only an HDR
|
||||
|
||||
@@ -194,7 +194,29 @@
|
||||
// only in the struct from here on — appended behind its `struct_size` guard, zero meaning
|
||||
// unspecified/auto — so they stop being ABI events at all. Client-local; [`WIRE_VERSION`] is
|
||||
// unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 26
|
||||
//
|
||||
// **v27** closes the two C-ABI gaps of the as-is Steam Controller 2 passthrough
|
||||
// (`PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2`), whose Rust internals both directions already carried:
|
||||
// `punktfunk_connection_send_hid_report` sends one raw captured report up as
|
||||
// `RichInput::HidReport` (`[0xCC][0x04]`, the clamp rules shared verbatim with the Android JNI
|
||||
// shim), and `punktfunk_connection_next_hidout` now SURFACES `HidOutput::HidRaw` (`[0xCD][0x05]`
|
||||
// — Steam's hidraw write to the host's virtual SC2) instead of skipping it as NoFrame: a new
|
||||
// `PUNKTFUNK_HIDOUT_HID_RAW` kind with the report in a `hid_kind`/`raw_len`/`raw[64]` tail
|
||||
// appended to `PunktfunkHidOutput`.
|
||||
//
|
||||
// ⚠ WIDENED, not just added — the first deliberate widening this surface has made, and the
|
||||
// v18/v24 rule ("growing one in place breaks every out-of-tree embedder at once") is why it is
|
||||
// spelled out here rather than slipped in. `PunktfunkHidOutput` grows 19 → 85 bytes (the
|
||||
// pre-v27 prefix layout is byte-identical; the tail is appended), so a binary built against a
|
||||
// v26 header passes a 19-byte out-slot that a v27 core would overrun. The version equality check IS the
|
||||
// guard: `punktfunk_abi_version()` mismatch has always meant "incompatible core", and every
|
||||
// in-tree embedder (the Apple xcframework, whose header and dylib build together) recompiles
|
||||
// against the regenerated header. A second struct + second pull symbol was considered and
|
||||
// rejected: the hidout plane has ONE puller by contract, and forking its drain loop across two
|
||||
// symbols so one of them could stay 19 bytes would push the fork into every embedder forever,
|
||||
// for a struct only poll-written by the core into caller memory. No wire change — both datagram
|
||||
// forms shipped with the passthrough itself — so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 27
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -225,6 +247,16 @@
|
||||
// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
#define PUNKTFUNK_HIDOUT_AUDIO_CTL 5
|
||||
|
||||
// `PunktfunkHidOutput::kind` — a raw report the host's hidraw consumer (Steam) wrote to an
|
||||
// as-is passthrough pad (`HidOutput::HidRaw`, the reverse of
|
||||
// [`punktfunk_connection_send_hid_report`]): `hid_kind` (`PUNKTFUNK_HID_RAW_OUTPUT` /
|
||||
// `PUNKTFUNK_HID_RAW_FEATURE`) + `raw`/`raw_len` valid. Replay it verbatim on the physical
|
||||
// device — an OUTPUT report on the interrupt-OUT endpoint / per-report GATT characteristic
|
||||
// (Triton rumble `0x80`, haptic pulse `0x81`, …), a FEATURE report as `SET_REPORT` / a GATT
|
||||
// feature write (lizard mode, IMU enable). Only an as-is passthrough session
|
||||
// (`PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2`) emits these; clients without such a capture drop them.
|
||||
#define PUNKTFUNK_HIDOUT_HID_RAW 6
|
||||
|
||||
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
||||
|
||||
@@ -240,6 +272,13 @@
|
||||
// `punktfunk_connection_send_rich_input2` (added with client capture).
|
||||
#define PUNKTFUNK_RICH_TOUCHPAD_EX 3
|
||||
|
||||
// `RichInput::HidReport` kind on the wire (`[0xCC][0x04][pad][len][data…]`) — one raw HID input
|
||||
// report from a client-captured controller, forwarded verbatim for the host's as-is virtual pad
|
||||
// (the Steam Controller 2 passthrough, `PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2`). A C client sends it
|
||||
// through [`punktfunk_connection_send_hid_report`], never by building the datagram itself; the
|
||||
// constant exists so client-side tests can pin the wire byte against this header.
|
||||
#define PUNKTFUNK_RICH_HID_REPORT 4
|
||||
|
||||
// [`PunktfunkPenSample::state`] bit: the pen hovers in range (implied by `TOUCHING`).
|
||||
#define PUNKTFUNK_PEN_IN_RANGE 1
|
||||
|
||||
@@ -2402,10 +2441,12 @@ typedef struct {
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// One DualSense HID-output feedback event a game wrote to the host's virtual pad
|
||||
// One HID-output feedback event a game wrote to the host's virtual pad
|
||||
// ([`punktfunk_connection_next_hidout`]). `kind` selects which fields are meaningful — replay it
|
||||
// on a real DualSense (lightbar color, player LEDs, or an adaptive-trigger effect via the
|
||||
// platform's `GCDualSenseAdaptiveTrigger`-style API).
|
||||
// on the real controller: DualSense feedback (lightbar color, player LEDs, an adaptive-trigger
|
||||
// effect via the platform's `GCDualSenseAdaptiveTrigger`-style API), or — on an as-is Steam
|
||||
// Controller 2 passthrough session — a raw report to forward verbatim
|
||||
// (`PUNKTFUNK_HIDOUT_HID_RAW`).
|
||||
typedef struct {
|
||||
// One of `PUNKTFUNK_HIDOUT_*`.
|
||||
uint8_t kind;
|
||||
@@ -2428,6 +2469,17 @@ typedef struct {
|
||||
// exported precisely so embedders can size their own buffers against it, and it declaring one
|
||||
// number while the struct it describes hardcoded another was the whole hazard.
|
||||
uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX];
|
||||
// HidRaw: `PUNKTFUNK_HID_RAW_OUTPUT` (an OUTPUT report — a hidraw `write()`) or
|
||||
// `PUNKTFUNK_HID_RAW_FEATURE` (a FEATURE report — `SET_REPORT`). Distinct from `kind`,
|
||||
// which says this event IS a raw report; this says which device channel replays it.
|
||||
uint8_t hid_kind;
|
||||
// HidRaw: number of valid bytes in `raw` (≤ `PUNKTFUNK_HID_REPORT_MAX`).
|
||||
uint8_t raw_len;
|
||||
// HidRaw: the full report, id byte first — exactly what the host's hidraw consumer wrote
|
||||
// (Steam writes feature frames whole, so trailing zero-padding is normal; OUTPUT frames
|
||||
// arrive host-trimmed to the declared report length on current hosts). Sized off
|
||||
// [`HID_REPORT_MAX`](crate::quic::HID_REPORT_MAX), the wire bound for the same bytes.
|
||||
uint8_t raw[PUNKTFUNK_HID_REPORT_MAX];
|
||||
} PunktfunkHidOutput;
|
||||
#endif
|
||||
|
||||
@@ -3714,11 +3766,12 @@ PunktfunkStatus punktfunk_connection_set_rumble_quirks(PunktfunkConnection *c,
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger)
|
||||
// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on
|
||||
// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend
|
||||
// emits these. Same threading rules as [`punktfunk_connection_next_rumble`] (one puller, may run
|
||||
// alongside the other planes).
|
||||
// Pull the next HID-output feedback event the host's virtual pad received from a game
|
||||
// (DualSense lightbar / player LEDs / adaptive trigger — or, on an as-is Steam Controller 2
|
||||
// passthrough session, a raw `PUNKTFUNK_HIDOUT_HID_RAW` report to replay verbatim), into
|
||||
// `*out`. [`PunktfunkStatus::NoFrame`] on timeout, [`PunktfunkStatus::Closed`] once the session
|
||||
// ended. Only the DualSense and SC2 host backends emit these. Same threading rules as
|
||||
// [`punktfunk_connection_next_rumble`] (one puller, may run alongside the other planes).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `out` is writable for one `PunktfunkHidOutput`.
|
||||
@@ -3904,6 +3957,25 @@ PunktfunkStatus punktfunk_connection_send_rich_input2(PunktfunkConnection *c,
|
||||
const PunktfunkRichInputEx *rich);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Send one raw HID input report from a client-captured controller — the as-is Steam Controller 2
|
||||
// passthrough's up direction (`[0xCC][0x04]` on the wire, [`RichInput::HidReport`](crate::quic::RichInput))
|
||||
// — as a QUIC datagram (non-blocking enqueue). `data[..len]` is the report exactly as the device
|
||||
// produced it on its interrupt endpoint / GATT notify, id byte first (`0x42`/`0x45`/`0x47` state,
|
||||
// `0x43` battery, …); `len` is clamped to `PUNKTFUNK_HID_REPORT_MAX` and `pad` masked into the
|
||||
// 16-pad wire space. Best-effort/lossy by design — state reports are idempotent snapshots at the
|
||||
// device's own rate, so a lost datagram self-heals on the next one. A no-op unless the pad
|
||||
// declared `PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2` and the host runs the as-is backend.
|
||||
// [`PunktfunkStatus::InvalidArg`] on an empty report.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `data` points to `len` readable bytes.
|
||||
PunktfunkStatus punktfunk_connection_send_hid_report(PunktfunkConnection *c,
|
||||
uint8_t pad,
|
||||
const uint8_t *data,
|
||||
uintptr_t len);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Send one stylus sample batch — `count` (`1..=PUNKTFUNK_PEN_BATCH_MAX`) state-full
|
||||
// [`PunktfunkPenSample`]s, oldest first (a capture callback's coalesced samples) — as one
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
;/*++
|
||||
; punktfunk virtual gamepads — UMDF2 HID minidriver INF.
|
||||
; One package, seven hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck, and three
|
||||
; Xbox pads (Wireless / One S / Elite Series 2) — which is why the package is called pf_gamepad and
|
||||
; not pf_dualsense (it never was one identity).
|
||||
; One package, eight hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck, Steam
|
||||
; Controller 2 (Triton), and three Xbox pads (Wireless / One S / Elite Series 2) — which is why the
|
||||
; package is called pf_gamepad and not pf_dualsense (it never was one identity).
|
||||
;
|
||||
; ⚠️ The HARDWARE IDS below deliberately keep their old names (`pf_dualsense`, `pf_dualshock4`,
|
||||
; `pf_dualsenseedge`, `pf_steamdeck`). They are the binding contract with every devnode the host
|
||||
@@ -36,11 +36,11 @@ pf_gamepad.dll=1
|
||||
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
|
||||
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
|
||||
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` /
|
||||
; `pf_steamdeck` / `pf_xboxwireless` / `pf_xboxones` / `pf_xboxelite` for the host's other virtual
|
||||
; pads — ONE driver binds all of them and serves the matching HID identity per the device_type byte
|
||||
; the host stamps into shared memory. TWO install sections, though: the PlayStation/Deck ids share
|
||||
; `pfGamepad`, and the three Xbox ids install `pfGamepadXbox`, which additionally attaches the
|
||||
; `xinputhid` bus filter (see the ⚠️ below the Deck line).
|
||||
; `pf_steamdeck` / `pf_triton` / `pf_xboxwireless` / `pf_xboxones` / `pf_xboxelite` for the host's
|
||||
; other virtual pads — ONE driver binds all of them and serves the matching HID identity per the
|
||||
; device_type byte the host stamps into shared memory. TWO install sections, though: the
|
||||
; PlayStation/Deck/Triton ids share `pfGamepad`, and the three Xbox ids install `pfGamepadXbox`,
|
||||
; which additionally attaches the `xinputhid` bus filter (see the ⚠️ below the Triton line).
|
||||
;
|
||||
; Each id carries its OWN description: Device Manager reads this string, and a single shared
|
||||
; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been
|
||||
@@ -50,6 +50,7 @@ pf_gamepad.dll=1
|
||||
%DeviceDescDS4%=pfGamepad, pf_dualshock4
|
||||
%DeviceDescEdge%=pfGamepad, pf_dualsenseedge
|
||||
%DeviceDescDeck%=pfGamepad, pf_steamdeck
|
||||
%DeviceDescTriton%=pfGamepad, pf_triton
|
||||
; ⚠️ The Xbox lines install their OWN section, `pfGamepadXbox`, and must keep doing so. Every other
|
||||
; identity shares `pfGamepad`; the Xbox ones additionally attach the `xinputhid` bus filter, and
|
||||
; putting that on a DualSense / DualShock 4 / Edge / Steam Deck would hand a PlayStation pad to
|
||||
@@ -187,6 +188,7 @@ DeviceDesc ="Punktfunk Virtual DualSense"
|
||||
DeviceDescDS4 ="Punktfunk Virtual DualShock 4"
|
||||
DeviceDescEdge ="Punktfunk Virtual DualSense Edge"
|
||||
DeviceDescDeck ="Punktfunk Virtual Steam Deck Controller"
|
||||
DeviceDescTriton ="Punktfunk Virtual Steam Controller"
|
||||
DeviceDescXbox ="Punktfunk Virtual Xbox Wireless Controller"
|
||||
; ⚠️ This one deliberately does NOT match the product string the driver serves for device_type 5.
|
||||
; A real Xbox One S pad reports "Xbox Wireless Controller" over Bluetooth, exactly like the Series
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
|
||||
// Every remaining `unsafe {}` (all WDF setup FFI) must carry a `// SAFETY:` proof.
|
||||
|
||||
use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
|
||||
use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering};
|
||||
|
||||
use pf_driver_proto::gamepad::PadShm;
|
||||
use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
|
||||
@@ -72,6 +72,13 @@ const DS_EDGE_PID: u16 = 0x0DF2;
|
||||
/// CLIENT streaming to a Windows host declares it, and `steam_deck_windows` builds the pad.
|
||||
const DECK_VID: u16 = 0x28DE;
|
||||
const DECK_PID: u16 = 0x1205;
|
||||
/// Steam Controller 2 ("Triton", 28DE:1302 wired), served when the host stamps device_type=7 —
|
||||
/// same Valve VID as the Deck.
|
||||
const TRITON_PID: u16 = 0x1302;
|
||||
/// bcdDevice of the real wired Triton (Phase-0 bench capture). Unlike the Deck we do NOT borrow
|
||||
/// `DS_VER` here: 0x0307 is the captured value, and the whole point of this identity is fidelity
|
||||
/// to the capture.
|
||||
const TRITON_VER: u16 = 0x0307;
|
||||
|
||||
// ---- Xbox identities (device_type = 4 Wireless / 5 One S / 6 Elite Series 2) ----
|
||||
//
|
||||
@@ -523,6 +530,9 @@ static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85,
|
||||
static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes
|
||||
// Serves device_type 4, 5 AND 6 — one descriptor, three identities (see the XBOX_RDESC header).
|
||||
static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xDF, 0x00]; // 223 bytes
|
||||
// bcdHID 0x0111 (bytes 2-3) is the real capture's value — the other identities declare
|
||||
// 0x0100; declared_len never reads it, this is deliberate identity fidelity.
|
||||
static TRITON_HID_DESC: [u8; 9] = [0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x74, 0x01]; // 372 bytes
|
||||
|
||||
// Each `wReportLength` above is a SECOND copy of a length that already exists as its descriptor's
|
||||
// array size, and the two are edited in different places. Getting them out of step does not fail
|
||||
@@ -538,6 +548,7 @@ const _: () = assert!(declared_len(&DS4_HID_DESC) == DS4_RDESC.len());
|
||||
const _: () = assert!(declared_len(&EDGE_HID_DESC) == DS_EDGE_RDESC.len());
|
||||
const _: () = assert!(declared_len(&DECK_HID_DESC) == DECK_RDESC.len());
|
||||
const _: () = assert!(declared_len(&XBOX_HID_DESC) == XBOX_RDESC.len());
|
||||
const _: () = assert!(declared_len(&TRITON_HID_DESC) == pf_driver_proto::triton::RDESC.len());
|
||||
|
||||
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
|
||||
// `devtype` selects the identity: PS family (same Sony VID/version), the N4-spike Deck, or one of
|
||||
@@ -555,6 +566,7 @@ fn hid_attrs(devtype: u8) -> [u8; 32] {
|
||||
4 => (XBOX_VID, XBOX_PID, XBOX_VER),
|
||||
5 => (XBOX_VID, XBOX_PID_ONE_S, XBOX_VER),
|
||||
6 => (XBOX_VID, XBOX_PID_ELITE2, XBOX_VER),
|
||||
7 => (DECK_VID, TRITON_PID, TRITON_VER),
|
||||
_ => (DS_VID, DS_PID, DS_VER),
|
||||
};
|
||||
let mut a = [0u8; 32];
|
||||
@@ -575,10 +587,20 @@ fn hid_attrs(devtype: u8) -> [u8; 32] {
|
||||
/// fails every single read and the pad looks dead.
|
||||
///
|
||||
/// Returns 64 for every pre-existing identity, so this is provably a no-op for them. All three
|
||||
/// Xbox identities share one descriptor, hence one report length.
|
||||
/// Xbox identities share one descriptor, hence one report length. The Triton identity (7) gets
|
||||
/// 54 — its LARGEST declared input report (0x42, id byte included), the length hidclass sizes a
|
||||
/// natural `HidD_GetInputReport` buffer from. The `evt_timer` serve path never consults this
|
||||
/// function for the Triton (it trims each served report to
|
||||
/// `pf_driver_proto::triton::input_len(id)` per id), so the ONLY consumer this arm affects is the
|
||||
/// `IOCTL_UMDF_HID_GET_INPUT_REPORT` arm, which serves
|
||||
/// `neutral_report(dt)[..input_report_len(dt)]` — with the 64 default it handed a 64-byte source
|
||||
/// to that natural 54-byte buffer, and `copy_to_output` refuses source > buffer
|
||||
/// (`STATUS_INVALID_BUFFER_SIZE`) rather than truncating, failing every such GET.
|
||||
fn input_report_len(devtype: u8) -> usize {
|
||||
match devtype {
|
||||
4..=6 => XBOX_INPUT_REPORT_LEN,
|
||||
// = `triton::input_len(0x42)`, the largest input the 372-byte descriptor declares.
|
||||
7 => 54,
|
||||
_ => 64,
|
||||
}
|
||||
}
|
||||
@@ -631,12 +653,22 @@ const XBOX_NEUTRAL_REPORT: [u8; 64] = {
|
||||
r[8] = 0x7F;
|
||||
r
|
||||
};
|
||||
// Neutral wired-Triton 0x42 state report: id + an all-zero payload — the same canned shape the
|
||||
// host's `neutral_triton_report` (triton_windows.rs) seeds the section with. `static`, not
|
||||
// `const` like its siblings, so the timer's completion path can serve
|
||||
// `&TRITON_NEUTRAL_REPORT[..54]` as a `'static` slice (a const would borrow a temporary).
|
||||
static TRITON_NEUTRAL_REPORT: [u8; 64] = {
|
||||
let mut r = [0u8; 64];
|
||||
r[0] = 0x42; // ID_CONTROLLER_STATE, the wired Triton's input state report
|
||||
r
|
||||
};
|
||||
fn neutral_report(devtype: u8) -> [u8; 64] {
|
||||
match devtype {
|
||||
1 => DS4_NEUTRAL_REPORT,
|
||||
3 => DECK_NEUTRAL_REPORT,
|
||||
// Wireless / One S / Elite Series 2 — one report shape, three identities.
|
||||
4..=6 => XBOX_NEUTRAL_REPORT,
|
||||
7 => TRITON_NEUTRAL_REPORT,
|
||||
_ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape
|
||||
}
|
||||
}
|
||||
@@ -645,6 +677,11 @@ static MANUAL_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut(
|
||||
/// The latest input report the host pushed (report `0x01`) via shared memory; the timer delivers it
|
||||
/// to pended game READ_REPORTs. Defaults to neutral until the host connects.
|
||||
static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_REPORT);
|
||||
/// Whether [`INPUT_REPORT`] holds a value no pended READ_REPORT has been completed with yet. Set
|
||||
/// only when the latch actually CHANGES, cleared only when a request is actually completed, so a
|
||||
/// tick that finds no read pended leaves the report undelivered rather than losing it. Consulted
|
||||
/// by the Triton identity alone — see the delivery gate in [`evt_timer`].
|
||||
static INPUT_DIRTY: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
// ---- the sealed pad channel: layouts + offsets from pf_driver_proto (drift = compile error) ----
|
||||
// UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack
|
||||
@@ -741,7 +778,14 @@ fn ring_len(view: &pf_umdf_util::section::MappedView) -> u32 {
|
||||
/// the slot bytes and the length that indexed them. The ring is what stops a rumble-STOP report
|
||||
/// from being coalesced away by a following LED/trigger report inside one host poll window (the
|
||||
/// confirmed stuck-rumble path).
|
||||
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||
///
|
||||
/// `feature` ORs [`pf_driver_proto::triton::OUT_FEATURE_BIT`] (bit 31) into the ring slot's len —
|
||||
/// the Triton identity's FEATURE/OUTPUT kind tag, stripped back out by the host's `drain_tagged`.
|
||||
/// Only the ring carries the tag; the legacy latest-slot has no length field to tag, which is fine
|
||||
/// because the one consumer that needs the split (triton_windows) always drains the ring. Every
|
||||
/// pre-Triton call site passes `false` (the Deck host expects untagged frames), so plain lengths
|
||||
/// are bit-identical to before the parameter existed.
|
||||
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8], feature: bool) {
|
||||
// Serialized: the whole publish is a read-modify-write (read the cursor, write the slot it
|
||||
// names, then advance it) and the framework dispatches output callbacks in PARALLEL, so two
|
||||
// can be inside this at once. Unsynchronized, both read the same `ring_head`, both write the
|
||||
@@ -773,7 +817,12 @@ fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||
let head = view.read_u32(OFF_RING_HEAD);
|
||||
let slot = OFF_OUT_RING + (head % len) as usize * OUT_SLOT_SIZE;
|
||||
let n = bytes.len().min(64);
|
||||
view.write_u32(slot, n as u32);
|
||||
let tag = if feature {
|
||||
pf_driver_proto::triton::OUT_FEATURE_BIT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
view.write_u32(slot, n as u32 | tag);
|
||||
view.write_bytes(slot + 4, &bytes[..n]);
|
||||
view.write_u32(OFF_OUT_RING_LEN, len);
|
||||
view.store_u32(OFF_RING_HEAD, head.wrapping_add(1), Ordering::Release);
|
||||
@@ -789,8 +838,9 @@ static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge,
|
||||
/// 3 = Steam Deck, 4 = Xbox Wireless, 5 = Xbox One S, 6 = Xbox Elite Series 2) — the
|
||||
/// neutral-report shape when the channel detaches, and the fallback identity while unattached.
|
||||
/// 3 = Steam Deck, 4 = Xbox Wireless, 5 = Xbox One S, 6 = Xbox Elite Series 2,
|
||||
/// 7 = Steam Controller 2 ("Triton")) — the neutral-report shape when the channel detaches,
|
||||
/// and the fallback identity while unattached.
|
||||
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
|
||||
/// The identity resolved from the devnode's PnP hardware ids at `EvtDeviceAdd` ([`devtype_from_hwids`]);
|
||||
/// `u32::MAX` = not resolved. See [`device_type`] for why this exists.
|
||||
@@ -814,6 +864,7 @@ fn devtype_from_hwids(ids: &str) -> Option<u8> {
|
||||
("pf_xboxwireless", 4u8),
|
||||
("pf_xboxones", 5),
|
||||
("pf_xboxelite", 6),
|
||||
("pf_triton", 7),
|
||||
("pf_steamdeck", 3),
|
||||
("pf_dualsenseedge", 2),
|
||||
("pf_dualshock4", 1),
|
||||
@@ -1103,6 +1154,7 @@ extern "C" fn evt_io_device_control(
|
||||
2 => &EDGE_HID_DESC,
|
||||
3 => &DECK_HID_DESC,
|
||||
4..=6 => &XBOX_HID_DESC,
|
||||
7 => &TRITON_HID_DESC,
|
||||
_ => &HID_DESC,
|
||||
}),
|
||||
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())),
|
||||
@@ -1113,6 +1165,9 @@ extern "C" fn evt_io_device_control(
|
||||
2 => &DS_EDGE_RDESC[..],
|
||||
3 => &DECK_RDESC[..],
|
||||
4..=6 => &XBOX_RDESC[..],
|
||||
// The Triton's captured 372-byte descriptor lives in the shared proto crate — the
|
||||
// host and the pf-inject layout tests read the SAME bytes (drift = test failure).
|
||||
7 => &pf_driver_proto::triton::RDESC[..],
|
||||
_ => &DUALSENSE_RDESC[..],
|
||||
}),
|
||||
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
|
||||
@@ -1122,10 +1177,25 @@ extern "C" fn evt_io_device_control(
|
||||
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
|
||||
// Sliced to the identity's declared report length for the same reason the timer's
|
||||
// completion is (see `input_report_len`): a source longer than the caller's buffer is
|
||||
// refused outright, not truncated.
|
||||
// refused outright, not truncated. Serves the CURRENT latch, not neutral — a reader that
|
||||
// opens mid-session (Steam restarting during a held input) queries the true state instead
|
||||
// of a fabricated all-zeros one. Does NOT touch `INPUT_DIRTY`: this is an on-demand
|
||||
// query, and consuming the dirty flag here would starve the interrupt pipeline of a
|
||||
// report it still owes. Before any host publish the latch is the neutral default anyway.
|
||||
IOCTL_UMDF_HID_GET_INPUT_REPORT => {
|
||||
let dt = device_type();
|
||||
request.copy_to_output(&neutral_report(dt)[..input_report_len(dt)])
|
||||
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
|
||||
let served: &[u8] = if dt == pf_driver_proto::gamepad::DEVTYPE_TRITON {
|
||||
// Same per-id trim as the timer's completion: Triton input reports are
|
||||
// variable-length and id-first; an undeclared latched id falls back to neutral.
|
||||
match pf_driver_proto::triton::input_len(report[0]) {
|
||||
Some(len) => &report[..len],
|
||||
None => &TRITON_NEUTRAL_REPORT[..54],
|
||||
}
|
||||
} else {
|
||||
&report[..input_report_len(dt)]
|
||||
};
|
||||
request.copy_to_output(served)
|
||||
}
|
||||
IOCTL_HID_GET_STRING => on_get_string(&request),
|
||||
// The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process
|
||||
@@ -1164,10 +1234,13 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
|
||||
|
||||
// Publish the game's 0x02 output report to the sealed DATA section for the host (rumble /
|
||||
// lightbar / player-LEDs / adaptive triggers): legacy slot + seq, plus the v2.1 ring.
|
||||
// Triton OUTPUT reports (0x80.. haptics) flow through here too, untagged = OUTPUT kind; the
|
||||
// largest declared ones (0x87/0x88/0x89, 1 id + 63 payload = 64) exactly fit the 64-byte
|
||||
// ring slot, so nothing is ever truncated.
|
||||
if !bytes.is_empty()
|
||||
&& let Some(view) = CHANNEL.data()
|
||||
{
|
||||
publish_output(view, &bytes);
|
||||
publish_output(view, &bytes, false);
|
||||
}
|
||||
|
||||
request.set_information(inlen as u64);
|
||||
@@ -1180,36 +1253,80 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
|
||||
/// fire-and-forget) — acking them is all they need.
|
||||
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
|
||||
|
||||
// SET_FEATURE: ack (the PS identities' contract), latch the payload for the Deck's GET_FEATURE
|
||||
// answer, and — the Deck feedback path — publish Steam's rumble/haptic commands to the host.
|
||||
// Per the UMDF marshalling convention the report data is the input buffer.
|
||||
/// Triton identity: the last SET_FEATURE frame, WHOLE (id-first, exactly as marshalled) plus its
|
||||
/// true length — `pf_driver_proto::triton::feature_reply` wants the frame as SET, and the host's
|
||||
/// drain replays the same bytes verbatim. Separate from [`LAST_SET_FEATURE`] because that latch
|
||||
/// strips a leading `0x00` (the Deck's unnumbered-report marshalling), which would mangle a
|
||||
/// numbered Triton frame. Per-pad like every static here: `ProcessSharingDisabled` gives each pad
|
||||
/// its own WUDFHost (see [`INPUT_REPORT`]).
|
||||
static TRITON_LAST_SET: std::sync::Mutex<([u8; 64], usize)> = std::sync::Mutex::new(([0; 64], 0));
|
||||
|
||||
/// Whether a latched Triton SET_FEATURE frame is the host's channel-proof command — the SAME
|
||||
/// two-byte [`pf_driver_proto::gamepad::DECK_PROOF_CMD`] the Deck identity answers, riding the
|
||||
/// same SET→GET feature contract. The `[0x00, cmd, …]` shape is the Deck/UNNUMBERED-report
|
||||
/// marshalling of `channel_proof::ask_feature`; a numbered collection like this one never sees
|
||||
/// it — hidclass rejects a feature buffer whose byte 0 is not a declared nonzero report id — so
|
||||
/// the host's numbered leg frames the proof id-first, `[0x01, cmd, …]`. The driver accepts the
|
||||
/// bare, `0x00`- and `0x01`-prefixed shapes alike to cover every sender rather than pinning one
|
||||
/// marshalling (mirroring `triton::feature_reply`'s tolerance).
|
||||
fn triton_proof_requested(frame: &[u8]) -> bool {
|
||||
let body = match frame {
|
||||
[0x00 | 0x01, rest @ ..] => rest,
|
||||
d => d,
|
||||
};
|
||||
body.starts_with(&pf_driver_proto::gamepad::DECK_PROOF_CMD)
|
||||
}
|
||||
|
||||
// SET_FEATURE: ack (the PS identities' contract), latch the payload for the Deck's/Triton's
|
||||
// GET_FEATURE answer, and — the Deck + Triton feedback paths — publish Steam's commands to the
|
||||
// host. Per the UMDF marshalling convention the report data is the input buffer.
|
||||
fn on_set_feature(request: &Request) -> NTSTATUS {
|
||||
if let Ok((bytes, _)) = request.input_bytes(64) {
|
||||
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
|
||||
// command-first view. (PS set-features carry their own report id first — harmless.)
|
||||
let src: &[u8] = if bytes.first() == Some(&0x00) && bytes.len() > 1 {
|
||||
&bytes[1..]
|
||||
if device_type() == pf_driver_proto::gamepad::DEVTYPE_TRITON {
|
||||
// Latch the WHOLE id-first frame (see TRITON_LAST_SET), then republish it to the
|
||||
// host FEATURE-tagged — Steam's SET_REPORT features (lizard-off / IMU-enable /
|
||||
// settings) must reach the physical pad, and the tag is how the host's
|
||||
// `drain_tagged` tells them from interrupt OUTPUT reports.
|
||||
let n = bytes.len().min(64);
|
||||
if let Ok(mut g) = TRITON_LAST_SET.lock() {
|
||||
g.0.fill(0);
|
||||
g.0[..n].copy_from_slice(&bytes[..n]);
|
||||
g.1 = n;
|
||||
}
|
||||
if triton_proof_requested(&bytes[..n]) {
|
||||
// The channel-proof exchange is host↔driver plumbing; the client must never
|
||||
// see it — latched for the GET answer, NOT republished.
|
||||
} else if let Some(view) = CHANNEL.data() {
|
||||
publish_output(view, &bytes[..n], true);
|
||||
}
|
||||
} else {
|
||||
&bytes
|
||||
};
|
||||
if let Ok(mut g) = LAST_SET_FEATURE.lock() {
|
||||
g.fill(0);
|
||||
let n = src.len().min(64);
|
||||
g[..n].copy_from_slice(&src[..n]);
|
||||
}
|
||||
// Deck feedback: Steam drives rumble (0xEB) and trackpad haptic pulses (0x8F) via
|
||||
// SET_FEATURE on the unnumbered report — the PS identities get theirs as OUTPUT
|
||||
// reports instead. Publish them to the host through the same output slot + seq the
|
||||
// output path uses, re-prefixed with the report-id 0 byte so the host's
|
||||
// `parse_steam_output` sees the exact wire shape the Linux UHID path delivers.
|
||||
if device_type() == 3
|
||||
&& matches!(src.first(), Some(&0xEB) | Some(&0x8F))
|
||||
&& let Some(view) = CHANNEL.data()
|
||||
{
|
||||
let mut out = [0u8; 64];
|
||||
let n = src.len().min(63);
|
||||
out[1..1 + n].copy_from_slice(&src[..n]);
|
||||
publish_output(view, &out);
|
||||
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
|
||||
// command-first view. (PS set-features carry their own report id first — harmless.)
|
||||
let src: &[u8] = if bytes.first() == Some(&0x00) && bytes.len() > 1 {
|
||||
&bytes[1..]
|
||||
} else {
|
||||
&bytes
|
||||
};
|
||||
if let Ok(mut g) = LAST_SET_FEATURE.lock() {
|
||||
g.fill(0);
|
||||
let n = src.len().min(64);
|
||||
g[..n].copy_from_slice(&src[..n]);
|
||||
}
|
||||
// Deck feedback: Steam drives rumble (0xEB) and trackpad haptic pulses (0x8F) via
|
||||
// SET_FEATURE on the unnumbered report — the PS identities get theirs as OUTPUT
|
||||
// reports instead. Publish them to the host through the same output slot + seq the
|
||||
// output path uses, re-prefixed with the report-id 0 byte so the host's
|
||||
// `parse_steam_output` sees the exact wire shape the Linux UHID path delivers.
|
||||
// Untagged: the Deck host expects plain frames.
|
||||
if device_type() == 3
|
||||
&& matches!(src.first(), Some(&0xEB) | Some(&0x8F))
|
||||
&& let Some(view) = CHANNEL.data()
|
||||
{
|
||||
let mut out = [0u8; 64];
|
||||
let n = src.len().min(63);
|
||||
out[1..1 + n].copy_from_slice(&src[..n]);
|
||||
publish_output(view, &out, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
dbglog!("[pf-gamepad] SET_FEATURE (acked, latched for GET)");
|
||||
@@ -1238,11 +1355,7 @@ fn deck_feature_reply() -> [u8; 64] {
|
||||
// it as command→response, so the proof rides that same contract instead of a new report id (no
|
||||
// descriptor change). Two command bytes, so a Steam command we haven't catalogued cannot collide.
|
||||
if last.starts_with(&pf_driver_proto::gamepad::DECK_PROOF_CMD) {
|
||||
let proof =
|
||||
pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id());
|
||||
r[..2].copy_from_slice(&pf_driver_proto::gamepad::DECK_PROOF_CMD);
|
||||
r[2..18].copy_from_slice(&proof.to_bytes());
|
||||
return r;
|
||||
return proof_reply();
|
||||
}
|
||||
match last[0] {
|
||||
0x83 => {
|
||||
@@ -1291,10 +1404,65 @@ fn deck_feature_reply() -> [u8; 64] {
|
||||
r
|
||||
}
|
||||
|
||||
/// The channel-proof GET_FEATURE answer both command-driven identities (Deck + Triton) serve:
|
||||
/// `[DECK_PROOF_CMD, ChannelProof(16 bytes), zeros…]`.
|
||||
///
|
||||
/// ⚠️ Security-load-bearing input: the proof carries `CHANNEL.index()` — the pad index this driver
|
||||
/// read from its OWN devnode Location at `EvtDeviceAdd` — and NOT [`pad_index`], which reads the
|
||||
/// section. The host cross-checks the proof's index against the pad it is about to deliver
|
||||
/// PRECISELY because it does not yet trust any section; a section-derived index would let a forged
|
||||
/// delivery vouch for itself. Do not "simplify" the two into one.
|
||||
fn proof_reply() -> [u8; 64] {
|
||||
let proof = pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id());
|
||||
let mut r = [0u8; 64];
|
||||
r[..2].copy_from_slice(&pf_driver_proto::gamepad::DECK_PROOF_CMD);
|
||||
r[2..18].copy_from_slice(&proof.to_bytes());
|
||||
r
|
||||
}
|
||||
|
||||
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob
|
||||
// (the Deck identity instead answers the latched Steam command — its one feature report is
|
||||
// unnumbered).
|
||||
// unnumbered; the Triton identity answers its latched command through the shared
|
||||
// `triton::feature_reply` machine).
|
||||
fn on_get_feature(request: &Request) -> NTSTATUS {
|
||||
if device_type() == pf_driver_proto::gamepad::DEVTYPE_TRITON {
|
||||
let (last, len) = TRITON_LAST_SET.lock().map(|g| *g).unwrap_or(([0u8; 64], 0));
|
||||
let is_proof = triton_proof_requested(&last[..len]);
|
||||
let mut reply = if is_proof {
|
||||
proof_reply()
|
||||
} else {
|
||||
// The query dance (0x83 attributes / 0xAE string / 0xF2 firmware) + echo fallback —
|
||||
// and feature report 2 rides the SAME machine (mirror semantics, no special table).
|
||||
let mut serial = [0u8; 13];
|
||||
pf_driver_proto::triton::serial(pad_index(), &mut serial);
|
||||
pf_driver_proto::triton::feature_reply(
|
||||
&last[..len],
|
||||
// `triton::serial` writes 13 ASCII bytes, so the conversion is infallible.
|
||||
core::str::from_utf8(&serial).unwrap_or(""),
|
||||
pf_driver_proto::triton::unit_id(pad_index()),
|
||||
)
|
||||
};
|
||||
// A real pad echoes the feature id it was asked for, and this collection declares TWO
|
||||
// (0x01/0x02) while `triton::feature_reply` stamps every answer 0x01 — so a GET of
|
||||
// declared report 0x02 came back stamped 0x01. Read the requested id the way the PS arm
|
||||
// below does (input-buffer byte 0) and stamp it over the reply when it names a different
|
||||
// nonzero report. The proof reply is exempt: it is host↔driver plumbing framed
|
||||
// `[DECK_PROOF_CMD, proof…]`, and the host matches that command prefix — an id stamp
|
||||
// would destroy it.
|
||||
if !is_proof
|
||||
&& let Ok((req, _)) = request.input_bytes(1)
|
||||
&& let Some(&id) = req.first()
|
||||
&& id != 0
|
||||
&& id != reply[0]
|
||||
{
|
||||
reply[0] = id;
|
||||
}
|
||||
// The UMDF request's output-buffer length is authoritative: Steam asks with wLength 64
|
||||
// AND 65 (Phase-0 bench log — the 63-byte declared reports marshal as either), so serve
|
||||
// min(buffer_len, 64) zero-padded bytes and complete with that count.
|
||||
let n = request.output_buffer_len().min(64);
|
||||
return request.copy_to_output(&reply[..n]);
|
||||
}
|
||||
if device_type() == 3 {
|
||||
return request.copy_to_output(&deck_feature_reply());
|
||||
}
|
||||
@@ -1373,7 +1541,7 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
||||
let s: String = match string_id {
|
||||
0 | 0x000e => match devtype {
|
||||
1 => "Sony Computer Entertainment".into(),
|
||||
3 => "Valve Software".into(),
|
||||
3 | 7 => "Valve Software".into(),
|
||||
4..=6 => "Microsoft".into(),
|
||||
_ => "Sony Interactive Entertainment".into(),
|
||||
},
|
||||
@@ -1395,6 +1563,14 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
||||
4 => format!("F4B0FC2A6C{:02X}", 0x10u8.wrapping_add(pad_index())),
|
||||
5 => format!("F4B0FC2A6C{:02X}", 0x30u8.wrapping_add(pad_index())),
|
||||
6 => format!("F4B0FC2A6C{:02X}", 0x50u8.wrapping_add(pad_index())),
|
||||
// The Triton serial comes from the shared proto helper (13 ASCII bytes,
|
||||
// "FVPF1302<idx>D03") so it always agrees with the query dance's 0xAE / firmware
|
||||
// replies in `triton::feature_reply` — Steam reads both.
|
||||
7 => {
|
||||
let mut s = [0u8; 13];
|
||||
pf_driver_proto::triton::serial(pad_index(), &mut s);
|
||||
String::from_utf8_lossy(&s).into_owned()
|
||||
}
|
||||
_ => format!("35533AD6E7{:02X}", 0x74u8.wrapping_add(pad_index())),
|
||||
},
|
||||
_ => match devtype {
|
||||
@@ -1410,6 +1586,7 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
||||
// is ours, not the pad's.)
|
||||
4 | 5 => "Xbox Wireless Controller".into(),
|
||||
6 => "Xbox Elite Wireless Controller Series 2".into(),
|
||||
7 => "Steam Controller".into(),
|
||||
_ => "DualSense Wireless Controller".into(),
|
||||
},
|
||||
};
|
||||
@@ -1422,8 +1599,8 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
||||
}
|
||||
|
||||
/// The device-type selector: 0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck,
|
||||
/// 4 = Xbox Wireless Controller, 5 = Xbox One S, 6 = Xbox Elite Wireless Controller Series 2.
|
||||
/// Read fresh on each enumeration query — cheap.
|
||||
/// 4 = Xbox Wireless Controller, 5 = Xbox One S, 6 = Xbox Elite Wireless Controller Series 2,
|
||||
/// 7 = Steam Controller 2 ("Triton"). Read fresh on each enumeration query — cheap.
|
||||
///
|
||||
/// ⚠️ **The sealed section cannot answer the enumeration queries.** hidclass asks for
|
||||
/// `GET_DEVICE_DESCRIPTOR` / `GET_REPORT_DESCRIPTOR` / `GET_DEVICE_ATTRIBUTES` while it STARTS the
|
||||
@@ -1475,11 +1652,28 @@ extern "C" fn evt_timer(timer: WDFTIMER) {
|
||||
let mut buf = [0u8; 64];
|
||||
// A torn read is dropped rather than served: `read_input_report` returns false only
|
||||
// when it caught the host mid-publish, and the previous whole report stays in place.
|
||||
// ⚠️ ORDER IS LOAD-BEARING: the report-id check runs AFTER `read_input_report` filled
|
||||
// `buf` (short-circuit). Hoisted before the read it would test a zeroed buffer, fail
|
||||
// for every identity, and every pad would serve neutral forever — indistinguishable
|
||||
// from a Steam-claim failure at the bench.
|
||||
if read_input_report(view, &mut buf)
|
||||
&& buf[0] == 0x01
|
||||
&& (if device_type() == pf_driver_proto::gamepad::DEVTYPE_TRITON {
|
||||
// Triton reports are id-first (0x42 state, 0x43 battery, …). Undeclared ids
|
||||
// (0x47 BLE timestamp) are dropped — hidclass refuses ids the descriptor
|
||||
// doesn't declare.
|
||||
pf_driver_proto::triton::input_len(buf[0]).is_some()
|
||||
} else {
|
||||
buf[0] == 0x01
|
||||
})
|
||||
&& let Ok(mut g) = INPUT_REPORT.lock()
|
||||
{
|
||||
*g = buf;
|
||||
// Compare before storing: the dirty flag must mean "new state", not "the host
|
||||
// published again". An unchanged republish carries nothing a game can act on, and
|
||||
// treating it as fresh would put the Triton path back on the host's publish rate.
|
||||
if *g != buf {
|
||||
*g = buf;
|
||||
INPUT_DIRTY.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
if housekeeping {
|
||||
// Keep the fallback identity fresh: `device_type()`'s last resort (channel
|
||||
@@ -1499,11 +1693,38 @@ extern "C" fn evt_timer(timer: WDFTIMER) {
|
||||
// report instead of a frozen last state (matters for the persistent out-of-band devnode,
|
||||
// which outlives host sessions).
|
||||
if let Ok(mut g) = INPUT_REPORT.lock() {
|
||||
*g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) as u8);
|
||||
let neutral = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) as u8);
|
||||
if *g != neutral {
|
||||
*g = neutral;
|
||||
INPUT_DIRTY.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Triton delivery is EVENT-DRIVEN; every other identity keeps the every-tick cadence.
|
||||
//
|
||||
// The others carry typed frames a client streams at ~250 Hz, which a 2 ms tick undersamples
|
||||
// nothing of. Triton carries the physical pad's own BLE reports instead, and iOS floors the
|
||||
// connection interval at ~15 ms (~66 Hz) — so re-serving the latch every tick handed Steam
|
||||
// ~7 identical reports and then one holding a full 15 ms of trackpad travel. A delta that
|
||||
// large across a 2 ms inter-report gap reads as a flick ~7x faster than the finger made it,
|
||||
// which is where the runaway trackpad momentum came from (bench 2, 2026-08-23). Real hardware
|
||||
// NAKs the interrupt IN when it has nothing new; leaving the READ_REPORT pended is this
|
||||
// stack's equivalent, so Steam sees one report per real report, spaced as the pad spaced them.
|
||||
//
|
||||
// No idle re-serve floor: the pad streams state reports continuously — ~66 Hz over BLE with
|
||||
// the seq byte advancing even at rest (600-frame capture, 2026-06-08) — so total silence
|
||||
// means link loss, not idleness, and neutral-on-detach rides this same dirty path (the
|
||||
// detach branch above latches neutral, which IS a change). A time-based re-serve would only
|
||||
// ever fire across a stream stall, where re-serving the latch resets the reader's
|
||||
// arrival-time reference and the recovery report's delta lands on a compressed window —
|
||||
// the momentum bug's exact shape.
|
||||
let dt = device_type();
|
||||
if dt == pf_driver_proto::gamepad::DEVTYPE_TRITON && !INPUT_DIRTY.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete the next pended READ_REPORT with the current input report (safe queue/request API).
|
||||
// SAFETY: the timer's parent object is the manual queue (set in EvtDeviceAdd); the framework
|
||||
// guarantees a live handle here.
|
||||
@@ -1515,7 +1736,24 @@ extern "C" fn evt_timer(timer: WDFTIMER) {
|
||||
// Serve exactly what this identity's descriptor declares — `copy_to_output` REFUSES a
|
||||
// source longer than hidclass's buffer instead of truncating, so a 64-byte hand-over for
|
||||
// the Xbox pad's 16-byte report would fail every read and the pad would look dead.
|
||||
let st = request.copy_to_output(&report[..input_report_len(device_type())]);
|
||||
// A retrieved request is ALWAYS completed on every path below: `Request` has no Drop
|
||||
// impl and `complete(self, …)` consumes it, so a dequeued-but-uncompleted READ_REPORT
|
||||
// would leak.
|
||||
// Cleared HERE and not at the gate above: a tick that finds nothing pended must leave the
|
||||
// report undelivered, not drop it on the floor.
|
||||
INPUT_DIRTY.store(false, Ordering::Relaxed);
|
||||
let served: &[u8] = if dt == pf_driver_proto::gamepad::DEVTYPE_TRITON {
|
||||
// Per-id trim: Triton input reports are variable-length and id-first. hidclass's
|
||||
// READ buffer is 54 bytes (0x42, the largest declared input), so every served
|
||||
// length fits; a latched id the descriptor doesn't declare falls back to neutral.
|
||||
match pf_driver_proto::triton::input_len(report[0]) {
|
||||
Some(len) => &report[..len],
|
||||
None => &TRITON_NEUTRAL_REPORT[..54],
|
||||
}
|
||||
} else {
|
||||
&report[..input_report_len(dt)]
|
||||
};
|
||||
let st = request.copy_to_output(served);
|
||||
request.complete(st);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user