feat(gamepad): SC2 Puck-dongle passthrough with the native 28DE:1304 topology

Community-contributed round 5 of the Steam Controller 2 passthrough,
reviewed + verified. A Puck-captured pad now presents the dongle's real
seven-interface identity (CDC pair, four controller HID slots, management
HID) instead of relabelling its reports as a wired 1302 — Steam's Puck
feature dances (wireless_transport / esb/bond / 0xB4 slot status) get
capture-shaped answers, and the wired identity's canned replies are
corrected to the real captures (attribute count, string-attr framing,
0xF2 firmware info, bcdDevice nibble encoding).

- new wire pref 10 = SteamController2Puck (Hello/Welcome byte; older
  peers degrade to Auto), selected by the Android capture link when the
  transport is a dongle, or by VID/PID in the degraded InputDevice path
- TRITON_RDESC is now the captured numbered descriptor (mouse/keyboard
  lizard collections + per-id vendor reports); unnumbered framing made
  hidraw mangle feature report 2 and Steam eventually closed the device
- interrupt-IN now queues sparse reports (battery/RSSI/wireless edges)
  instead of keeping latest-only, so a 250 Hz state packet can no longer
  erase them before the USB/IP poll observes them; EP0 SET_REPORT is
  split by wValue report type (OUTPUT parsed for rumble vs FEATURE)
- vendored usbip-sim: config attributes/max-power, IAD prefix + BOS
  descriptor support, correct BCD minor.patch encoding (Deck's 0x0300/
  0x0200 values are nibble-zero, so its bytes are unchanged), and
  full-speed interrupt pacing in ms (was 8 kHz from the HS formula)
- Triton feedback is serviced at 1 kHz while an SC2 backend exists so
  Steam's trackpad haptic writes reach the client unbatched

Verified: clippy -D warnings + 319 host tests green on Linux, core wire
tests green, Android kit/app compile + unit tests green. On-glass Puck
retest owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:47:52 +02:00
parent b50b698078
commit 01266ff18d
15 changed files with 971 additions and 170 deletions
@@ -495,6 +495,7 @@ private fun prefLabel(pref: Int): String = when (pref) {
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
Gamepad.PREF_STEAMCONTROLLER2 -> "Steam Controller 2"
Gamepad.PREF_STEAMCONTROLLER2_PUCK -> "Steam Controller 2 Puck"
else -> "Automatic"
}
@@ -65,6 +65,7 @@ object Gamepad {
const val PREF_DUALSENSEEDGE = 7
const val PREF_SWITCHPRO = 8
const val PREF_STEAMCONTROLLER2 = 9
const val PREF_STEAMCONTROLLER2_PUCK = 10
// USB vendor ids of the controllers we can identify by VID/PID.
private const val VID_SONY = 0x054C
@@ -92,12 +93,11 @@ object Gamepad {
private val PID_STEAMDECK = setOf(0x1205)
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
// Steam Controller 2: wired (0x1302), BLE (0x1303), Puck dongles (0x1304/0x1305). Normally
// the Sc2 capture link CLAIMS the USB device (detaching it from the input stack), so this
// entry only fires in the degraded path — capture off / permission denied — where the pad
// surfaces as a plain InputDevice. Declaring SC2 there lets the host build the matching
// virtual pad from the typed plane instead of falling back to Xbox 360.
private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303, 0x1304, 0x1305)
// Steam Controller 2: wired (0x1302), BLE (0x1303), and Puck dongles (0x1304/0x1305).
// Sc2Capture normally claims these directly; the plain InputDevice path is only a degraded
// fallback. Keep Puck distinct so even that path requests the native multi-interface identity.
private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303)
private val PID_STEAMCONTROLLER2_PUCK = setOf(0x1304, 0x1305)
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
@@ -125,6 +125,8 @@ object Gamepad {
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2_PUCK ->
PREF_STEAMCONTROLLER2_PUCK
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2 -> PREF_STEAMCONTROLLER2
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
else -> PREF_XBOX360
@@ -47,6 +47,10 @@ class Sc2Capture(
private var pad: GamepadRouter.ExternalPad? = null
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
/** 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)
private var pendingWirelessLen = 0
// Typed-mirror diff state (wire units).
private val state = Sc2Device.State()
@@ -141,10 +145,20 @@ class Sc2Capture(
// saying "no radio link" — and must NOT tear the slot down (SDL's wired path likewise
// marks the controller connected unconditionally and reconnects on any state report).
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
if (dongleLink && (report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) {
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
releaseSlot()
releaseUiKeys()
if (dongleLink) {
when (report[1].toInt() and 0xFF) {
Sc2Device.WIRELESS_CONNECT -> {
pendingWireless[0] = report[0]
pendingWireless[1] = report[1]
pendingWirelessLen = 2
}
Sc2Device.WIRELESS_DISCONNECT -> {
pendingWirelessLen = 0
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
releaseSlot()
releaseUiKeys()
}
}
}
return
}
@@ -157,9 +171,21 @@ class Sc2Capture(
mirrorUi()
return
}
val p = pad ?: router.openExternal(Gamepad.PREF_STEAMCONTROLLER2)?.also {
val pref = if (dongleLink) {
Gamepad.PREF_STEAMCONTROLLER2_PUCK
} else {
Gamepad.PREF_STEAMCONTROLLER2
}
val p = pad ?: router.openExternal(pref)?.also {
pad = it
Log.i(TAG, "SC2 captured → wire pad ${it.index} (as-is passthrough)")
Log.i(
TAG,
"SC2 captured → wire pad ${it.index} (${if (dongleLink) "Puck" else "direct"} passthrough)",
)
if (pendingWirelessLen > 0) {
forwardRaw(pendingWireless, pendingWirelessLen)
pendingWirelessLen = 0
}
} ?: return // all 16 wire indices taken — drop until one frees
forwardRaw(report, len)
mirrorTyped(p)
@@ -259,6 +285,7 @@ class Sc2Capture(
pad = null
wireButtons = 0
lastAxis.fill(Int.MIN_VALUE)
pendingWirelessLen = 0
}
private companion object {