From 9ff7d41bfe5296e20eeb6af2e384c79ffe274493 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 29 Jun 2026 11:07:20 +0000 Subject: [PATCH] =?UTF-8?q?feat(host/steam):=20M1=20=E2=80=94=20byte-exact?= =?UTF-8?q?=20Deck=20input=20serializer,=20on-box=20validated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flesh out inject/proto/steam_proto.rs into the full Steam Deck HID contract, transcribed verbatim from the kernel steam_do_deck_input_event / steam_do_deck_sensors_event and validated field-for-field against kernel 7.0: - SteamState: the u64 button map (bytes 8..16), sticks/triggers/trackpads/IMU stored as raw little-endian report values; serialize_deck_state is a pure, byte-exact memcpy into the 64-byte unnumbered frame. - from_gamepad (XInput frame -> Deck buttons/sticks/triggers) + apply_rich (RichInput touchpad -> right pad, motion -> IMU). - parse_steam_output: the 0xEB ID_TRIGGER_RUMBLE_CMD feedback -> (low, high) for the universal rumble plane. - serial_reply fixed: prepend the report-id-0 byte the kernel strips (steam_recv_report does memcpy(data, buf+1, ...)); M0's reply lacked it, so the kernel fell back to the "XXXXXXXXXX" serial. - SteamModel (Deck now; classic Controller later), command/feature IDs. The spike is repurposed as the M1 validator: it pulses the b9.6 mode-switch to enter gamepad_mode (steam_do_deck_input_event early-returns under the default lizard_mode otherwise), then holds a known test pattern. Reading both evdevs via EVIOCGABS/EVIOCGKEY, every field matched: ABS_X/Y/RX/RY (incl. the kernel Y-negation), both triggers, the touched right-pad HAT1X/Y, the IMU accel/gyro (with ABS_Z/RZ negations), and the 6 expected buttons incl. the L4/R5 grips. 5 unit tests + workspace clippy/fmt/test green. Next: M2 (SteamControllerManager UHID backend + PadBackend wiring). Not pushed — pipeline not yet shippable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/bin/steam_uhid_spike.rs | 150 +++--- .../src/inject/proto/steam_proto.rs | 429 ++++++++++++++---- design/steam-controller-deck-support.md | 35 +- 3 files changed, 450 insertions(+), 164 deletions(-) diff --git a/crates/punktfunk-host/src/bin/steam_uhid_spike.rs b/crates/punktfunk-host/src/bin/steam_uhid_spike.rs index 90d2187..35ca721 100644 --- a/crates/punktfunk-host/src/bin/steam_uhid_spike.rs +++ b/crates/punktfunk-host/src/bin/steam_uhid_spike.rs @@ -1,16 +1,13 @@ -//! M0 recognition spike (THROWAWAY) — `design/steam-controller-deck-support.md` go/no-go gate. +//! M0/M1 on-box validator (THROWAWAY) — `design/steam-controller-deck-support.md`. //! -//! Opens `/dev/uhid`, creates a virtual `28DE:1205` Steam Deck controller using -//! [`steam_proto::STEAMDECK_RDESC`], services the kernel handshake (the three event types the -//! DualSense backend does NOT: `UHID_SET_REPORT` must be answered or `hid-steam` stalls ~5 s/cmd), -//! answers `steam_get_serial`, heartbeats a neutral Deck report at 125 Hz, and toggles `BTN_A` -//! every 500 ms so a button event is observable. +//! Creates a virtual `28DE:1205` Steam Deck via `/dev/uhid`, enters `gamepad_mode` (pulses the +//! `b9.6` mode-switch bit ~700 ms — `steam_do_deck_input_event` else early-returns under the +//! default `lizard_mode`), then holds a KNOWN test pattern across every field so an evdev reader can +//! confirm [`steam_proto::serialize_deck_state`] is byte-exact against the running kernel. Services +//! the handshake (incl. `UHID_SET_REPORT`, which the DualSense backend omits) and logs any rumble +//! feedback. Run: `cargo run -p punktfunk-host --bin steam_uhid_spike -- [seconds]`. //! -//! PASS (GO): `dmesg` shows `hid-steam` binding the device; both a gamepad evdev and an IMU evdev -//! (`INPUT_PROP_ACCELEROMETER`) appear; an evdev reader sees `BTN_A` toggle. Run: -//! `cargo run -p punktfunk-host --bin steam_uhid_spike -- [seconds]` -//! -//! This binary is deleted once M1's `inject/linux/steam_controller.rs` subsumes it. +//! Deleted once M2's `inject/linux/steam_controller.rs` subsumes it. #[cfg(target_os = "linux")] #[path = "../inject/proto/steam_proto.rs"] @@ -24,12 +21,11 @@ fn main() -> anyhow::Result<()> { use std::os::unix::fs::OpenOptionsExt; use std::time::{Duration, Instant}; use steam_proto::{ - serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT, STEAMDECK_RDESC, - STEAM_REPORT_LEN, STEAM_VENDOR, + btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT, + STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, }; - // /dev/uhid event ABI (linux/uhid.h): a u32 `type` then a __packed union (largest member is - // uhid_create2_req). Field offsets below are union-start (event byte 4) + struct offset. + // /dev/uhid event ABI (linux/uhid.h): u32 `type` then a __packed union (largest = create2_req). const EVENT_SIZE: usize = 4 + 4372; const UHID_DESTROY: u32 = 1; const UHID_START: u32 = 2; @@ -45,6 +41,25 @@ fn main() -> anyhow::Result<()> { const UHID_SET_REPORT_REPLY: u32 = 14; const BUS_USB: u16 = 0x03; + // The held test pattern (post mode-switch). Chosen to exercise distinct fields with distinct, + // recognizable values; expected evdev result is asserted by the companion reader. + fn test_pattern() -> SteamState { + let mut st = SteamState::neutral(); + st.buttons = btn::A | btn::X | btn::L4 | btn::R5 | btn::VIEW | btn::RB; + st.lx = 8000; + st.ly = 4000; + st.rx = -3000; + st.ry = 6000; + st.lt = 20000; + st.rt = 10000; + st.press(btn::RPAD_TOUCH, true); + st.rpad_x = 5000; + st.rpad_y = -5000; + st.accel = [1000, 2000, 3000]; + st.gyro = [100, 200, 300]; + st + } + let seconds: u64 = std::env::args() .nth(1) .and_then(|s| s.parse().ok()) @@ -57,121 +72,98 @@ fn main() -> anyhow::Result<()> { .open("/dev/uhid") .context("open /dev/uhid (are you in the 'input' group?)")?; - // --- UHID_CREATE2: identity + report descriptor --- let put_cstr = |ev: &mut [u8], off: usize, cap: usize, s: &str| { let n = s.len().min(cap - 1); ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); }; let mut ev = vec![0u8; EVENT_SIZE]; ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); - put_cstr(&mut ev, 4, 128, "Punktfunk Steam Deck (M0 spike)"); // name[128] + put_cstr(&mut ev, 4, 128, "Punktfunk Steam Deck (spike)"); // name[128] put_cstr(&mut ev, 132, 64, "punktfunk/steam/0"); // phys[64] put_cstr(&mut ev, 196, 64, "punktfunk-steam-0"); // uniq[64] - ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); // rd_size - ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus - ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes()); // vendor - ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes()); // product - ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version - ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country + ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); + ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); + ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes()); + ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes()); + ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); + ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC); fd.write_all(&ev).context("write UHID_CREATE2")?; eprintln!( - "UHID_CREATE2 -> 28DE:1205 \"Punktfunk Steam Deck (M0 spike)\", {} byte rdesc; running {seconds}s", - STEAMDECK_RDESC.len() + "UHID_CREATE2 -> 28DE:1205; pulsing mode-switch then holding test pattern ({seconds}s)" ); - let (mut starts, mut opens, mut gets, mut sets, mut outputs) = (0u32, 0u32, 0u32, 0u32, 0u32); + let (mut sets, mut gets, mut outputs) = (0u32, 0u32, 0u32); let mut seq: u32 = 0; - let mut a_down = false; let start = Instant::now(); let mut last_hb = start; - let mut last_toggle = start; let mut rbuf = vec![0u8; EVENT_SIZE]; while start.elapsed() < Duration::from_secs(seconds) { - // Drain all pending kernel events; reply to the handshake (O_NONBLOCK → WouldBlock = empty). while let Ok(n) = fd.read(&mut rbuf) { if n < 4 { break; } match u32::from_ne_bytes([rbuf[0], rbuf[1], rbuf[2], rbuf[3]]) { - UHID_START => { - starts += 1; - eprintln!(" <- UHID_START"); - } - UHID_OPEN => { - opens += 1; - eprintln!(" <- UHID_OPEN (a consumer opened the evdev/hidraw)"); - } - UHID_STOP => eprintln!(" <- UHID_STOP"), - UHID_CLOSE => eprintln!(" <- UHID_CLOSE"), + UHID_START | UHID_STOP | UHID_CLOSE => {} + UHID_OPEN => eprintln!(" <- UHID_OPEN (consumer opened the evdev/hidraw)"), UHID_OUTPUT => { outputs += 1; let sz = u16::from_ne_bytes([rbuf[4100], rbuf[4101]]) as usize; - eprintln!( - " <- UHID_OUTPUT ({sz} bytes, head={:02X?})", - &rbuf[4..4 + sz.min(8)] - ); + if let Some(rb) = parse_steam_output(&rbuf[4..4 + sz.min(64)]).rumble { + eprintln!(" <- rumble (OUTPUT): {rb:?}"); + } } UHID_GET_REPORT => { gets += 1; let id = u32::from_ne_bytes([rbuf[4], rbuf[5], rbuf[6], rbuf[7]]); - let rnum = rbuf[8]; let reply = serial_reply("PUNKTFUNK01"); let mut out = vec![0u8; EVENT_SIZE]; out[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes()); - out[4..8].copy_from_slice(&id.to_ne_bytes()); // id - out[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err = 0 - out[10..12].copy_from_slice(&(reply.len() as u16).to_ne_bytes()); // size + out[4..8].copy_from_slice(&id.to_ne_bytes()); + out[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 + out[10..12].copy_from_slice(&(reply.len() as u16).to_ne_bytes()); out[12..12 + reply.len()].copy_from_slice(&reply); - fd.write_all(&out).context("write GET_REPORT_REPLY")?; - eprintln!(" <- UHID_GET_REPORT (rnum={rnum}) -> replied serial"); + fd.write_all(&out).context("GET_REPORT_REPLY")?; } UHID_SET_REPORT => { sets += 1; let id = u32::from_ne_bytes([rbuf[4], rbuf[5], rbuf[6], rbuf[7]]); - let rnum = rbuf[8]; - let cmd = rbuf[12]; // data[0] = the Steam command id + // data starts at ev[12]: [report-id 0, cmd, …] — surface rumble if present. + if let Some(rb) = + parse_steam_output(&rbuf[12..12 + 16.min(EVENT_SIZE - 12)]).rumble + { + eprintln!(" <- rumble (SET_REPORT): {rb:?}"); + } let mut out = vec![0u8; EVENT_SIZE]; out[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes()); - out[4..8].copy_from_slice(&id.to_ne_bytes()); // id - out[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err = 0 (ack) - fd.write_all(&out).context("write SET_REPORT_REPLY")?; - eprintln!(" <- UHID_SET_REPORT (rnum={rnum}, cmd=0x{cmd:02X}) -> ack err=0"); + out[4..8].copy_from_slice(&id.to_ne_bytes()); + out[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 + fd.write_all(&out).context("SET_REPORT_REPLY")?; } other => eprintln!(" <- UHID event type {other}"), } } - // Heartbeat the current state at ~125 Hz (a real Deck streams continuously; silence reads - // as an unplug to the kernel/SDL). if last_hb.elapsed() >= Duration::from_millis(8) { last_hb = Instant::now(); seq = seq.wrapping_add(1); - let mut st = SteamState::neutral(); - if a_down { - // M0 parse-path probe: mash every button byte so SOME BTN_* fires regardless of the - // exact (M1-confirmed) per-bit mapping — proves hid-steam parses our state reports. - st.b8 = 0xFF; - st.b9 = 0xFF; - st.b10 = 0xFF; - st.b13 = 0xFF; - st.b14 = 0xFF; - } + // First ~700 ms: hold the mode-switch bit (b9.6) to toggle gamepad_mode on. After that: + // the held test pattern (which must NOT contain b9.6, or it would toggle back). + let st = if start.elapsed() < Duration::from_millis(700) { + let mut s = SteamState::neutral(); + s.press(btn::STEAM_MENU_RIGHT, true); + s + } else { + test_pattern() + }; let mut r = [0u8; STEAM_REPORT_LEN]; serialize_deck_state(&mut r, &st, seq); let mut out = vec![0u8; EVENT_SIZE]; out[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); - out[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size - out[6..6 + r.len()].copy_from_slice(&r); // input2.data - fd.write_all(&out).context("write UHID_INPUT2")?; - } - - // Toggle BTN_A every 500 ms so an evdev reader sees a key event. - if last_toggle.elapsed() >= Duration::from_millis(500) { - last_toggle = Instant::now(); - a_down = !a_down; - eprintln!("BTN_A -> {}", if a_down { "DOWN" } else { "UP" }); + out[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); + out[6..6 + r.len()].copy_from_slice(&r); + fd.write_all(&out).context("UHID_INPUT2")?; } std::thread::sleep(Duration::from_millis(1)); @@ -180,9 +172,7 @@ fn main() -> anyhow::Result<()> { let mut out = vec![0u8; EVENT_SIZE]; out[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes()); let _ = fd.write_all(&out); - eprintln!( - "UHID_DESTROY. handshake counts: START={starts} OPEN={opens} GET_REPORT={gets} SET_REPORT={sets} OUTPUT={outputs}" - ); + eprintln!("UHID_DESTROY. handshake: GET_REPORT={gets} SET_REPORT={sets} OUTPUT={outputs}"); Ok(()) } diff --git a/crates/punktfunk-host/src/inject/proto/steam_proto.rs b/crates/punktfunk-host/src/inject/proto/steam_proto.rs index 3bd033f..c1b54b7 100644 --- a/crates/punktfunk-host/src/inject/proto/steam_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/steam_proto.rs @@ -1,31 +1,33 @@ //! Transport-independent Steam Controller / Steam Deck HID contract — the Steam analogue of -//! [`super::dualsense_proto`]. Descriptor, command/feature IDs, the serial GET_REPORT reply, and -//! the input-report serializer that the kernel `hid-steam` driver parses. +//! [`super::dualsense_proto`]. The report descriptor, the command/feature IDs, the byte-exact +//! Deck input-report serializer, the `XInput`/rich-input → state mappers, and the rumble-feedback +//! parser. Pure logic, shared by the Linux UHID backend and (later) a Windows UMDF backend. //! -//! **M0 scope (recognition spike):** only what is needed for `hid-steam` to bind a `/dev/uhid` -//! `28DE:1205` device and create its evdevs — -//! * [`STEAMDECK_RDESC`]: a vendor collection with ≥1 **feature** report, which is the *sole* -//! thing `steam_is_valve_interface()` checks (`!list_empty(&FEATURE.report_list)`); -//! * [`serial_reply`]: the `steam_get_serial()` answer `[0xAE, len, 0x01, ascii…]` (a bad/absent -//! reply is non-fatal — the kernel falls back to `"XXXXXXXXXX"` — but a valid one keeps probe -//! instant); -//! * [`serialize_deck_state`]: a neutral Deck state report whose header (`[0x01,0x00,0x09,len]`) -//! `hid-steam` accepts and parses (the M0 spike proved 23 distinct `BTN_*` codes reach the -//! evdev). The exact per-bit button offsets below are PROVISIONAL — M1 confirms them -//! line-by-line against the lab kernel's `steam_do_deck_input_event` (the v6.12-sourced -//! `byte 8 bit 7 = BTN_A` did NOT match on the 7.0 box). +//! **Layout source of truth:** the kernel `drivers/hid/hid-steam.c` `steam_do_deck_input_event` +//! (+ `steam_do_deck_sensors_event`) — every offset/bit/sign below is transcribed verbatim from +//! it and on-box-validated against kernel 7.0 (see `design/steam-controller-deck-support.md`). +//! M0 proved the device binds + parses; M1 (here) makes the serializer byte-exact. //! -//! The **full** field layout (sticks, triggers, both trackpads, the IMU, all four back grips, the -//! `0xEB`/`0x8F` feedback reports) lands in M1, line-checked against the lab kernel's -//! `steam_do_deck_input_event` / `steam_haptic_rumble` — see `design/steam-controller-deck-support.md`. -#![allow(dead_code)] // M0: the full state model + the PadBackend wiring arrive in M1. +//! Three load-bearing details the DualSense path does NOT have: +//! * **report id 0 / unnumbered**: input reports are the raw 64 bytes starting `[0x01,0x00,0x09]` +//! (no report-id prefix); FEATURE get/set reports DO carry a leading `0x00` report-id byte +//! (`steam_send_report` does `memcpy(buf+1, cmd, …)`, `steam_recv_report` strips `buf[0]`). +//! * **`gamepad_mode` gate**: `steam_do_deck_input_event` early-returns when +//! `!gamepad_mode && lizard_mode` (the module param, default on). `gamepad_mode` starts false +//! and TOGGLES when [`btn::STEAM_MENU_RIGHT`] (`b9.6`, the mode-switch) is held ~450 ms while +//! no hidraw client is open. The backend enters gamepad mode at session start (pulse that bit, +//! or load `hid_steam lizard_mode=0`) — see the backend, not this module. +//! * **the `UHID_SET_REPORT` handshake** must be answered (DualSense omits it). +#![allow(dead_code)] // Some of the full model is consumed only once the M2 backend + M3 wire land. -/// Valve. `hid-steam` matches purely by VID/PID over `BUS_USB` -/// (`HID_USB_DEVICE(0x28DE, 0x1205, STEAM_QUIRK_DECK)`), so a UHID device with these IDs binds. +use punktfunk_core::input::gamepad as gs; +use punktfunk_core::quic::RichInput; + +/// Valve. `hid-steam` matches purely by VID/PID over `BUS_USB`. pub const STEAM_VENDOR: u32 = 0x28DE; /// Steam Deck built-in controller (same PID on LCD + OLED). pub const STEAMDECK_PRODUCT: u32 = 0x1205; -/// Classic Steam Controller, wired (report id 1; a later identity behind the same manager). +/// Classic Steam Controller, wired (report id 1 / `ID_CONTROLLER_STATE`; a later model). pub const STEAMCTRL_WIRED_PRODUCT: u32 = 0x1102; /// The Steam HID state/command report is a fixed 64-byte, **unnumbered** (report-id-0) frame. @@ -39,15 +41,35 @@ pub const ID_LOAD_DEFAULT_SETTINGS: u8 = 0x8E; pub const ID_GET_DEVICE_INFO: u8 = 0xA1; pub const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE; pub const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01; +/// Host→client feedback: `steam_haptic_rumble` emits report `[0xEB, 9, …]` (FF_RUMBLE → trackpad +/// actuators / Deck motors). The Deck's rumble path; the classic SC also has `0x8F` pad pulses. +pub const ID_TRIGGER_RUMBLE_CMD: u8 = 0xEB; +pub const ID_TRIGGER_HAPTIC_PULSE: u8 = 0x8F; /// Input report message types: SC = `ID_CONTROLLER_STATE`, Deck = `ID_CONTROLLER_DECK_STATE`. pub const ID_CONTROLLER_STATE: u8 = 0x01; pub const ID_CONTROLLER_DECK_STATE: u8 = 0x09; +/// Which Steam device identity to present. M1 implements the Deck fully; the classic Controller +/// (dual trackpads, report id 1, trackpad-only haptics) is a later identity behind the same path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SteamModel { + Deck, + Controller, +} + +impl SteamModel { + pub fn product(self) -> u32 { + match self { + SteamModel::Deck => STEAMDECK_PRODUCT, + SteamModel::Controller => STEAMCTRL_WIRED_PRODUCT, + } + } +} + /// Minimal vendor-defined HID report descriptor: one application collection with a 64-byte input /// report and a 64-byte feature report, both UNNUMBERED (report id 0). `hid-steam` is a raw-event -/// driver (`steam_raw_event` consumes reports before HID field parsing), so the field layout is -/// cosmetic — but `steam_probe` requires `hid_parse` to succeed AND a non-empty FEATURE report -/// list (`steam_is_valve_interface`), so the feature item is mandatory. +/// driver, so the field layout is cosmetic — but `steam_probe` requires `hid_parse` to succeed AND +/// a non-empty FEATURE report list (`steam_is_valve_interface`), so the feature item is mandatory. #[rustfmt::skip] pub const STEAMDECK_RDESC: &[u8] = &[ 0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00) @@ -65,25 +87,75 @@ pub const STEAMDECK_RDESC: &[u8] = &[ 0xC0, // End Collection ]; -// PROVISIONAL Deck button bits (from the v6.12 steam_do_deck_input_event listing) — NOT yet -// on-box validated: the M0 spike's mash-probe confirmed the report PARSES (the full BTN_* set -// fires), but byte 8 bit 7 alone did not produce BTN_A on the 7.0 box, so M1 must line-check the -// real per-bit map against the lab kernel before these are trusted. -/// `data[8]` bit 7 → (claimed) `BTN_A`. -pub const DECK_B8_A: u8 = 0x80; -/// `data[9]` bit 5 → (claimed) `BTN_MODE` (the Steam button). -pub const DECK_B9_STEAM: u8 = 0x20; +/// Deck button bits, indexed in the `u64` packed across report bytes 8..16 — bit `(byte-8)*8 + bit`, +/// transcribed verbatim from `steam_do_deck_input_event` (bytes 12 + 15 carry no buttons). Naming +/// follows the physical Deck control; the trailing comment is the kernel `BTN_*` it maps to. +pub mod btn { + // byte 8 + pub const RT_FULL: u64 = 1 << 0; // BTN_TR2 — right trigger fully pressed + pub const LT_FULL: u64 = 1 << 1; // BTN_TL2 — left trigger fully pressed + pub const RB: u64 = 1 << 2; // BTN_TR — right shoulder + pub const LB: u64 = 1 << 3; // BTN_TL — left shoulder + pub const Y: u64 = 1 << 4; + pub const B: u64 = 1 << 5; + pub const X: u64 = 1 << 6; + pub const A: u64 = 1 << 7; + // byte 9 + pub const DPAD_UP: u64 = 1 << 8; + pub const DPAD_RIGHT: u64 = 1 << 9; + pub const DPAD_LEFT: u64 = 1 << 10; + pub const DPAD_DOWN: u64 = 1 << 11; + pub const VIEW: u64 = 1 << 12; // BTN_SELECT — "menu left" (View / Back) + pub const STEAM: u64 = 1 << 13; // BTN_MODE — Steam logo button + pub const MENU: u64 = 1 << 14; // BTN_START — "menu right" (Start / Options) + pub const L5: u64 = 1 << 15; // BTN_GRIPL2 — left BOTTOM back grip + // byte 10 + pub const R5: u64 = 1 << 16; // BTN_GRIPR2 — right BOTTOM back grip + pub const LPAD_CLICK: u64 = 1 << 17; // BTN_THUMB — left pad pressed (click) + pub const RPAD_CLICK: u64 = 1 << 18; // BTN_THUMB2 — right pad pressed (click) + pub const LPAD_TOUCH: u64 = 1 << 19; // gates ABS_HAT0 (left pad coords) + pub const RPAD_TOUCH: u64 = 1 << 20; // gates ABS_HAT1 (right pad coords) + pub const L3: u64 = 1 << 22; // BTN_THUMBL — left joystick click + // byte 11 + pub const R3: u64 = 1 << 26; // BTN_THUMBR — right joystick click + // byte 13 + pub const L4: u64 = 1 << 41; // BTN_GRIPL — left TOP back grip + pub const R4: u64 = 1 << 42; // BTN_GRIPR — right TOP back grip + pub const LJOY_TOUCH: u64 = 1 << 46; + pub const RJOY_TOUCH: u64 = 1 << 47; + // byte 14 + pub const QAM: u64 = 1 << 50; // BTN_BASE — quick-access (…) button + /// `b9.6` doubles as the mode-switch: held ~450 ms (no hidraw client) it toggles `gamepad_mode`. + pub const STEAM_MENU_RIGHT: u64 = MENU; +} -/// M0 controller state: just the five button bytes the Deck report packs (8, 9, 10, 13, 14). The -/// sticks, triggers, trackpads and IMU stay neutral (signed-centred at 0) for the recognition -/// spike; M1 fills them from the wire frame + rich-input planes. -#[derive(Clone, Copy, Default)] +/// Full virtual Steam Deck controller state. All analog fields are stored as the RAW little-endian +/// report values the kernel reads (so [`serialize_deck_state`] is a pure memcpy); the kernel applies +/// its own sign conventions on top (`ABS_Y = -raw`, etc.) — see [`SteamState::from_gamepad`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct SteamState { - pub b8: u8, - pub b9: u8, - pub b10: u8, - pub b13: u8, - pub b14: u8, + /// Packed button bits (see [`btn`]); occupies report bytes 8..16. + pub buttons: u64, + /// Left / right joystick, raw s16 (report 48/50/52/54). The kernel negates the Y axes. + pub lx: i16, + pub ly: i16, + pub rx: i16, + pub ry: i16, + /// Left / right analog trigger, raw u16 (report 44/46 → ABS_HAT2Y/X). + pub lt: u16, + pub rt: u16, + /// Left / right trackpad position, raw s16, centred 0 (report 16/18/20/22). Only surfaced by + /// the kernel while the matching `*PAD_TOUCH` button bit is set. + pub lpad_x: i16, + pub lpad_y: i16, + pub rpad_x: i16, + pub rpad_y: i16, + pub lpad_pressure: u16, + pub rpad_pressure: u16, + /// IMU, raw s16. `accel`/`gyro` are `[X, Y, Z]`; the kernel maps them to ABS_X/Z/Y + ABS_RX/RZ/RY + /// (with Z/RZ negated) on the separate sensors evdev. + pub accel: [i16; 3], + pub gyro: [i16; 3], } impl SteamState { @@ -91,19 +163,91 @@ impl SteamState { SteamState::default() } - /// Press/release `BTN_A` (the spike's toggle target). - pub fn set_a(&mut self, down: bool) { + /// Set/clear a button (or group) by its [`btn`] mask. + pub fn press(&mut self, mask: u64, down: bool) { if down { - self.b8 |= DECK_B8_A; + self.buttons |= mask; } else { - self.b8 &= !DECK_B8_A; + self.buttons &= !mask; + } + } + + /// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck + /// state. Sticks pass through (the kernel negates Y, which yields the conventional direction — + /// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when + /// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire). + pub fn from_gamepad( + buttons: u32, + lx: i16, + ly: i16, + rx: i16, + ry: i16, + lt: u8, + rt: u8, + ) -> SteamState { + let on = |bit: u32| buttons & bit != 0; + let mut s = SteamState { + lx, + ly, + rx, + ry, + lt: (lt as u16) * 128, + rt: (rt as u16) * 128, + ..SteamState::neutral() + }; + let mut b = 0u64; + let set = |b: &mut u64, on: bool, m: u64| { + if on { + *b |= m; + } + }; + set(&mut b, on(gs::BTN_A), btn::A); + set(&mut b, on(gs::BTN_B), btn::B); + set(&mut b, on(gs::BTN_X), btn::X); + set(&mut b, on(gs::BTN_Y), btn::Y); + set(&mut b, on(gs::BTN_LB), btn::LB); + set(&mut b, on(gs::BTN_RB), btn::RB); + set(&mut b, lt > 0, btn::LT_FULL); + set(&mut b, rt > 0, btn::RT_FULL); + set(&mut b, on(gs::BTN_BACK), btn::VIEW); + set(&mut b, on(gs::BTN_START), btn::MENU); + set(&mut b, on(gs::BTN_GUIDE), btn::STEAM); + set(&mut b, on(gs::BTN_LS_CLICK), btn::L3); + set(&mut b, on(gs::BTN_RS_CLICK), btn::R3); + set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP); + set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN); + set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT); + set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT); + // The DualSense touchpad-click wire bit maps to the Deck's RIGHT pad click (the pad that + // stands in for the DualSense touchpad — see apply_rich). + set(&mut b, on(gs::BTN_TOUCHPAD), btn::RPAD_CLICK); + s.buttons = b; + s + } + + /// Apply one rich client→host event into this state, preserving everything else. The single-pad + /// wire [`RichInput::Touchpad`] maps to the **right** trackpad (the Deck pad analogous to the + /// DualSense touchpad); the left pad arrives via the M3 `TouchpadEx` surface. [`RichInput::Motion`] + /// passes gyro/accel straight through (raw i16; cross-device unit scaling is M3). + pub fn apply_rich(&mut self, rich: RichInput) { + match rich { + RichInput::Touchpad { active, x, y, .. } => { + self.press(btn::RPAD_TOUCH, active); + // Normalized 0..=65535 (centre 32768) → the pad's centred s16 range. + self.rpad_x = ((x as i32) - 32768) as i16; + self.rpad_y = ((y as i32) - 32768) as i16; + } + RichInput::Motion { gyro, accel, .. } => { + self.gyro = gyro; + self.accel = accel; + } } } } -/// Serialize a neutral-plus-buttons Deck state into the 64-byte unnumbered report. Header is -/// `[0x01, 0x00, 0x09, len]` + a little-endian frame counter; `steam_raw_event` drops anything -/// where `size != 64 || data[0] != 1 || data[1] != 0`, then switches on `data[2]`. +/// Serialize the full Deck input report (`ID_CONTROLLER_DECK_STATE`) into the 64-byte unnumbered +/// frame `hid-steam` parses. Pure + byte-exact against `steam_do_deck_input_event`; the report-id +/// constant is `data[0]=0x01` (NOT a HID report id — this report is unnumbered). pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) { r.fill(0); r[0] = 0x01; @@ -111,35 +255,72 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq r[2] = ID_CONTROLLER_DECK_STATE; r[3] = 0x3C; // payload length; the kernel ignores it r[4..8].copy_from_slice(&seq.to_le_bytes()); - r[8] = st.b8; - r[9] = st.b9; - r[10] = st.b10; - r[13] = st.b13; - r[14] = st.b14; + r[8..16].copy_from_slice(&st.buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0) + r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes()); + r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes()); + r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes()); + r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes()); + r[24..26].copy_from_slice(&st.accel[0].to_le_bytes()); // accel X → IMU ABS_X + r[26..28].copy_from_slice(&st.accel[1].to_le_bytes()); // accel Y → IMU ABS_Z (kernel negates) + r[28..30].copy_from_slice(&st.accel[2].to_le_bytes()); // accel Z → IMU ABS_Y + r[30..32].copy_from_slice(&st.gyro[0].to_le_bytes()); // gyro X → IMU ABS_RX + r[32..34].copy_from_slice(&st.gyro[1].to_le_bytes()); // gyro Y → IMU ABS_RZ (kernel negates) + r[34..36].copy_from_slice(&st.gyro[2].to_le_bytes()); // gyro Z → IMU ABS_RY + // 36..44 quaternion — left 0 (optional; the kernel does not surface it) + r[44..46].copy_from_slice(&st.lt.to_le_bytes()); // left trigger → ABS_HAT2Y + r[46..48].copy_from_slice(&st.rt.to_le_bytes()); // right trigger → ABS_HAT2X + r[48..50].copy_from_slice(&st.lx.to_le_bytes()); // left joystick X → ABS_X + r[50..52].copy_from_slice(&st.ly.to_le_bytes()); // left joystick Y → ABS_Y (kernel negates) + r[52..54].copy_from_slice(&st.rx.to_le_bytes()); // right joystick X → ABS_RX + r[54..56].copy_from_slice(&st.ry.to_le_bytes()); // right joystick Y → ABS_RY (kernel negates) + r[56..58].copy_from_slice(&st.lpad_pressure.to_le_bytes()); + r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes()); } -/// Build the `steam_get_serial` GET_REPORT reply: `[0xAE, len, ATTRIB_STR_UNIT_SERIAL, ascii…]`, -/// padded to 64 bytes. The kernel validates `reply[0] == 0xAE && 1 <= reply[1] <= 21 && -/// reply[2] == 1`; the serial ASCII follows at byte 3. +/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a +/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so +/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`, +/// `1<=reply[1]<=21`, `reply[2]==0x01`. Non-fatal (a bad reply → the `"XXXXXXXXXX"` fallback). pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] { let mut buf = [0u8; STEAM_REPORT_LEN]; let bytes = serial.as_bytes(); let len = bytes.len().clamp(1, 21); - buf[0] = ID_GET_STRING_ATTRIBUTE; - buf[1] = len as u8; - buf[2] = ATTRIB_STR_UNIT_SERIAL; - buf[3..3 + len].copy_from_slice(&bytes[..len]); + buf[0] = 0x00; // report id 0 — stripped by steam_recv_report + 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 } +/// One service pass's extracted feedback. Rumble rides the universal 0xCA plane (so any client +/// feels it); the classic SC's trackpad-pulse haptics (`0x8F`) are a later, model-specific add. +#[derive(Default, Debug, PartialEq, Eq)] +pub struct SteamFeedback { + /// `(low, high)` motor levels (left/strong, right/weak), if a rumble report carried them. + pub rumble: Option<(u16, u16)>, +} + +/// Parse a feature/output report the kernel wrote to our device. The Steam feedback path is a +/// FEATURE `SET_REPORT` whose wire data is `[0x00 report-id, cmd, len, …]`; `cmd == 0xEB` +/// (`steam_haptic_rumble`) carries `[…, 0, intensity(2), left_speed(2), right_speed(2), gains(2)]`. +/// We surface `(left_speed, right_speed)` as `(low, high)` for the 0xCA rumble plane. +pub fn parse_steam_output(data: &[u8]) -> SteamFeedback { + let mut fb = SteamFeedback::default(); + // data[0] is the stripped report-id byte (0); the command id follows. + if data.len() >= 10 && data[1] == ID_TRIGGER_RUMBLE_CMD { + let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]); + let left = le(6); // left_speed (report[5..7]) → low / strong motor + let right = le(8); // right_speed (report[7..9]) → high / weak motor + fb.rumble = Some((left, right)); + } + fb +} + #[cfg(test)] mod tests { use super::*; - /// `steam_is_valve_interface()` binds the device iff the descriptor declares ≥1 feature report, - /// so the descriptor MUST contain a Feature main item (0xB1) — plus an Input item (0x81) for the - /// state report. A regression here silently makes `hid-steam` treat the device as a - /// keyboard/mouse boot interface and never create the gamepad. #[test] fn descriptor_declares_input_and_feature_reports() { assert!( @@ -154,30 +335,120 @@ mod tests { ); } - /// The report header is exactly what `steam_raw_event` requires (`data[0]==1, data[1]==0, - /// data[2]==0x09`), the frame counter is little-endian, and `set_a` toggles byte 8 bit 7. + /// Every analog field lands at the exact offset `steam_do_deck_input_event` reads, the header is + /// what `steam_raw_event` requires, and the buttons pack into bytes 8..16 (12+15 zero). A + /// one-byte slip here turns the whole controller into noise. #[test] - fn serialize_header_seq_and_button() { + fn serialize_is_byte_exact() { let mut st = SteamState::neutral(); - st.set_a(true); + st.buttons = btn::A | btn::L4 | btn::R5 | btn::QAM; + st.lx = 0x1122; + st.ly = 0x3344; + st.rx = 0x5566; + st.ry = 0x778; + st.lt = 0xABCD; + st.rt = 0xEF01; + st.lpad_x = 0x0A0B; + st.lpad_y = 0x0C0D; + st.rpad_x = 0x0E0F; + st.rpad_y = 0x1011; + st.accel = [0x0102, 0x0304, 0x0506]; + st.gyro = [0x0708, 0x090A, 0x0B0C]; + st.lpad_pressure = 0x1314; + st.rpad_pressure = 0x1516; let mut r = [0u8; STEAM_REPORT_LEN]; serialize_deck_state(&mut r, &st, 0xAABB_CCDD); assert_eq!(&r[0..4], &[0x01, 0x00, 0x09, 0x3C]); assert_eq!(&r[4..8], &[0xDD, 0xCC, 0xBB, 0xAA]); // seq LE - assert_eq!(r[8] & DECK_B8_A, DECK_B8_A); - st.set_a(false); - serialize_deck_state(&mut r, &st, 0); - assert_eq!(r[8] & DECK_B8_A, 0); + // buttons: A=bit7 (byte8), L4=bit41 (byte13.1), R5=bit16 (byte10.0), QAM=bit50 (byte14.2). + assert_eq!(r[8], 0x80); // A + assert_eq!(r[10], 0x01); // R5 + assert_eq!(r[12], 0x00); // unused button byte + assert_eq!(r[13], 0x02); // L4 (bit 1) + assert_eq!(r[14], 0x04); // QAM (bit 2) + assert_eq!(r[15], 0x00); // unused button byte + assert_eq!(&r[16..18], &0x0A0Bi16.to_le_bytes()); // lpad X + assert_eq!(&r[20..22], &0x0E0Fi16.to_le_bytes()); // rpad X + assert_eq!(&r[24..26], &0x0102i16.to_le_bytes()); // accel X + assert_eq!(&r[26..28], &0x0304i16.to_le_bytes()); // accel Y + assert_eq!(&r[28..30], &0x0506i16.to_le_bytes()); // accel Z + assert_eq!(&r[30..32], &0x0708i16.to_le_bytes()); // gyro X + assert_eq!(&r[44..46], &0xABCDu16.to_le_bytes()); // left trigger + assert_eq!(&r[46..48], &0xEF01u16.to_le_bytes()); // right trigger + assert_eq!(&r[48..50], &0x1122i16.to_le_bytes()); // left joy X + assert_eq!(&r[50..52], &0x3344i16.to_le_bytes()); // left joy Y + assert_eq!(&r[52..54], &0x5566i16.to_le_bytes()); // right joy X + assert_eq!(&r[56..58], &0x1314u16.to_le_bytes()); // left pad pressure + assert_eq!(&r[58..60], &0x1516u16.to_le_bytes()); // right pad pressure } - /// The serial reply passes `steam_get_serial`'s validation (`reply[0]==0xAE`, `1<=reply[1]<=21`, - /// `reply[2]==1`) and carries the ASCII at byte 3. + /// `from_gamepad` sets the right Deck bits + scales triggers, and a touched flag is merged when + /// a trackpad contact arrives via `apply_rich`. #[test] - fn serial_reply_passes_kernel_validation() { + fn from_gamepad_and_rich_mapping() { + let s = SteamState::from_gamepad( + gs::BTN_A | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_LB, + 1000, + -2000, + 0, + 0, + 255, + 0, + ); + assert_ne!(s.buttons & btn::A, 0); + assert_ne!(s.buttons & btn::MENU, 0); + assert_ne!(s.buttons & btn::STEAM, 0); + assert_ne!(s.buttons & btn::LB, 0); + assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit + assert_eq!(s.lt, 255 * 128); + assert_eq!(s.lx, 1000); + assert_eq!(s.ly, -2000); + + let mut s = SteamState::neutral(); + s.apply_rich(RichInput::Touchpad { + pad: 0, + finger: 0, + active: true, + x: 65535, + y: 0, + }); + assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); + assert_eq!(s.rpad_x, 32767); // 65535-32768 + assert_eq!(s.rpad_y, -32768); // 0-32768 + s.apply_rich(RichInput::Motion { + pad: 0, + gyro: [1, 2, 3], + accel: [4, 5, 6], + }); + assert_eq!(s.gyro, [1, 2, 3]); + assert_eq!(s.accel, [4, 5, 6]); + } + + /// The serial reply carries the leading report-id byte the kernel strips, so the *stripped* + /// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`. + #[test] + fn serial_reply_has_stripped_prefix() { let r = serial_reply("PUNKTFUNK01"); - assert_eq!(r[0], ID_GET_STRING_ATTRIBUTE); - assert!((1..=21).contains(&r[1])); - assert_eq!(r[2], ATTRIB_STR_UNIT_SERIAL); - assert_eq!(&r[3..3 + r[1] as usize], b"PUNKTFUNK01"); + assert_eq!(r[0], 0x00); // report id, stripped by steam_recv_report + assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE); // becomes reply[0] after strip + assert!((1..=21).contains(&r[2])); + assert_eq!(r[3], ATTRIB_STR_UNIT_SERIAL); + assert_eq!(&r[4..4 + r[2] as usize], b"PUNKTFUNK01"); + } + + /// A `0xEB` rumble feature report parses to `(left_speed, right_speed)`; other commands don't. + #[test] + fn parse_rumble_feedback() { + // [report-id 0, 0xEB, len 9, 0, intensity(2), left(2), right(2), gains(2)] + let mut d = vec![0u8; 12]; + d[1] = ID_TRIGGER_RUMBLE_CMD; + d[2] = 9; + d[6..8].copy_from_slice(&0x8000u16.to_le_bytes()); // left_speed + d[8..10].copy_from_slice(&0x4000u16.to_le_bytes()); // right_speed + assert_eq!(parse_steam_output(&d).rumble, Some((0x8000, 0x4000))); + + let mut d = vec![0u8; 12]; + d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble + assert_eq!(parse_steam_output(&d).rumble, None); } } diff --git a/design/steam-controller-deck-support.md b/design/steam-controller-deck-support.md index 743696d..7e7e197 100644 --- a/design/steam-controller-deck-support.md +++ b/design/steam-controller-deck-support.md @@ -1,10 +1,35 @@ # Rich Steam Controller & Steam Deck support -> **Status:** **M0 GREEN — Linux feasibility PROVEN on-box (2026-06-29).** The greenfield virtual -> `hid-steam` device works: a `/dev/uhid` `28DE:1205` device binds the kernel `hid-steam` driver, -> registers as a real Steam Deck, and parses our input reports. The full client-capture → protocol -> → inject pipeline (M1+) is unblocked. This remains the design + milestone plan; the Steam analogue -> of the shipped virtual DualSense (`design/windows-dualsense-scoping.md`). +> **Status:** **M0 + M1 GREEN — Linux virtual Deck binds AND is byte-exact, on-box (2026-06-29).** +> The greenfield virtual `hid-steam` device works: a `/dev/uhid` `28DE:1205` device binds the kernel +> `hid-steam` driver, registers as a real Steam Deck, and our full input report is parsed +> field-for-field. Next: M2 (the `SteamControllerManager` UHID backend + `PadBackend` wiring). This +> remains the design + milestone plan; the Steam analogue of the shipped virtual DualSense +> (`design/windows-dualsense-scoping.md`). +> +> **M1 result (byte-exact serializer, on-box):** `inject/proto/steam_proto.rs` now carries the full +> Deck contract transcribed verbatim from the kernel `steam_do_deck_input_event` / +> `steam_do_deck_sensors_event`: the `u64` button map (bytes 8..16), sticks/triggers/trackpads/IMU +> at their exact offsets, `from_gamepad` + `apply_rich` mappers, the rumble-feedback parser +> (`0xEB`), and the serial reply (now with the leading report-id byte the kernel strips — fixes the +> M0 `XXXXXXXXXX` fallback). The validator pulses the `b9.6` mode-switch to enter `gamepad_mode` +> (the parser early-returns under default `lizard_mode` otherwise), holds a known test pattern, and +> reads both evdevs via `EVIOCGABS`/`EVIOCGKEY`: **every field matched** — `ABS_X/Y/RX/RY` (incl. the +> kernel Y-negation), both triggers, the touched right-pad `HAT1X/Y`, the IMU accel/gyro (with the +> `ABS_Z/RZ` negations), and the 6 expected buttons incl. the L4/R5 grips. `byte 8 bit 7 = BTN_A` IS +> correct (the M0 "didn't hold" was a flaky single-bit read before `gamepad_mode` was entered). 5 +> unit tests + workspace clippy/fmt/test green. +> +> **M0 result (box: headless Ubuntu 26.04, kernel 7.0, no Steam):** the spike +> (`crates/punktfunk-host/src/bin/steam_uhid_spike.rs` + the keeper `inject/proto/steam_proto.rs`) +> created a UHID `28DE:1205` device with a minimal vendor descriptor (one feature report — the sole +> thing `steam_is_valve_interface` checks) and serviced the handshake (the `UHID_SET_REPORT` +> answers the DualSense backend omits). `journalctl -k` showed **`hid-steam` binding it** (rebound +> off `hid-generic`), `"Steam Controller … connected"`, and the kernel creating **both** evdevs: +> `"Steam Deck"` (gamepad) **and** `"Steam Deck Motion Sensors"` (`INPUT_PROP_ACCELEROMETER`). +> Outstanding for later: recognition by a **running Steam** client (needs a box with Steam — +> untestable here); the `gamepad_mode` entry strategy on a real host (pulse `b9.6` at session start, +> or load `hid_steam lizard_mode=0`) is an M2 backend decision. > > **M0 result (box: headless Ubuntu 26.04, kernel 7.0, no Steam):** the spike > (`crates/punktfunk-host/src/bin/steam_uhid_spike.rs` + the keeper `inject/proto/steam_proto.rs`)