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