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
@@ -40,35 +40,48 @@ pub const ID_TRITON_CONTROLLER_STATE_TIMESTAMP: u8 = 0x47;
/// universal 0xCA plane); every output report is forwarded raw regardless.
pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80;
/// Fixed report size: 64-byte feature reports, input reports at most 64 (state is 46/54).
/// Physical `0x42` state report size: one report-id byte plus 53 payload bytes.
pub const TRITON_REPORT_LEN: usize = 64;
pub const TRITON_STATE_LEN: usize = 54;
/// The `TritonMTUNoQuat_t` state payload (46 bytes with the leading report id).
pub const TRITON_STATE_LEN: usize = 46;
/// Minimal vendor-defined HID report descriptor, mirroring [`super::steam_proto::STEAMDECK_RDESC`]
/// with an added OUTPUT item: the Triton receives haptics as output reports (`SDL_hid_write`),
/// not feature-only like the Deck, so hidapi consumers expect a writable interrupt-OUT-style
/// report to exist. All items unnumbered 64-byte — the raw bytes we mirror carry the Valve
/// report-type byte first, exactly like the physical device's stream.
/// The physical Triton HID report descriptor, captured byte-for-byte from both wired `28DE:1302`
/// and Puck `28DE:1304` controller interfaces. Its numbered reports are part of the protocol:
/// 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] = &[
0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00)
0x09, 0x01, // Usage (0x01)
0xA1, 0x01, // Collection (Application)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8 bits)
0x95, 0x40, // Report Count (64)
0x09, 0x01, // Usage (0x01)
0x81, 0x02, // Input (Data,Var,Abs) — the state/battery/wireless report stream
0x09, 0x01, // Usage (0x01)
0x95, 0x40, // Report Count (64)
0x91, 0x02, // Output (Data,Var,Abs) — haptic commands (0x80 rumble, 0x81 pulse, …)
0x09, 0x01, // Usage (0x01)
0x95, 0x40, // Report Count (64)
0xB1, 0x02, // Feature (Data,Var,Abs) — settings/attributes (report id 1 on the wire)
0xC0, // End Collection
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,
];
/// Triton button bits in the state report's `buttons` u32 — transcribed verbatim from SDL's
@@ -277,9 +290,9 @@ pub fn triton_serial(index: u8) -> String {
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;
// Normalize to the command + its payload, tolerating a missing report-id byte.
let body = match last_set {
[0x01, rest @ ..] => rest,
d => d,
@@ -287,22 +300,18 @@ pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8;
let cmd = body.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
let mut r = [0u8; 64];
r[0] = 0x01; // feature report id
r[0] = 0x01;
match cmd {
ID_GET_ATTRIBUTES_VALUES => {
// [0x01, 0x83, 0x2d, then 9× (attr-id, value u32-LE)].
// Captured controller response: 25-byte payload containing five id/u32 attributes.
r[1] = ID_GET_ATTRIBUTES_VALUES;
r[2] = 0x2d;
let attrs: [(u8, u32); 9] = [
(0x01, TRITON_WIRED_PRODUCT), // product id
r[2] = 0x19;
let attrs = [
(0x01, TRITON_WIRED_PRODUCT),
(0x02, 0),
(0x0a, unit_id), // per-instance unit identity
(0x04, unit_id ^ 0x5555_5555),
(0x09, 0x2e),
(0x0b, 0x0fa0), // connection interval 4000 µs — the pad's ~4 ms cadence
(0x0d, 0),
(0x0c, 0),
(0x0e, 0),
(0x0A, unit_id),
(0x04, unit_id ^ 0x0296_DAF9),
(0x09, 0x49),
];
let mut o = 3;
for (id, val) in attrs {
@@ -312,17 +321,42 @@ pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8;
}
}
ID_GET_STRING_ATTRIBUTE => {
// [0x01, 0xAE, len, attr, ascii…]; the serial is string-attr 0x01.
// 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().clamp(1, 20);
r[1] = ID_GET_STRING_ATTRIBUTE;
r[2] = len as u8;
r[3] = attr;
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]);
}
}
}
_ => {
// Settings read-back (e.g. 0x87): echo the host's last command + data, id-first.
let n = body.len().min(63);
r[1..1 + n].copy_from_slice(&body[..n]);
}
@@ -392,22 +426,22 @@ mod tests {
fn feature_reply_echoes_the_queried_command() {
let serial = triton_serial(0);
let uid = triton_unit_id(0);
// 0x83 attributes: id-first frame, 9 blocks, product id = 0x1302 in the first block.
// 0x83 attributes: id-first frame, 5 captured blocks, product id = 0x1302 in the first.
let r = triton_feature_reply(&[0x01, 0x83, 0x00], &serial, uid);
assert_eq!(&r[..3], &[0x01, 0x83, 0x2d]);
assert_eq!(&r[..3], &[0x01, 0x83, 0x19]);
assert_eq!(r[3], 0x01); // ATTRIB product-id tag
assert_eq!(
u32::from_le_bytes([r[4], r[5], r[6], r[7]]),
TRITON_WIRED_PRODUCT
);
// 0xAE serial: echoes the requested string attribute + the FVPF serial.
// 0xAE serial: the captured fixed 20-byte payload — attribute id + padded string.
let r = triton_feature_reply(&[0x01, 0xAE, 0x01, 0x01], &serial, uid);
assert_eq!(&r[..3], &[0x01, 0xAE, serial.len() as u8]);
assert_eq!(&r[..3], &[0x01, 0xAE, 0x14]);
assert_eq!(r[3], 0x01);
assert_eq!(&r[4..4 + serial.len()], serial.as_bytes());
// A stack that stripped the id byte still resolves the command.
let r = triton_feature_reply(&[0x83u8, 0x00], &serial, uid);
assert_eq!(&r[..3], &[0x01, 0x83, 0x2d]);
assert_eq!(&r[..3], &[0x01, 0x83, 0x19]);
// Anything else (settings write) reads back as an echo.
let r = triton_feature_reply(&[0x01, 0x87, 3, 9, 0, 0], &serial, uid);
assert_eq!(&r[..6], &[0x01, 0x87, 3, 9, 0, 0]);