diff --git a/crates/punktfunk-host/src/inject/linux/steam_gadget.rs b/crates/punktfunk-host/src/inject/linux/steam_gadget.rs index c9eb2b7..981231a 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_gadget.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_gadget.rs @@ -247,6 +247,7 @@ impl SteamDeckGadget { } let serial = format!("PFDECK{index:04}"); + let unit_id = 0x5046_0000u32 | index as u32; // "PF" + index — a synthetic per-instance device id let report = Arc::new(Mutex::new(neutral_report())); let feedback = Arc::new(Mutex::new(Default::default())); let running = Arc::new(AtomicBool::new(true)); @@ -262,7 +263,9 @@ impl SteamDeckGadget { let feedback = feedback.clone(); std::thread::Builder::new() .name("pf-deck-gadget-ctrl".into()) - .spawn(move || control_loop(fd, running, ctrl_ep, configured, feedback, serial)) + .spawn(move || { + control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id) + }) .context("spawn gadget control thread")? }; // Stream thread: push the current report on the controller interrupt-IN endpoint. @@ -336,6 +339,7 @@ fn control_loop( configured: Arc, feedback: Arc>, serial: String, + unit_id: u32, ) { let raw = fd.0; let cfg = build_config(); @@ -369,6 +373,7 @@ fn control_loop( &ctrl, &cfg, &serial, + unit_id, &ctrl_ep, &configured, &mut last_set, @@ -394,6 +399,7 @@ fn handle_control( ctrl: &Setup, cfg: &[u8], serial: &str, + unit_id: u32, ctrl_ep: &std::sync::atomic::AtomicI32, configured: &AtomicBool, last_set: &mut Vec, @@ -450,7 +456,7 @@ fn handle_control( match ctrl.b_request { 0x01 => { // GET_REPORT — serve the Deck feature reply for the last requested command. - let resp = feature_reply(last_set, serial); + let resp = feature_reply(last_set, serial, unit_id); let n = resp.len().min(wl); ep0_write(raw, &resp[..n]); } @@ -482,19 +488,54 @@ fn handle_control( } } -/// Build the HID feature GET_REPORT reply for whatever the host last asked via SET_REPORT. -fn feature_reply(last_set: &[u8], serial: &str) -> Vec { - // The Deck serial path: SET [0xAE,…] then GET → [0xAE,len,0x01,ascii]. For unknown commands echo - // the command byte with a zeroed body (enough to keep hid-steam's probe from erroring). +/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command. Steam's +/// `GetControllerInfo` reads the `0x83` attributes + the `0xAE` serial; **serving the real `0x83` +/// blob is what stops Steam re-probing** (the gamepad-evdev churn). The contract (`0x83` 9-attribute +/// layout + the `0xAE` string format) was captured from a physical Steam Deck via hidraw. `unit_id` +/// stamps a per-instance value into the device-id attributes (`0x0a`/`0x04`) so a gadget never +/// collides with a real Deck or another gadget. +fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; 64] { let cmd = last_set.first().copied().unwrap_or(0xAE); - let mut r = vec![0u8; 64]; - r[0] = cmd; - if cmd == 0xAE { - let b = serial.as_bytes(); - let len = b.len().clamp(1, 21); - r[1] = len as u8; - r[2] = 0x01; - r[3..3 + len].copy_from_slice(&b[..len]); + let mut r = [0u8; 64]; + match cmd { + 0x83 => { + // GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)]. + r[0] = 0x83; + r[1] = 0x2d; + let attrs: [(u8, u32); 9] = [ + (0x01, 0x1205), // product id + (0x02, 0), + (0x0a, unit_id), // unit serial number (per-instance) + (0x04, unit_id ^ 0x5555_5555), + (0x09, 0x2e), + (0x0b, 0x0fa0), + (0x0d, 0), + (0x0c, 0), + (0x0e, 0), + ]; + let mut o = 2; + for (id, val) in attrs { + r[o] = id; + r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes()); + o += 5; + } + } + 0xAE => { + // GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr + // 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id. + let attr = last_set.get(2).copied().unwrap_or(0x01); + let b = serial.as_bytes(); + let len = b.len().clamp(1, 20); + r[0] = 0xAE; + r[1] = len as u8; + r[2] = attr; + r[3..3 + len].copy_from_slice(&b[..len]); + } + _ => { + // Settings read-back (e.g. 0x87): echo the host's last command + data. + let n = last_set.len().min(64); + r[..n].copy_from_slice(&last_set[..n]); + } } r } diff --git a/design/steam-controller-deck-support.md b/design/steam-controller-deck-support.md index 26aed96..061f5a2 100644 --- a/design/steam-controller-deck-support.md +++ b/design/steam-controller-deck-support.md @@ -807,3 +807,18 @@ With the A button held in the streamed report on a `pressa` build, on the Deck: Conclusion: input delivery + format are proven; the only gap is the gamepad-evdev transience, which is a **feature-report-completeness** problem — exactly what the host backend fixes (serve the full Deck feature/attribute contract so Steam stops fighting it). That's the next step, not more PoC patching. + +### Feature contract hardened — the churn is fixed (2026-06-29) + +The gamepad-evdev churn was Steam re-probing because the gadget served zeros for the HID feature +reports Steam's `GetControllerInfo` reads. The real contract was captured from a physical Deck +(`packaging/linux/steam-deck-gadget/get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`) and implemented in +`steam_gadget.rs::feature_reply`: the **`0x83` GET_ATTRIBUTES_VALUES** blob (`[83,2d, 9×(id,u32-LE)]` +— product id `0x1205`, a per-instance unit serial, capability attrs) plus the **`0xAE`** string +attributes (serial / board serial) and a settings echo. Result on the Deck: **1 connect / 0 +disconnect / 1 gamepad evdev** (was constant churn), Steam *activates* the gadget cleanly (no +`GetControllerInfo failed`, no zombie) and emits its **X-Box 360 pad 1**. usbmon on the gadget's bus +confirms our state reports (pressed button at byte 8) are delivered on the interrupt-IN and consumed +by hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode, the input chain is proven end-to-end. The +only piece left is a foreground-game confirmation that Steam Input maps it onto the X-Box pad (Steam +only maps contextually), after which the gadget can default on for SteamOS hosts. diff --git a/packaging/linux/steam-deck-gadget/README.md b/packaging/linux/steam-deck-gadget/README.md index d93773c..0fa2e61 100644 --- a/packaging/linux/steam-deck-gadget/README.md +++ b/packaging/linux/steam-deck-gadget/README.md @@ -71,9 +71,27 @@ real module): it enumerates the 3-interface Deck, hid-steam binds it + reads our musl, `ioctl(fd, RUN)` with no third arg passes a garbage `value`, and raw_gadget's `RUN`/`CONFIGURE`/ `EP0_STALL` reject a non-zero `value` with `EINVAL` — so the no-arg ioctls must pass an explicit `0`. +## Feature contract (hardened — churn fixed) + +Steam's `GetControllerInfo` reads HID **feature reports**; serving the real ones is what stops Steam +re-probing (which was destroying + recreating the gamepad evdev — the "churn"). The contract was +captured from a physical Deck (`get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`; usbmon truncates to 32B): + +- **`0x83` GET_ATTRIBUTES_VALUES** — `[83, 2d, 9× (attr-id, u32-LE)]`: product id `0x1205`, a unit + serial (`0x0a`/`0x04` — we stamp a per-instance value so a gadget never collides with a real Deck), + and capability attrs (`0x09=0x2e`, `0x0b=0x0fa0`, `0x02/0x0c/0x0d/0x0e=0`). **This blob is the fix.** +- **`0xAE` GET_STRING_ATTRIBUTE** — `[ae, len, attr, ascii]`: serial (attr 1), board serial (attr 0). +- Other commands (e.g. `0x87` settings) read back the last write (echo). + +Result on the Deck (`feature_reply` in `steam_gadget.rs`): **1 connect / 0 disconnect / 1 gamepad +evdev** (was constant churn), and Steam *activates* the controller cleanly (no `GetControllerInfo +failed`, no zombie) and emits its **X-Box 360 pad**. usbmon on the gadget's bus confirms our state +reports (with the pressed button at byte 8) are delivered on the interrupt-IN and consumed by +hid-steam — so the input transport is proven end-to-end. + ## Remaining -- **Harden the feature contract** so Steam stops re-probing + the gamepad evdev stops churning (serve - Steam's full `GetControllerInfo` attribute set, captured from a physical Deck) — then a clean live - input-flow check + defaulting the gadget on for SteamOS hosts. +- **Glass confirmation of the XInput mapping** — Steam Input only maps the gadget's raw input onto its + X-Box pad while a game using Steam Input is focused; confirm a button reaches a real game, then + default the gadget on for SteamOS hosts (it's strictly better than the non-promoted UHID path). - A `punktfunk-host` build for SteamOS to exercise the integrated path end-to-end with a live client. diff --git a/packaging/linux/steam-deck-gadget/get_deck_attrs.c b/packaging/linux/steam-deck-gadget/get_deck_attrs.c new file mode 100644 index 0000000..b967d57 --- /dev/null +++ b/packaging/linux/steam-deck-gadget/get_deck_attrs.c @@ -0,0 +1,39 @@ +// Query a physical Steam Deck's feature reports (SET command then GET response) over hidraw to get +// the FULL blobs (usbmon truncates to 32 bytes). Steam feature reports are unnumbered 64-byte; the +// hidraw buffer prefixes a report-id byte (0). +#include +#include +#include +#include +#include +#include +static void dump(const char *name, unsigned char *b, int n) { + printf("%s rc=%d:", name, n); + for (int i = 0; i < (n > 0 ? n : 0); i++) printf(" %02x", b[i]); + printf("\n"); +} +int main(int argc, char **argv) { + if (argc < 2) { printf("usage: %s /dev/hidrawN\n", argv[0]); return 1; } + int fd = open(argv[1], O_RDWR); + if (fd < 0) { perror("open"); return 1; } + // SET [reportid=0, cmd, len, attr, ...] then GET the response. + unsigned char queries[][4] = { + {0x83, 0x00, 0x00, 0x00}, // GET_ATTRIBUTES_VALUES + {0xae, 0x16, 0x01, 0x00}, // GET_STRING_ATTRIBUTE, serial (attr 1) + {0xae, 0x16, 0x00, 0x00}, // GET_STRING_ATTRIBUTE, attr 0 + {0xae, 0x16, 0x02, 0x00}, // attr 2 (board serial?) + }; + for (int q = 0; q < 4; q++) { + unsigned char set[65] = {0}; + set[0] = 0; // report id 0 + memcpy(set + 1, queries[q], 4); + int sr = ioctl(fd, HIDIOCSFEATURE(65), set); + usleep(3000); + unsigned char get[65] = {0}; + get[0] = 0; + int gr = ioctl(fd, HIDIOCGFEATURE(65), get); + printf("=== query cmd=%02x attr=%02x (SET rc=%d) ===\n", queries[q][0], queries[q][2], sr); + dump(" GET", get, gr); + } + return 0; +}