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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user