diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller2.rs b/crates/punktfunk-host/src/inject/linux/steam_controller2.rs index 30612b8f..3b45ed10 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller2.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller2.rs @@ -19,8 +19,9 @@ //! fallback (hidraw exists, Steam won't list it) for hosts without `vhci_hcd`/root. use super::triton_proto::{ - parse_triton_rumble, serialize_triton_state, strip_report_prefix, TritonState, TRITON_RDESC, - TRITON_STATE_LEN, TRITON_VENDOR, TRITON_WIRED_PRODUCT, + parse_triton_rumble, serialize_triton_state, strip_report_prefix, triton_feature_reply, + triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR, + TRITON_WIRED_PRODUCT, }; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; @@ -55,6 +56,12 @@ pub struct TritonPad { seq: u8, /// Raw reports Steam wrote since the last service pass, kind-tagged for the 0xCD plane. pending_raw: Vec<(u8, Vec)>, + /// The last feature SET_REPORT (id-first) — the query half of the Valve GET dance. + last_set: Vec, + serial: String, + unit_id: u32, + /// Last GET query command logged, so the tester-facing log line fires once per distinct cmd. + last_get_logged: u8, } impl TritonPad { @@ -71,6 +78,10 @@ impl TritonPad { fd, seq: 0, pending_raw: Vec::new(), + last_set: Vec::new(), + serial: triton_serial(index), + unit_id: triton_unit_id(index), + last_get_logged: 0, }; pad.send_create2(index).context("UHID_CREATE2 Triton pad")?; Ok(pad) @@ -148,23 +159,31 @@ impl TritonPad { // uhid_set_report: id u32, rnum u8, rtype u8, size u16, data — data at ev[12..]. let size = u16::from_ne_bytes([ev[10], ev[11]]) as usize; let end = (12 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(UHID_EVENT_SIZE); - let rep = strip_report_prefix(&ev[12..end]); - if let Some(r) = parse_triton_rumble(rep) { + let rep = strip_report_prefix(&ev[12..end]).to_vec(); + if let Some(r) = parse_triton_rumble(&rep) { rumble = Some(r); // some stacks send haptics on the feature path } - self.queue_raw(HID_RAW_FEATURE, rep); + // Remember the command — it selects the NEXT GET_REPORT's answer (the Valve + // query dance) — and forward it raw to the physical pad. + self.queue_raw(HID_RAW_FEATURE, &rep); + self.last_set = rep; let _ = self.reply_set_report(id); } UHID_GET_REPORT => { - // Steam's attribute/serial reads can't reach the physical pad synchronously; - // answer with a plausible Triton-shaped string-attribute reply (the same - // canned-reply approach the virtual Deck ships). Logged for on-glass tuning. + // The answer half of the Valve query dance: echo the LAST SET's command with + // a plausible payload (attributes / serial). Answering with the wrong command + // type makes Steam drop the pad — confirmed on-glass 2026-07-15; the dance + // can't round-trip to the physical pad synchronously. let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); - tracing::debug!( - rnum = ev[8], - "virtual SC2: GET_REPORT — canned serial reply" - ); - let _ = self.reply_get_report(id, &triton_serial_reply("PUNKTFUNK02")); + let reply = triton_feature_reply(&self.last_set, &self.serial, self.unit_id); + if reply[1] != self.last_get_logged { + self.last_get_logged = reply[1]; + tracing::info!( + cmd = format!("{:#04x}", reply[1]), + "virtual SC2: answering feature GET" + ); + } + let _ = self.reply_get_report(id, &reply); } _ => {} // Start/Stop/Open/Close — ignore } @@ -205,23 +224,6 @@ impl TritonPad { } } -/// The Valve feature GET reply shape (`[report-id 1][ID_GET_STRING_ATTRIBUTE][len][unit-serial] -/// [ascii…]`), Triton-flavored: feature reports ride report id 1 on this device (SDL sends -/// `buffer[0] = 1`), unlike the Deck's id-0 path. -fn triton_serial_reply(serial: &str) -> [u8; 64] { - const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE; - const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01; - let mut buf = [0u8; 64]; - let bytes = serial.as_bytes(); - let len = bytes.len().clamp(1, 21); - buf[0] = 0x01; // feature report id - buf[1] = ID_GET_STRING_ATTRIBUTE; - buf[2] = len as u8; - buf[3] = ATTRIB_STR_UNIT_SERIAL; - buf[4..4 + len].copy_from_slice(&bytes[..len]); - buf -} - impl Drop for TritonPad { fn drop(&mut self) { let mut ev = [0u8; UHID_EVENT_SIZE]; diff --git a/crates/punktfunk-host/src/inject/linux/triton_usbip.rs b/crates/punktfunk-host/src/inject/linux/triton_usbip.rs index 1a4db3c1..8935ad5e 100644 --- a/crates/punktfunk-host/src/inject/linux/triton_usbip.rs +++ b/crates/punktfunk-host/src/inject/linux/triton_usbip.rs @@ -22,7 +22,8 @@ use super::steam_usbip::{attach_device, boxed, UsbipAttachment}; use super::triton_proto::{ - parse_triton_rumble, serialize_triton_state, TritonState, TRITON_RDESC, TRITON_STATE_LEN, + parse_triton_rumble, serialize_triton_state, triton_feature_reply, triton_serial, + triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, }; use anyhow::Result; use std::any::Any; @@ -44,14 +45,6 @@ pub(crate) struct TritonUsbFeedback { pub raw: Vec<(u8, Vec)>, } -/// The wired pad's serial, FVPF-prefixed: [`super::steam_controller`]'s physical-Steam-controller -/// conflict gate recognizes `FVPF…` (`HID_UNIQ`) as one of punktfunk's own virtual pads, so a -/// concurrent session never mistakes this device for real hardware (the vhci sysfs path is the -/// second belt). Shaped like the real `FXA…` serials (13 chars). -fn triton_serial(index: u8) -> String { - format!("FVPF1302{index:02}D03") -} - /// The 9-byte HID class descriptor: bcdHID **1.11**, country 0, one report descriptor — the /// captured wired values ([`super::steam_usbip`]'s shared helper bakes the Deck's 1.10/33). fn triton_hid_desc() -> Vec { @@ -77,6 +70,11 @@ struct TritonHandler { report: Arc>, feedback: Arc>, serial: String, + unit_id: u32, + /// The last feature SET_REPORT (id-first) — the query half of the Valve GET dance. + last_set: Vec, + /// Last GET query command logged (once per distinct cmd, for the tester-facing journal). + last_get_logged: u8, } impl TritonHandler { @@ -112,20 +110,25 @@ impl UsbInterfaceHandler for TritonHandler { Ok(match (setup.request_type, setup.request) { // GET report descriptor (standard, interface recipient). (0x81, 0x06) if (setup.value >> 8) == 0x22 => TRITON_RDESC.to_vec(), - // HID GET_REPORT (feature): the query/answer dance can't reach the physical pad - // synchronously — answer a plausible Triton-shaped serial blob (id-1 framing, - // the same canned-reply approach the virtual Deck validated on-glass). Logged - // for tuning against Steam's real expectations. + // HID GET_REPORT (feature): the answer half of the Valve query dance — echo the + // LAST SET's command with a plausible payload (attributes / serial). Answering + // with the wrong command type makes Steam drop the pad (confirmed on-glass + // 2026-07-15); the dance can't round-trip to the physical pad synchronously. (0xA1, 0x01) => { - tracing::debug!( - value = format!("{:#06x}", setup.value), - "virtual SC2 usbip: GET_REPORT — canned serial reply" - ); - triton_serial_reply(&self.serial).to_vec() + let reply = triton_feature_reply(&self.last_set, &self.serial, self.unit_id); + if reply[1] != self.last_get_logged { + self.last_get_logged = reply[1]; + tracing::info!( + cmd = format!("{:#04x}", reply[1]), + "virtual SC2 usbip: answering feature GET" + ); + } + reply.to_vec() } - // HID SET_REPORT (feature): forward raw for replay on the physical pad. EP0 OUT - // data may or may not carry the report-id byte depending on the writer's stack - // (the id also rides wValue's low byte) — normalize to id-first for the client. + // HID SET_REPORT (feature): remember the command (it selects the next GET's + // answer) and forward raw for replay on the physical pad. EP0 OUT data may or + // may not carry the report-id byte depending on the writer's stack (the id also + // rides wValue's low byte) — normalize to id-first for the client. (0x21, 0x09) => { let id = (setup.value & 0xFF) as u8; let framed = if req.first() == Some(&id) && id != 0 { @@ -136,6 +139,7 @@ impl UsbInterfaceHandler for TritonHandler { v.extend_from_slice(req); v }; + self.last_set = framed.clone(); self.queue_raw(HID_RAW_FEATURE, framed); vec![] } @@ -167,22 +171,6 @@ impl UsbInterfaceHandler for TritonHandler { } } -/// The Valve feature GET reply (`[report-id 1][ID_GET_STRING_ATTRIBUTE][len][unit-serial] -/// [ascii…]`), zero-padded to the 64-byte feature size — kept in sync with the UHID leg's reply. -fn triton_serial_reply(serial: &str) -> [u8; 64] { - const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE; - const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01; - let mut buf = [0u8; 64]; - let bytes = serial.as_bytes(); - let len = bytes.len().clamp(1, 21); - buf[0] = 0x01; - buf[1] = ID_GET_STRING_ATTRIBUTE; - buf[2] = len as u8; - buf[3] = ATTRIB_STR_UNIT_SERIAL; - buf[4..4 + len].copy_from_slice(&bytes[..len]); - buf -} - /// Assemble the simulated wired Steam Controller 2 (see the module docs for the capture it /// matches). The handler shares `report` + `feedback` with the owning [`TritonUsbip`]. fn build_triton_device( @@ -219,6 +207,9 @@ fn build_triton_device( report: report.clone(), feedback: feedback.clone(), serial: triton_serial(index), + unit_id: triton_unit_id(index), + last_set: Vec::new(), + last_get_logged: 0, }), ) } @@ -340,6 +331,9 @@ mod tests { report, feedback: feedback.clone(), serial: triton_serial(0), + unit_id: triton_unit_id(0), + last_set: Vec::new(), + last_get_logged: 0, }; let iface_dummy = UsbInterface { interface_class: 3, diff --git a/crates/punktfunk-host/src/inject/proto/triton_proto.rs b/crates/punktfunk-host/src/inject/proto/triton_proto.rs index a680975b..76902bc5 100644 --- a/crates/punktfunk-host/src/inject/proto/triton_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/triton_proto.rs @@ -248,6 +248,88 @@ 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 +} + +/// The virtual pad's serial, FVPF-prefixed: the physical-Steam-controller conflict gate +/// recognizes `FVPF…` (`HID_UNIQ`) as one of punktfunk's own virtual pads, so a concurrent +/// session never mistakes this device for real hardware. Shaped like the real `FXA…` serials +/// (13 chars). Shared by the UHID and usbip legs (identity + `0xAE` replies must agree). +pub fn triton_serial(index: u8) -> String { + format!("FVPF1302{index:02}D03") +} + +/// 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). Mirrors the Deck's validated +/// [`feature_reply`](super::steam_proto::feature_reply), with two Triton deltas: 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. +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 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, + }; + let cmd = body.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE); + + let mut r = [0u8; 64]; + r[0] = 0x01; // feature report id + match cmd { + ID_GET_ATTRIBUTES_VALUES => { + // [0x01, 0x83, 0x2d, then 9× (attr-id, value u32-LE)]. + r[1] = ID_GET_ATTRIBUTES_VALUES; + r[2] = 0x2d; + let attrs: [(u8, u32); 9] = [ + (0x01, TRITON_WIRED_PRODUCT), // product id + (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), + ]; + 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 => { + // [0x01, 0xAE, len, attr, ascii…]; the serial is string-attr 0x01. + 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; + r[4..4 + len].copy_from_slice(&b[..len]); + } + _ => { + // 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]); + } + } + r +} + #[cfg(test)] mod tests { use super::*; @@ -303,4 +385,31 @@ mod tests { assert_eq!(strip_report_prefix(&[0x01, 0x87]), &[0x01, 0x87]); // feature id 1 kept assert_eq!(strip_report_prefix(&[0x00]), &[0x00]); // lone zero: nothing to strip to } + + /// 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. + #[test] + 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. + let r = triton_feature_reply(&[0x01, 0x83, 0x00], &serial, uid); + assert_eq!(&r[..3], &[0x01, 0x83, 0x2d]); + 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. + 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); + 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]); + // 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]); + } }