feat(host/steam): M1 — byte-exact Deck input serializer, on-box validated
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user