Triton upstream #425

Merged
enricobuehler merged 13 commits from bluelightspecial/punktfunk:triton-upstream into main 2026-08-29 17:40:07 +00:00
40 changed files with 3670 additions and 295 deletions
@@ -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
}
}
+8
View File
@@ -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 (T5 m / T1 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 (2540 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 2540 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 2540 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
// clienthost 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 2540 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 0x450x42 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 0x450x42 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)
}
}
+336
View File
@@ -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);
}
}
+17 -109
View File
@@ -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>;
+16 -6
View File
@@ -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")]
+219 -43
View File
@@ -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
+23 -1
View File
@@ -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**
+11 -2
View File
@@ -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(),
+8 -2
View File
@@ -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),
+31 -13
View File
@@ -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")]
{
+12
View File
@@ -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
+1 -1
View File
@@ -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:
+18 -3
View File
@@ -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
+81 -9
View File
@@ -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
+287 -49
View File
@@ -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);
}
}