From 567b0d70e9127b8443a508c73df839f670b70f9b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 29 Jun 2026 10:52:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(host/steam):=20M0=20=E2=80=94=20virtual=20?= =?UTF-8?q?hid-steam=20UHID=20device=20binds=20+=20parses=20(Linux)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenfield virtual Steam Deck controller, the Steam analogue of the shipped virtual DualSense. Proves the kernel hid-steam driver binds a /dev/uhid 28DE:1205 device, registers it as a real Steam Deck, and parses our input reports — the go/no-go gate for the full Steam Controller/Deck pipeline. - inject/proto/steam_proto.rs: keeper module — the vendor HID descriptor (one feature report, the sole thing steam_is_valve_interface() checks), the command/feature IDs, serialize_deck_state, and the serial GET_REPORT reply. Unit-tested. - src/bin/steam_uhid_spike.rs: throwaway M0 spike (Linux-only) — opens /dev/uhid, creates the device, services the handshake including UHID_SET_REPORT (which the DualSense backend omits and which hid-steam stalls ~5s/cmd without), and heartbeats a neutral report. - design/steam-controller-deck-support.md: full design + M0–M7 plan; the two walls (Steam Input capture ownership; virtual-Steam recognition) and the fidelity ceiling. Status: M0 GREEN. On-box (headless Ubuntu 26.04, kernel 7.0, no Steam): journalctl -k shows hid-steam binding the device (rebind off hid-generic), "Steam Controller connected", and the kernel creating BOTH a "Steam Deck" gamepad evdev and a "Steam Deck Motion Sensors" IMU evdev (INPUT_PROP_ACCELEROMETER). A layout-agnostic mash-probe drove 23 distinct BTN_* codes through hid-steam -> evdev, proving the input-report parse path. M1 line-checks the exact per-bit report layout against the lab kernel. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/bin/steam_uhid_spike.rs | 192 ++++++++++++++++++ .../src/inject/proto/steam_proto.rs | 183 +++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 crates/punktfunk-host/src/bin/steam_uhid_spike.rs create mode 100644 crates/punktfunk-host/src/inject/proto/steam_proto.rs 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 00000000..90d21872 --- /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 00000000..3bd033fc --- /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"); + } +}