feat(host/steam): M0 — virtual hid-steam UHID device binds + parses (Linux)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 10:52:53 +00:00
parent 7cd9364c9e
commit 2b47d8cc28
3 changed files with 902 additions and 0 deletions
@@ -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)");
}