diff --git a/crates/punktfunk-host/src/bin/steam_uhid_spike.rs b/crates/punktfunk-host/src/bin/steam_uhid_spike.rs new file mode 100644 index 0000000..90d2187 --- /dev/null +++ b/crates/punktfunk-host/src/bin/steam_uhid_spike.rs @@ -0,0 +1,192 @@ +//! M0 recognition spike (THROWAWAY) — `design/steam-controller-deck-support.md` go/no-go gate. +//! +//! 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. +//! +//! 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. + +#[cfg(target_os = "linux")] +#[path = "../inject/proto/steam_proto.rs"] +mod steam_proto; + +#[cfg(target_os = "linux")] +fn main() -> anyhow::Result<()> { + use anyhow::Context; + use std::fs::OpenOptions; + use std::io::{Read, Write}; + 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, + }; + + // /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. + const EVENT_SIZE: usize = 4 + 4372; + const UHID_DESTROY: u32 = 1; + const UHID_START: u32 = 2; + const UHID_STOP: u32 = 3; + const UHID_OPEN: u32 = 4; + const UHID_CLOSE: u32 = 5; + const UHID_OUTPUT: u32 = 6; + const UHID_GET_REPORT: u32 = 9; + const UHID_GET_REPORT_REPLY: u32 = 10; + const UHID_CREATE2: u32 = 11; + const UHID_INPUT2: u32 = 12; + const UHID_SET_REPORT: u32 = 13; + const UHID_SET_REPORT_REPLY: u32 = 14; + const BUS_USB: u16 = 0x03; + + let seconds: u64 = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(25); + + let mut fd = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .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, 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[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() + ); + + let (mut starts, mut opens, mut gets, mut sets, mut outputs) = (0u32, 0u32, 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_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)] + ); + } + 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[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"); + } + 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 + 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"); + } + 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; + } + 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" }); + } + + std::thread::sleep(Duration::from_millis(1)); + } + + 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}" + ); + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn main() { + eprintln!("steam_uhid_spike: Linux-only (needs /dev/uhid + the hid-steam kernel driver)"); +} diff --git a/crates/punktfunk-host/src/inject/proto/steam_proto.rs b/crates/punktfunk-host/src/inject/proto/steam_proto.rs new file mode 100644 index 0000000..3bd033f --- /dev/null +++ b/crates/punktfunk-host/src/inject/proto/steam_proto.rs @@ -0,0 +1,183 @@ +//! 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. +//! +//! **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). +//! +//! 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. + +/// 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. +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). +pub const STEAMCTRL_WIRED_PRODUCT: u32 = 0x1102; + +/// The Steam HID state/command report is a fixed 64-byte, **unnumbered** (report-id-0) frame. +pub const STEAM_REPORT_LEN: usize = 64; + +// Command IDs (drivers/hid/hid-steam.c), confirmed against the kernel source. +pub const ID_CLEAR_DIGITAL_MAPPINGS: u8 = 0x81; +pub const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83; +pub const ID_SET_SETTINGS_VALUES: u8 = 0x87; +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; +/// 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; + +/// 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. +#[rustfmt::skip] +pub const STEAMDECK_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 64-byte state report + 0x09, 0x01, // Usage (0x01) + 0x95, 0x40, // Report Count (64) + 0xB1, 0x02, // Feature (Data,Var,Abs) — makes steam_is_valve_interface() true + 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; + +/// 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)] +pub struct SteamState { + pub b8: u8, + pub b9: u8, + pub b10: u8, + pub b13: u8, + pub b14: u8, +} + +impl SteamState { + pub fn neutral() -> SteamState { + SteamState::default() + } + + /// Press/release `BTN_A` (the spike's toggle target). + pub fn set_a(&mut self, down: bool) { + if down { + self.b8 |= DECK_B8_A; + } else { + self.b8 &= !DECK_B8_A; + } + } +} + +/// 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]`. +pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) { + r.fill(0); + r[0] = 0x01; + r[1] = 0x00; + 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; +} + +/// 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. +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 +} + +#[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!( + STEAMDECK_RDESC.contains(&0xB1), + "missing Feature main item — steam_is_valve_interface() would fail" + ); + assert!(STEAMDECK_RDESC.contains(&0x81), "missing Input main item"); + assert_eq!( + *STEAMDECK_RDESC.last().unwrap(), + 0xC0, + "unterminated collection" + ); + } + + /// 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. + #[test] + fn serialize_header_seq_and_button() { + let mut st = SteamState::neutral(); + st.set_a(true); + 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); + } + + /// 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. + #[test] + fn serial_reply_passes_kernel_validation() { + 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"); + } +} diff --git a/design/steam-controller-deck-support.md b/design/steam-controller-deck-support.md new file mode 100644 index 0000000..743696d --- /dev/null +++ b/design/steam-controller-deck-support.md @@ -0,0 +1,527 @@ +# 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`). +> +> **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, `BTN_A` in key caps) **and** `"Steam Deck Motion Sensors"` +> (`INPUT_PROP_ACCELEROMETER`, 6 IMU axes). A layout-agnostic mash-probe confirmed the input path: +> **23 distinct `BTN_*` codes** (A/B/X/Y, TL/TR, SELECT/START/MODE, THUMBL, all 4 DPAD, grips, back +> codes) toggled through `hid-steam → evdev`. ✅ bind ✅ dual evdev incl. IMU ✅ report-parse path. +> Outstanding: (4) recognition by a **running Steam** client (needs a box with Steam — untestable +> here); and the exact per-bit button/stick/pad/IMU offsets (M1, line-checked vs the lab kernel — +> the v6.12-sourced `byte 8 bit 7 = BTN_A` did not hold on 7.0). The serial GET_REPORT reply also +> needs its report-number-prefix offset fixed (the kernel used the `XXXXXXXXXX` fallback; non-fatal). + +## 1. Goal + scope + +Carry the **full** Steam Controller / Steam Deck input surface end-to-end and let a remote +host present a **real virtual Steam device** that Steam Input and games bind as genuine: + +- the 4 back grips (L4/L5/R4/R5), +- both capacitive **trackpads** (the Deck's L/R pads, the SC's dual pads) with touch + click + + pressure, +- the **IMU** (3-axis gyro + 3-axis accel), +- the Steam/quick-access (`…`/QAM) buttons, +- haptics/rumble back-channel (Deck rumble motors; SC trackpad voice-coils). + +**Locked decisions (2026-06-29):** + +1. **Full pipeline** — capture on every client + inject on the hosts, not a one-platform demo. +2. **Disable-Steam-Input UX** for the Deck-in-Game-Mode capture wall (§6) — we own the + instruction and a best-effort programmatic flip; the manual toggle is the source of truth. +3. **Max fidelity** — build the greenfield virtual `hid-steam` driver. **Linux UHID first** + (validates the contract against open-source `hid-steam.c` + SDL hidapi); **Windows UMDF + later**, gated on the Linux result (§8). +4. The virtual **DualSense remap is the proven fallback** wherever a virtual Steam device is + unavailable or undesired (§7), so Steam-only inputs are *never silently dropped*. + +This is the same architectural bet as the virtual DualSense: the rich semantics +(adaptive triggers there; back grips + trackpads + gyro **bindings/glyphs** here) only +materialize end-to-end if the **game/Steam sees a real device** and therefore drives them. A +generic Xbox pad makes the game take its Xbox code path and the rich surface never exists. + +The unique value of a virtual Steam device is realized **only when the host runs Steam Input**, +which re-grabs the `28DE` device and re-emits it as `28DE:11FF` with the user's per-game +bindings and correct glyphs. Off-Steam, we fall back to DualSense (§7). + +## 2. The two walls + the honest fidelity ceiling + +There are exactly two hard problems. Everything else is plumbing we have already shipped twice +(DualSense, DS4). + +### Wall A — Steam Input capture ownership (client side, solvable via UX) + +On the **client**, enabling SDL's `SDL_JOYSTICK_HIDAPI_STEAMDECK`/`HIDAPI_STEAM` drivers makes +SDL open the raw `28DE:1205` and expose paddles + trackpads + gyro as a first-class SDL +gamepad (the inputtino/Sunshine path). **But in Deck Gaming Mode, Steam Input grabs the device +exclusively** and re-presents it as a virtual XInput pad — so SDL's HIDAPI driver cannot open +the raw device and the rich controls silently vanish. **This is inherent**: Steam owns the +device. The only escape is the locked Disable-Steam-Input-per-title decision. Outside Game Mode +(desktop client, or a Deck used as a streaming *target*), the hints just work. + +### Wall B — virtual-Steam-Controller recognition (host side, art-pushing) + +On the **host**, no public project emulates a virtual `hid-steam` device (inputtino does +`hid-playstation`/Xbox/Switch, **not** Steam). Two unknowns stack: + +- **Linux (lower risk):** the kernel bind path is well-mapped from `drivers/hid/hid-steam.c` + (open source) — match by VID/PID over `BUS_USB`, answer the probe feature handshake, stream + the 64-byte state report. This is provable against open source. *M0 proves it.* +- **Windows (higher risk):** Steam's **closed** userspace driver must accept the same contract + over a UMDF-presented HID interface, and SDL #12166 shows Steam/SDL **aborts** the controller + if its `0x83 GET_ATTRIBUTES_VALUES` / `0xA1 GET_DEVICE_INFO` feature probes fail. Those reply + blobs are **not derivable — they must be captured from real hardware**. Deferred to §8. + +### The fidelity ceiling — what is inherent vs solvable + +| Capability | Status | +|---|---| +| Buttons, dual sticks, analog triggers, dpad | Solvable — maps 1:1 from the existing Xbox-style frame | +| Back grips L4/L5/R4/R5 | Solvable on the virtual Steam pad + uinput Xbox (`BTN_TRIGGER_HAPPY*`); **lost on DualSense/DS4 fallback** (no back-button HID slot — remapped or dropped, documented) | +| Dual trackpads (touch/click/pressure) | Solvable on the virtual Steam pad; collapses to **one** surface on a DualSense fallback target | +| Gyro/accel (IMU) | Solvable — already carried by `RichInput::Motion`; **no IMU on an Xbox fallback** (xpad has none) | +| Rich semantics + glyphs in games | **Inherently requires Steam Input running on the host** to re-grab + rebind | +| Deck-in-Game-Mode capture | **Inherently requires disabling Steam Input** for the punktfunk title | +| Trackpad voice-coil haptics (SC) | Collapsed to the universal rumble plane unless a client renders localized haptics | +| Adaptive triggers / lightbar | **N/A** — Steam devices have none | + +## 3. Architecture overview (capture → protocol → inject) + +``` + CLIENT (SDL3 / GameController / NDK) HOST (punktfunk1.rs PadBackend) + ┌──────────────────────────────┐ ┌────────────────────────────────────────┐ + │ buttons+sticks+triggers ────┼── 0xC8 ────────►│ SteamControllerManager (Linux UHID) │ + │ back grips L4/L5/R4/R5 ────┼── 0xC8 bits ───►│ → serialize_deck_state → /dev/uhid │ + │ trackpads (2 surfaces) ────┼── 0xCC 0x03 ───►│ → kernel hid-steam binds 28DE:1205 │ + │ gyro+accel (IMU) ────┼── 0xCC 0x02 ───►│ → gamepad evdev + IMU evdev │ + │ GamepadPref=SteamDeck ────┼── Hello byte ──►│ → Steam Input re-emits 28DE:11FF │ + └──────────────────────────────┘ │ │ + ▲ rumble 0xCA / haptic 0xCD 0x04 │ parse_steam_output ◄── UHID_SET_REPORT │ + └──────────────────────────────────────────┤ 0xEB rumble / 0x8F pad-haptic │ + │ │ + │ FALLBACK (target = DualSense/DS4/Xbox): │ + │ inject/proto/steam_remap.rs │ + │ gyro→Motion, pads→touchpad/stick/mouse │ + │ grips→BTN_PADDLE→BTN_TRIGGER_HAPPY │ + └────────────────────────────────────────┘ +``` + +The **centerpiece** is the virtual `hid-steam` UHID device (§4). Gamepads are **not** on the +compositor injector path — they are owned by a per-session `PadBackend` created in +`PadBackend::select` and torn down (RAII) when the session input thread exits, identical to the +DualSense/DS4 lifecycle. So this drops in with **zero** changes to the injector, capture, or +audio planes; only `PadBackend` + `GamepadPref` + the protocol kinds grow. + +The **proven fallback** is the virtual DualSense remap: whenever the resolved backend is not a +real Steam device, `steam_remap.rs` folds the Steam-only inputs into whatever pad we do present +so nothing is silently lost. + +## 4. The virtual `hid-steam` driver (Linux UHID first) + +The mechanism is the **exact analogue** of the shipped virtual DualSense +(`inject/linux/dualsense.rs` + `inject/proto/dualsense_proto.rs`), with three Steam-specific +deltas: the **bind identity**, the **feature SET_REPORT handshake** (the DualSense backend only +handles GET_REPORT — the Steam path MUST also service SET_REPORT or stall), and the +**unnumbered (report-id-0) raw 64-byte framing**. + +### 4.1 Binding mechanism + +`hid-steam` matches **purely by VID/PID over `BUS_USB`**: +`HID_USB_DEVICE(0x28DE, 0x1205, STEAM_QUIRK_DECK)`. A `UHID_CREATE2` device with `bus = +BUS_USB (0x03)`, `vendor = 0x28DE`, `product = 0x1205` is bound and `steam_probe` runs. `hid-steam` +is a **raw-event driver** (`steam_raw_event` returns "handled" and bypasses HID field parsing), +so the report descriptor is almost cosmetic — **except** `steam_probe` requires `hid_parse` to +succeed *and* a **non-empty FEATURE report list**, so the descriptor MUST declare ≥1 feature +report at report id 0. + +**Recommend the Deck (`0x1205`), not the classic SC (`0x1102`), for M0.** The Deck has standard +dual sticks + dual analog triggers + 4 back grips + IMU, all of which map cleanly from the +existing Xbox-style client frame + `Motion`/`TouchpadEx` planes. The classic SC has **no right +stick** (dual trackpads) and trackpad-voice-coil-only haptics — awkward to synthesize. Deck is +also what the locked Game-Mode UX targets. SC (`0x1102`, report id 1) is a later identity behind +the same manager. + +**Reports are UNNUMBERED (report id 0).** `steam_send_report`/`steam_recv_report` call +`hid_hw_raw_request(hdev, 0, …)`; interrupt-in payloads have **no report-id prefix**. So +`data[0]` is the protocol constant `0x01`, `data[1] = 0x00`, `data[2] = 0x09`. A *numbered* +descriptor would shift the whole frame one byte and `data[0] != 1` would drop every report. + +### 4.2 The feature-report probe contract (the load-bearing delta from DualSense) + +During probe, `steam_register → steam_get_serial()` sends command `0xAE` +(`ID_GET_STRING_ATTRIBUTE`) as a **feature SET_REPORT**, then **blocks on a GET_REPORT** for the +reply. On UHID these arrive as `UHID_SET_REPORT` (type 13) and `UHID_GET_REPORT` (type 9). The +service loop MUST handle **three** event types (vs the DualSense's two): + +| Event | Reply | Notes | +|---|---|---| +| `UHID_GET_REPORT` (9) | `UHID_GET_REPORT_REPLY` (10) | serial blob `[0xAE, attrib=0x01, len, ascii…]`, or `err=EIO` | +| `UHID_SET_REPORT` (13) | `UHID_SET_REPORT_REPLY` (14), `err=0` **always** | **ignore → kernel stalls ~5 s/command**; parse `id u32@[4..8]`, `rnum@[8]`, `rsize u16@[9..11]`, `data@[11..]` | +| `UHID_OUTPUT` (6) | parse if present | feedback path | + +Command IDs the device must **ack** (`err=0`) and may parse: + +- `0xAE` `ID_GET_STRING_ATTRIBUTE` (serial) — **NON-FATAL**: the kernel falls back to a fake + serial `"XXXXXXXXXX"` and continues either way, so even an EIO reply yields a working device. + Answering keeps probe instant. +- `0x81` `ID_CLEAR_DIGITAL_MAPPINGS` / `0x8E` `ID_LOAD_DEFAULT_SETTINGS` / `0x87` + `ID_SET_SETTINGS_VALUES` (lizard-mode + settings) — **ack err=0, ignore content**. The Deck + path **skips** the auto lizard-mode disable at `input_open`, so these only arrive on an + options-hold or via Steam userspace — but must be ack'd to avoid the per-command stall. +- `0x83` `ID_GET_ATTRIBUTES_VALUES` / `0xA1` `ID_GET_DEVICE_INFO` — **not** issued by the kernel + Deck probe, but Steam userspace queries them for full Steam Input fidelity (gyro/back + buttons). For M0 bind they are unnecessary; for full fidelity (M3+) answer with + real-hardware-captured blobs. + +### 4.3 Input-report layout (Deck `ID_CONTROLLER_DECK_STATE`, msg `0x09`) + +64-byte **unnumbered** report, little-endian. `steam_raw_event` drops anything where +`size != 64 || data[0] != 1 || data[1] != 0`, then `switch(data[2])`. + +``` +[0] 0x01 protocol constant (REQUIRED ==1) +[1] 0x00 protocol constant (REQUIRED ==0) +[2] 0x09 ID_CONTROLLER_DECK_STATE (0x01 = ID_CONTROLLER_STATE for the SC) +[3] len payload length, kernel ignores (set ~0x3C) +[4..8] u32 LE frame/sequence counter (monotonic) +[8] buttons b8 {A,X,B,Y, L1,R1, L2-full,R2-full} +[9] buttons b9 {DPAD_U,DPAD_R,DPAD_L,DPAD_D, view, steam, menu, GRIPL2(L5)} +[10] buttons b10 {GRIPR2(R5), lpad_touch, rpad_touch, L3, R3, …} +[11..13] b11/b12 reserved/touch + THUMBR alt +[13] buttons b13 GRIPL(L4)@bit1, GRIPR(R4)@bit2 +[14] buttons b14 BTN_BASE (quick-access)@bit2 +[16..24] s16 x4 LE LEFT pad X/Y, RIGHT pad X/Y → ABS_HAT0X/Y, ABS_HAT1X/Y (res ~1638) +[24..36] s16 x6 LE accel X, accel Z(neg), accel Y, gyro X, gyro Z(neg), gyro Y + → IMU ABS_X/Y/Z + ABS_RX/RY/RZ +[36..44] s16 x4 LE orientation quaternion (optional) +[44..48] u16 x2 LE LEFT trigger, RIGHT trigger → ABS_HAT2Y / ABS_HAT2X +[48..56] s16 x4 LE LEFT stick X/Y(neg), RIGHT stick X/Y(neg) → ABS_X/Y, ABS_RX/RY +[56..60] u16 x2 LE LEFT/RIGHT pad pressure +[60..64] reserved +``` + +**Neutral state:** sticks/pads/triggers = `0x0000` (signed-centered at 0 — note this differs +from the DualSense's `0x80` stick centers); all button bytes 0. On bind the kernel exposes +**two** evdevs: a Deck gamepad (`BTN_A`..`BTN_GRIPL/R` + `GRIPL2/R2`, `BTN_BASE`, ABS +sticks/triggers/pads) **and** a separate IMU evdev (`INPUT_PROP_ACCELEROMETER`). + +> **The exact per-byte button bit masks and the 0xEB rumble offsets in this table are +> summarized from secondary parsing and MUST be confirmed line-by-line against +> `steam_do_deck_input_event` / `steam_haptic_rumble` in the lab kernel before trusting input +> fidelity.** The backend logs a first-frame layout dump (the DS4 pattern) to catch slips. + +### 4.4 Feedback surface + +Simpler than the DualSense — no lightbar / player LEDs / adaptive triggers. Feedback arrives as +a feature **SET_REPORT** (type 13, ack `err=0`), not a `UHID_OUTPUT` interrupt: + +- `0xEB` `ID_TRIGGER_RUMBLE_CMD` — Deck rumble motors; map left/right → `(low, high)` on the + existing universal **0xCA** rumble plane (exactly like the DualSense `fb.rumble`). +- `0x8F` `ID_TRIGGER_HAPTIC_PULSE` — the SC's two trackpad voice-coils (pad 0=left/1=right/2=both, + duration/interval/count). Niche on the Deck; for M0 ack-and-fold into 0xCA (left-pad→low, + right-pad→high). For clients with localized haptics, surface as the new `0xCD 0x04` + `TrackpadHaptic` (§5). + +No `0xCD` HID-output plane is otherwise needed for the Deck. + +### 4.5 New Linux modules (mirror the DualSense trio) + +- `crates/punktfunk-host/src/inject/proto/steam_proto.rs` — transport-independent contract: + `STEAM_VENDOR=0x28DE`; `SteamModel{ Deck=0x1205 rid 9, Controller=0x1102 rid 1 }`; the verbatim + `STEAMDECK_RDESC` (≥1 feature report at id 0); `SteamState` superset model (sticks, analog + triggers, packed buttons, dpad, `gyro[3]`/`accel[3]`, `back:[bool;4]`, two `SteamPad{active, + click, x:i16, y:i16, pressure:u16}` surfaces, steam/quickaccess); `SteamState::from_gamepad` + (the XInput mapper + the new paddle/misc wire bits); `serialize_deck_state`/`serialize_sc_state` + (byte-exact); `feature_reply(rnum)`; `parse_steam_output(data, &mut SteamFeedback)`. Unit tests + for offsets + output parsing, mirroring `dualsense_proto`'s tests. +- `crates/punktfunk-host/src/inject/linux/steam_controller.rs` — `/dev/uhid` plumbing + + `SteamControllerManager`, byte-identical structure to `dualsense.rs`: `SteamPad::open(index, + model)` does `UHID_CREATE2`; `write_state → UHID_INPUT2`; `service()` answers GET_REPORT + + SET_REPORT + OUTPUT; `Drop → UHID_DESTROY`; `handle`/`apply_rich`/`pump`/`heartbeat(8 ms)`. The + 8 ms heartbeat re-emits the last report — a real Deck streams continuously and multi-second + silence reads as a disconnect to SDL/Steam. + +Needs `/dev/uhid` writable (the existing `60-punktfunk.rules` udev rule + `input` group, same as +DualSense) and `hid-steam` present/loaded (`modprobe hid-steam`; mainline module). + +## 5. Protocol / ABI changes (exact wire/constants) + +Strictly additive and forward-compatible — everything rides existing tags (`0xC8` buttons, +`0xCC` rich input, `0xCD` HID-out, the `GamepadPref` Hello/Welcome byte). Unknown kinds/bits drop +on old peers exactly as today. + +### 5.1 Back-button bits (`input.rs::gamepad`, ride `0xC8`, no length change) + +We align to Moonlight's `buttonFlags2 << 16` namespace so the GameStream paddle path and the +native path share one injector map. The classic-paddle slots are GameStream-aligned; the four +Steam grips sit just above the touchpad bit: + +``` +BTN_PADDLE1 = 0x0001_0000 (R4 / SDL RightPaddle1 / GameStream PADDLE1) +BTN_PADDLE2 = 0x0002_0000 (L4 / SDL LeftPaddle1 / GameStream PADDLE2) +BTN_PADDLE3 = 0x0004_0000 (R5 / SDL RightPaddle2 / GameStream PADDLE3) +BTN_PADDLE4 = 0x0008_0000 (L5 / SDL LeftPaddle2 / GameStream PADDLE4) +BTN_TOUCHPAD= 0x0010_0000 (already present, = TOUCHPAD_FLAG << 16) +BTN_MISC1 = 0x0020_0000 (Deck '…'/QAM, Share/Capture / GameStream MISC) +``` + +> **Decision (resolves the placement open-question):** native back buttons **reuse the +> GameStream paddle bits** (`0x0001_0000..0x0008_0000`) rather than a separate `0x40_0000+` +> range. This unifies the GameStream-paddle and native-grip injector maps onto one table, and +> Xbox Elite paddles map for free. Steam L4/L5/R4/R5 ↔ Xbox P1–P4 is a semantic 1:1 for binding +> purposes; the device identity carries the glyph distinction. + +### 5.2 `RichInput::TouchpadEx` (kind `0x03`, rides `0xCC`, client→host) + +`0x01 Touchpad` (DualSense single contact) is kept forever. The new superset carries the second +pad + click + pressure with **signed** coords matching the real Steam report: + +``` +[0xCC][0x03][pad u8][surface u8][finger u8][state u8][x i16 LE][y i16 LE][pressure u16 LE] // 12 B + surface : 0 = single/DS touchpad, 1 = Steam left pad, 2 = Steam right pad + state : bit0 = touch (capacitive contact), bit1 = click (pad depressed) + pressure: 0 if the surface has no sensor +``` + +Decode gated `b.len() >= 12`; unknown kind → `None`. New clients emit `TouchpadEx` for all touch +surfaces; the host decodes both `0x01` and `0x03` indefinitely (no flag day). Every existing +manager (`dualsense.rs`, `dualshock4.rs`, `dualsense_windows.rs`) gains a `TouchpadEx` arm +(treat surface 0/2 → contact, ignore 1) so the new variant compiles everywhere. + +### 5.3 `HidOutput::TrackpadHaptic` (kind `0x04`, rides `0xCD`, host→client) + +``` +[0xCD][0x04][pad u8][side u8][amplitude u16 LE][period u16 LE µs][count u16 LE] // 10 B +``` + +Decode gated `b.len() >= 10`. **The ABI `PunktfunkHidOutput` struct is NOT grown** (it has no +`struct_size` guard — growing it would overwrite old-built caller buffers): the new kind reuses +existing fields — `which = side`, `amplitude/period/count` packed LE into `effect[0..6]` with +`effect_len = 6`. Clients without coils drop it (or optionally map to rumble). + +### 5.4 `GamepadPref` source hints + +Trailing fwd-compat Hello/Welcome byte (unknown → `Auto`): + +``` +5 = SteamController (steam | steamcontroller | sc) +6 = SteamDeck (steamdeck | deck | sd) +``` + +These are **source hints** (what the physical client controller is) so the host prefers the +virtual `hid-steam` backend + the right glyph identity; honored only where the backend exists +(Linux UHID first), else degraded — the Welcome echoes the **real** resolved backend (honest +downgrade). Clients auto-resolve from VID/PID (§6), like DS5→DualSense. + +### 5.5 ABI surface (`abi.rs` + regenerate `include/punktfunk_core.h`) + +- New constants: `PUNKTFUNK_RICH_TOUCHPAD_EX=3`, `PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC=4`, + `PUNKTFUNK_GAMEPAD_STEAMCONTROLLER=5`, `PUNKTFUNK_GAMEPAD_STEAMDECK=6`, and + `PUNKTFUNK_GAMEPAD_BTN_PADDLE1..4` / `_BTN_MISC1` for embedders building raw `InputEvent`s. +- **Do not mutate `PunktfunkRichInput`** (no `struct_size` guard). Add a guarded superset + `PunktfunkRichInputEx{ u32 struct_size; u8 kind,pad,finger,active,surface,click; u8 _pad[2]; + i16 x,y; u16 pressure; i16 gyro[3]; i16 accel[3]; }` + `punktfunk_connection_send_rich_input2` + that reads the size prefix first (the `connect_exN`/`config_from_ptr` precedent). Legacy + `PunktfunkRichInput` + `send_rich_input` stay byte-for-byte. +- `from_hid` gains the `TrackpadHaptic → effect[]`-packing arm; `to_rich` (Ex) gains the + `TouchpadEx` arm. +- Compile guards: extend the `GamepadPref` lockstep `assert!` block with the two new variants; + add `assert!(size_of::()==20)` + `assert!(size_of::() + ==19)` so the additive changes can never silently shift the legacy layouts. CI fails on header + drift. + +### 5.6 GameStream host-map fix (no protocol change) + +`gamestream/gamepad.rs:91` already computes `buttons = buttonFlags | (buttonFlags2 << 16)`, but +the xpad `BUTTON_MAP` drops every bit above `0x8000`, so Moonlight paddle/Share clients are +silently no-op'd today. Name the already-decoded bits (`PADDLE1=0x0001_0000 … PADDLE4= +0x0008_0000`, `TOUCHPAD=0x0010_0000`, `MISC=0x0020_0000`) and add them to the injector map so +the **native** and **GameStream** back-button paths drive one unified output. This is a behavior +change for existing Moonlight users — needs a live regression check (§10). + +## 6. Client capture + +### sdl3-rs 0.18.4 API-coverage gate — RESOLVED (was the top client risk) + +Independently verified against docs.rs for `sdl3` 0.18.4 (2026-06-29), so **no raw `sdl3-sys` +fallback is needed**: `Button::{LeftPaddle1,RightPaddle1,LeftPaddle2,RightPaddle2,Misc1..6, +Touchpad}` exist (confirmed); `Gamepad::touchpads_count()` + `supported_touchpad_fingers()` exist +(confirmed); `vendor_id()`/`product_id()` exist (confirmed); `Event::ControllerTouchpad +{Down,Motion,Up}` carries the `touchpad` **surface** field the current code discards. **Sensor +capture (gyro/accel) is already proven in-tree** — the shipping client enables and reads SDL +sensors for the DualSense today (`set_sensors` in `clients/linux/src/gamepad.rs`), so +genericizing it past the DualSense gate is a one-line change, not a new dependency. The hint +strings `SDL_JOYSTICK_HIDAPI_STEAMDECK` / `SDL_JOYSTICK_HIDAPI_STEAM` live in `sdl3-sys`. There is +**no `SDL_GAMEPAD_TYPE_STEAM_DECK`** — detect by VID `0x28DE`. + +### Linux + Windows SDL clients (near-verbatim ports — `clients/{linux,windows}/src/gamepad.rs`) + +1. **Before `sdl3::init()`**, set `SDL_JOYSTICK_HIDAPI_STEAMDECK="1"` + `SDL_JOYSTICK_HIDAPI_STEAM + ="1"` (the exact `sdl3::hint::set` mechanism already used for `SDL_NO_SIGNAL_HANDLERS`). +2. In `pad_info`, override to `GamepadPref::SteamDeck` when `vendor_id()==0x28DE` and + `product_id() ∈ {0x1205 Deck, 0x1102 SC wired, 0x1142 SC dongle}`; add a `"Steam Deck"` label. +3. Extend `button_bit`: `RightPaddle1→BTN_PADDLE1`, `LeftPaddle1→BTN_PADDLE2`, + `RightPaddle2→BTN_PADDLE3`, `LeftPaddle2→BTN_PADDLE4`, `Misc1→BTN_MISC1` (free win for Elite). +4. Bind the `touchpad` surface field in the three `ControllerTouchpad*` arms; **branch on + `touchpads_count()`** rather than hard-coding 2 — surface 0 → existing `RichInput::Touchpad`, + surface ≥1 → `RichInput::TouchpadEx{surface}`. +5. Track held contacts keyed by `(surface, finger)` and lift them (`active=false`) in + `flush_held` on pad switch/detach (today only the DualSense single surface is implicitly lifted). +6. Sensor capture is **already generic** — only the DualSense-only doc comments change. + +### Disable-Steam-Input UX (Decky + docs) + +- **Decky** (`clients/decky/`): a Settings toggle "Capture Steam Deck controls (paddles · + trackpads · gyro)" that selects `gamepad pref=steamdeck`, adds `"steamdeck"` to the option set + (TS + `main.py` validation), and renders an **unmissable inline instruction**: gamescope game + page → gear → Controller → Steam Input → **Off** for the punktfunk shortcut. +- **Best-effort programmatic flip** (`clients/decky/src/steam.ts`): + `disableSteamInputForShortcut(appId)` — feature-detected at runtime (guarded exactly like the + existing optional `window.DeckyBackend`/`collectionStore` globals), called best-effort inside + `ensureShortcut()`, **never** blocking or throwing into `launchStream`. **The manual toggle is + the documented source of truth** — there is no confirmed stable SteamClient API and it may + regress across Steam updates. + +### Apple / Android — honest no-code-now scope + +- **Apple:** parity only — add a `.steamDeck` enum case (wire byte 6) so the type round-trips; + **no capture**. GameController never surfaces a `28DE` HID device as a `GCExtendedGamepad` + (Apple has no Steam Input; a raw path would need `IOHIDManager`). Document as blocked. +- **Android:** parity only — add `PREF_STEAMDECK` + the `28DE` PIDs to the mapping. Capture of + paddles/trackpads/gyro is **out of scope** here: `send_rich_input` is itself still a TODO + (`session.rs:13`), and a Deck dongle appears only as a generic gamepad via `InputDevice`. + Revisit after the rich-input port lands. + +## 7. Host inject / mapping + host-integration semantics + +### PadBackend wiring + +`enum PadBackend` (`punktfunk1.rs`) gains `#[cfg(target_os="linux")] SteamController +(SteamControllerManager)` and `SteamDeck(SteamControllerManager)` — **one manager, a `SteamModel` +field** (sticks/descriptor/report differ, logic is shared; the DS4-reuses-DualSense pattern). +Wire all five arms (`select`/`handle`/`apply_rich`/`pump`/`heartbeat`). `pick_gamepad` / +`resolve_gamepad` gain `GamepadPref::SteamController|SteamDeck if linux` arms; **on Windows and +elsewhere they fold to Xbox360** until the UMDF driver lands (§8). + +### Selection / resolution policy (the load-bearing part) + +Unlike the DualSense, a virtual Steam pad only pays off under specific host conditions, so +resolution is gated. Highest priority first: + +1. **Explicit client `SteamController`/`SteamDeck` pref** — honored if `/dev/uhid` is writable + AND `hid-steam` is loadable; else degrade. +2. **`PUNKTFUNK_GAMEPAD=steamdeck|steamcontroller`** host env. +3. **Auto** — resolve to a Steam pad **only when the host is running Steam Input** (so the rich + semantics are actually consumed). Otherwise Auto prefers **DualSense** (broader non-Steam SDL + surface: gyro + a real touchpad) over a Steam pad whose trackpads/grips a non-Steam game + won't understand. +4. **Degrade ladder** when a requested Steam pad is unavailable: `hid-steam` missing → + **DualSense** → **Xbox360**. The Welcome carries the real choice. + +> **SteamOS/Deck-as-host conflict:** a host already running Steam eagerly grabs **any** `28DE` +> device, so our virtual pad could be double-handled alongside the operator's physical Deck +> controller. **Default policy: gate Steam pads OFF on a SteamOS/gamescope host** unless +> explicitly forced (M6 confirms). + +### Fallback remap (`inject/proto/steam_remap.rs`, pure + unit-testable) + +When the resolved backend is DualSense/DS4/Xbox, fold the Steam-only inputs in so nothing drops: + +- **Gyro/accel** → `RichInput::Motion` (native on DualSense/DS4; no-op on Xbox — xpad has no IMU). +- **Right trackpad** → DualSense/DS4 touchpad contact (1:1 absolute surface); on an Xbox target, + optionally synthesized to the right stick behind a config toggle. +- **Left trackpad** → left stick or relative mouse via the existing `InjectorService` pointer + plane (config; default mouse, matching SteamOS desktop feel). +- **Back buttons L4/L5/R4/R5** → `BTN_PADDLE1..4` → on a uinput Xbox pad, `BTN_TRIGGER_HAPPY1..4` + (`0x2c0..0x2c3`, what Steam Input/SDL read as paddles); add the matching `UI_SET_KEYBIT` + registrations in `create()`. **DS4/DualSense have no back-button HID slot** — paddles fall back + to a configurable default (e.g. L4→L3, R4→R3) or are dropped, **documented, not silent**. +- **DS4 100 ms motion-timestamp keepalive** applies whenever motion is forwarded onto a DS4 + target — keep `apply_rich`/`heartbeat` flowing so the sensor `ts += 188` advances, or games + reject motion as stale. + +A `RemapConfig` (env/config driven, e.g. `PUNKTFUNK_STEAM_REMAP=…`) holds the trackpad/back-button +policy knobs. + +## 8. Windows UMDF — a later, gated phase + +**Do not start until the Linux UHID device binds.** Linux proves the report descriptor + feature +blobs + state layout against open-source `hid-steam.c` + SDL hidapi; Windows then only adds the +unknown of **Steam's closed userspace driver** accepting the same contract over UMDF. + +Reuse the **entire** proven `pf-dualsense` UMDF path (the repo already proved a self-signed Rust +UMDF HID minidriver loads under Secure Boot ON and is recognized as a genuine controller): + +- Fork `packaging/windows/drivers/pf-dualsense` → `pf-steamdeck`. Keep verbatim the + `vhidmini2`-derived WDF scaffolding, the **FORCE_INTEGRITY PE-bit clear** (PE+0x5e), the + timer-completes-pended-READ_REPORT pattern, the queue `NumberOfPresentedRequests=u32::MAX` and + timer `ExecutionLevel/SynchronizationScope=InheritFromParent + AutomaticSerialization=TRUE` + gotchas, the `Global\pfds-shm-` shared-memory channel, the multi-pad + `pszDeviceLocation`/`UmdfHostProcessSharing=ProcessSharingDisabled` plumbing, the + `Include=MsHidUmdf.inf`/`WUDFRD.inf` INF stanza, and `SwDeviceCreate` (enumerator `punktfunk`, + hardware id `pf_steamdeck`). +- Swap identity (VID `0x28DE` / PID `0x1205`), the hid-steam report descriptor, and — the + **riskiest, non-derivable** part — the `0x83 GET_ATTRIBUTES_VALUES` + `0xA1 GET_DEVICE_INFO` + feature blobs **captured from real hardware** (SDL #12166: Steam/SDL aborts the controller if + these probes fail). Add ACK-only SET_FEATURE handlers for `0x81`/`0x87`/`0x8E`. +- Host backend: a `SteamDeckWindows` reusing `inject/windows/dualsense_windows.rs` almost + wholesale (SwDeviceCreate, map the section, pack the 64-byte state, read the haptic output slot). +- Bundle `pf_steamdeck.{inf,cat,dll}` into the existing Inno installer + `install-gamepad-drivers + .ps1` pnputil flow, identical to pf-dualsense/DS4/XUSB. + +**NEVER emulate `28DE:11FF`** — that is Steam's own emulated *output* pad, not an input device; +emulating it risks a feedback loop where Steam ingests its own output. Watch for: Steam requiring +a USB instance path a SwDevice lacks; Steam wanting the sibling emulated keyboard/mouse +collections present; VAC/device-trust rejection of a self-signed virtual Steam Controller; and +gating the Deck PID (`0x1205`) on Deck hardware (wired-SC `0x1102` may be the safer desktop +identity). + +## 9. Milestone plan (M0 is the go/no-go) + +See the structured `milestones`. The shape mirrors the DualSense effort: an **M0 feasibility +gate** answers the recognition question before any pipeline is built. M1–M3 are Linux. M4–M5 are +clients + protocol. M6 is the SteamOS-host conflict check. M7+ is the deferred Windows UMDF +phase, itself re-gated on its own recognition spike. + +## 10. Risks, open questions, validation + +### Validation / test plan + +**Loopback (no hardware):** +- Core: `RichInput::TouchpadEx` + `HidOutput::TrackpadHaptic` + `GamepadPref` 5/6 encode/decode + round-trips + an old-peer-drops-unknown-kind assertion; the `from_gamepad` paddle/misc mapping; + `steam_proto` report-offset + `parse_steam_output` unit tests (mirror `dualsense_proto`); the + `steam_remap` fold policy. +- `pick_gamepad`/`resolve_gamepad`: client SteamDeck + hid-steam present + Steam → SteamDeck; + client SteamDeck + no module → DualSense in the Welcome; Auto + no Steam → DualSense; + `PUNKTFUNK_GAMEPAD=steamdeck` forces it; Windows folds to Xbox360. Assert the Welcome echoes + the real choice each time. + +**On-box (the box has `hid-steam` mainline + the udev rule):** +- **BIND proof (Steam NOT running):** a tiny test main creates the device + heartbeats neutral. + Confirm `dmesg` shows `hid-steam … Valve Software Steam Deck Controller`; the sysfs node binds + `hid_steam`; a gamepad evdev AND a second IMU evdev appear (`udevadm info` → + `ID_INPUT_JOYSTICK=1` + `INPUT_PROP_ACCELEROMETER`). +- **RECOGNITION proof:** `sdl2-jstest --list` / an SDL3 app reports GUID `28de:1205` "Steam + Deck"; `evtest` shows `BTN_SOUTH` etc.; toggle the A bit and watch the key event. +- **STEAM proof (Steam running on the host):** Settings → Controller shows a "Steam Deck + Controller"; the kernel evdev disappears (the `client_opened` standoff is expected); bind a + back grip to a key in Steam Input and confirm a non-Steam test game sees it. +- **RUMBLE proof:** `fftest` / a game triggers `FF_RUMBLE`; confirm a `0xEB` SET_REPORT arrives + and our parser emits `(low, high)` on `0xCA` back to the client. +- **Cross-machine:** the Linux client (paddles + both pads + gyro) over the LAN → the virtual + Deck on the host → Steam re-emits `28DE:11FF` with working bindings + glyphs. +- **GameStream regression:** confirm the new `buttonFlags2` consumption doesn't emit spurious + back-grip/record events for a stock Moonlight client with a normal pad. + +(Full risks + open questions in the structured fields.) +