diff --git a/crates/punktfunk-host/src/bin/steam_uhid_spike.rs b/crates/punktfunk-host/src/bin/steam_uhid_spike.rs deleted file mode 100644 index 35ca721..0000000 --- a/crates/punktfunk-host/src/bin/steam_uhid_spike.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! M0/M1 on-box validator (THROWAWAY) — `design/steam-controller-deck-support.md`. -//! -//! 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]`. -//! -//! Deleted once M2'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::{ - 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): 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; - 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; - - // 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()) - .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?)")?; - - 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 (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()); - 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; pulsing mode-switch then holding test pattern ({seconds}s)" - ); - - let (mut sets, mut gets, mut outputs) = (0u32, 0u32, 0u32); - let mut seq: u32 = 0; - let start = Instant::now(); - let mut last_hb = start; - let mut rbuf = vec![0u8; EVENT_SIZE]; - - while start.elapsed() < Duration::from_secs(seconds) { - 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 | 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; - 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 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()); - 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("GET_REPORT_REPLY")?; - } - UHID_SET_REPORT => { - sets += 1; - let id = u32::from_ne_bytes([rbuf[4], rbuf[5], rbuf[6], rbuf[7]]); - // 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()); - 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}"), - } - } - - if last_hb.elapsed() >= Duration::from_millis(8) { - last_hb = Instant::now(); - seq = seq.wrapping_add(1); - // 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()); - out[6..6 + r.len()].copy_from_slice(&r); - fd.write_all(&out).context("UHID_INPUT2")?; - } - - 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: 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.rs b/crates/punktfunk-host/src/inject.rs index 45ce3f8..8ed2772 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -500,6 +500,10 @@ pub mod steam_controller; #[cfg(target_os = "linux")] #[path = "inject/proto/steam_proto.rs"] pub mod steam_proto; +/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale. +#[cfg(target_os = "linux")] +#[path = "inject/proto/steam_remap.rs"] +pub mod steam_remap; /// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere. #[cfg(not(any(target_os = "linux", target_os = "windows")))] pub mod gamepad { diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index 2fc4e67..2a798dd 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -182,6 +182,9 @@ pub struct DualSenseManager { last_write: Vec, /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. broken: bool, + /// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button + /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. + remap: crate::inject::steam_remap::RemapConfig, } impl Default for DualSenseManager { @@ -198,6 +201,7 @@ impl DualSenseManager { last_rumble: vec![(0, 0); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], broken: false, + remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -229,8 +233,12 @@ impl DualSenseManager { // Merge buttons/sticks/triggers from the frame, preserving touch + motion (those // come on the rich-input plane and must survive a button-only frame). let prev = self.state[idx]; + // Steam back grips have no DualSense slot — fold them onto standard buttons per the + // configured policy (default drop) so they aren't silently lost. + let buttons = + crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let mut s = DsState::from_gamepad( - f.buttons, + buttons, f.ls_x, f.ls_y, f.rs_x, diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index 77f0854..f602aee 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -367,6 +367,9 @@ pub struct DualShock4Manager { last_write: Vec, /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. broken: bool, + /// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID + /// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. + remap: crate::inject::steam_remap::RemapConfig, } impl Default for DualShock4Manager { @@ -384,6 +387,7 @@ impl DualShock4Manager { last_led: vec![None; MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], broken: false, + remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -416,8 +420,12 @@ impl DualShock4Manager { // Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the // rich-input plane and must survive a button-only frame). let prev = self.state[idx]; + // Steam back grips have no DS4 slot — fold them onto standard buttons per the + // configured policy (default drop) so they aren't silently lost. + let buttons = + crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let mut s = DsState::from_gamepad( - f.buttons, + buttons, f.ls_x, f.ls_y, f.rs_x, diff --git a/crates/punktfunk-host/src/inject/proto/steam_proto.rs b/crates/punktfunk-host/src/inject/proto/steam_proto.rs index dace47b..b903193 100644 --- a/crates/punktfunk-host/src/inject/proto/steam_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/steam_proto.rs @@ -245,8 +245,11 @@ impl SteamState { self.rpad_y = ((y as i32) - 32768) as i16; } RichInput::Motion { gyro, accel, .. } => { - self.gyro = gyro; - self.accel = accel; + // The wire carries DualSense-convention units (what every client capture emits); the + // Deck's hid-steam report wants 16 LSB/°·s + 16384 LSB/g, so rescale here. + let (g, a) = super::steam_remap::motion_wire_to_deck(gyro, accel); + self.gyro = g; + self.accel = a; } RichInput::TouchpadEx { surface, @@ -444,13 +447,15 @@ mod tests { assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); assert_eq!(s.rpad_x, 32767); // 65535-32768 assert_eq!(s.rpad_y, -32768); // 0-32768 + // Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20, + // accel ×16384/10000) — see steam_remap::motion_wire_to_deck. s.apply_rich(RichInput::Motion { pad: 0, - gyro: [1, 2, 3], - accel: [4, 5, 6], + gyro: [1000, -2000, 0], + accel: [10000, -5000, 0], }); - assert_eq!(s.gyro, [1, 2, 3]); - assert_eq!(s.accel, [4, 5, 6]); + assert_eq!(s.gyro, [800, -1600, 0]); + assert_eq!(s.accel, [16384, -8192, 0]); } /// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the diff --git a/crates/punktfunk-host/src/inject/proto/steam_remap.rs b/crates/punktfunk-host/src/inject/proto/steam_remap.rs new file mode 100644 index 0000000..6cb5ae9 --- /dev/null +++ b/crates/punktfunk-host/src/inject/proto/steam_remap.rs @@ -0,0 +1,149 @@ +//! Pure fallback-remap policy for the Steam Controller / Steam Deck rich inputs when the resolved +//! host backend is **not** the virtual `hid-steam` device (DualSense / DualShock 4 / Xbox), so a +//! client's Steam-only inputs aren't silently dropped — plus the cross-device motion rescale the +//! Deck backend itself needs. +//! +//! Driven by the host's `PUNKTFUNK_STEAM_REMAP` env (`key=value`, `,`/`;`-separated, e.g. +//! `paddles=stickclicks`). No I/O beyond [`RemapConfig::from_env`]; everything else is pure + +//! unit-testable. The uinput Xbox pad already exposes the back grips as Elite paddles +//! (`BTN_TRIGGER_HAPPY5-8`), so only the slot-less DualSense / DS4 backends fold them. + +use punktfunk_core::input::gamepad as gs; + +/// Where the four Steam back grips go on a backend with no native back-button HID slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PaddleFallback { + /// Drop them — the back buttons are simply absent on this pad. The honest default: don't fire + /// buttons the user didn't ask for. Set the env to map them instead. + #[default] + Drop, + /// L4/L5 → left-stick click, R4/R5 → right-stick click. + StickClicks, + /// L4/L5 → left bumper, R4/R5 → right bumper. + Shoulders, +} + +/// Fallback-remap knobs parsed from `PUNKTFUNK_STEAM_REMAP`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RemapConfig { + pub paddles: PaddleFallback, +} + +impl RemapConfig { + /// Parse the host's `PUNKTFUNK_STEAM_REMAP` env (absent / unrecognized → defaults). + pub fn from_env() -> RemapConfig { + std::env::var("PUNKTFUNK_STEAM_REMAP") + .map(|s| RemapConfig::parse(&s)) + .unwrap_or_default() + } + + /// Pure parse of the `key=value[,key=value…]` string (the testable core of [`from_env`]). + pub fn parse(s: &str) -> RemapConfig { + let mut cfg = RemapConfig::default(); + for kv in s.split([',', ';']) { + let mut it = kv.splitn(2, '='); + if let (Some(k), Some(v)) = (it.next(), it.next()) { + if k.trim().eq_ignore_ascii_case("paddles") { + cfg.paddles = match v.trim().to_ascii_lowercase().as_str() { + "stickclicks" | "l3r3" | "sticks" => PaddleFallback::StickClicks, + "shoulders" | "lbrb" | "bumpers" => PaddleFallback::Shoulders, + _ => PaddleFallback::Drop, + }; + } + } + } + cfg + } +} + +/// Fold the wire back-grip bits (`BTN_PADDLE1..4`) into standard buttons per `policy` for a pad with +/// no native back-button slot, clearing the paddle bits. Pure. PADDLE1/2/3/4 = R4/L4/R5/L5. +pub fn fold_paddles(mut buttons: u32, policy: PaddleFallback) -> u32 { + let left = buttons & (gs::BTN_PADDLE2 | gs::BTN_PADDLE4) != 0; // L4 | L5 + let right = buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE3) != 0; // R4 | R5 + buttons &= !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4); + let (lbit, rbit) = match policy { + PaddleFallback::Drop => return buttons, + PaddleFallback::StickClicks => (gs::BTN_LS_CLICK, gs::BTN_RS_CLICK), + PaddleFallback::Shoulders => (gs::BTN_LB, gs::BTN_RB), + }; + if left { + buttons |= lbit; + } + if right { + buttons |= rbit; + } + buttons +} + +// Motion rescale. The wire uses the DualSense convention (20 LSB/°·s gyro, 10000 LSB/g accel — the +// scale every client capture applies). The Steam Deck's `hid-steam` report wants 16 LSB/°·s and +// 16384 LSB/g, so the Deck backend rescales; the DualSense / DS4 backends consume the wire 1:1. +const GYRO_NUM: i32 = 16; +const GYRO_DEN: i32 = 20; +const ACCEL_NUM: i32 = 16384; +const ACCEL_DEN: i32 = 10000; + +fn scale(v: i16, num: i32, den: i32) -> i16 { + ((v as i32 * num) / den).clamp(i16::MIN as i32, i16::MAX as i32) as i16 +} + +/// Rescale a wire (DualSense-convention) motion sample into the Steam Deck's `hid-steam` units. +pub fn motion_wire_to_deck(gyro: [i16; 3], accel: [i16; 3]) -> ([i16; 3], [i16; 3]) { + ( + gyro.map(|g| scale(g, GYRO_NUM, GYRO_DEN)), + accel.map(|a| scale(a, ACCEL_NUM, ACCEL_DEN)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_paddle_policy() { + assert_eq!(RemapConfig::parse("").paddles, PaddleFallback::Drop); + assert_eq!( + RemapConfig::parse("paddles=stickclicks").paddles, + PaddleFallback::StickClicks + ); + assert_eq!( + RemapConfig::parse("foo=bar; paddles = Shoulders").paddles, + PaddleFallback::Shoulders + ); + assert_eq!( + RemapConfig::parse("paddles=nonsense").paddles, + PaddleFallback::Drop + ); + } + + #[test] + fn fold_paddles_maps_and_clears() { + // All four grips set + a real A button. + let b = gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4; + // Drop: paddle bits cleared, A preserved, nothing added. + assert_eq!(fold_paddles(b, PaddleFallback::Drop), gs::BTN_A); + // StickClicks: left grips → L3, right grips → R3. + assert_eq!( + fold_paddles(b, PaddleFallback::StickClicks), + gs::BTN_A | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK + ); + // Only a left grip (L4 = PADDLE2) → only the left bumper under Shoulders. + assert_eq!( + fold_paddles(gs::BTN_PADDLE2, PaddleFallback::Shoulders), + gs::BTN_LB + ); + } + + #[test] + fn motion_rescale_to_deck_units() { + // gyro × 16/20 = 0.8; accel × 16384/10000 = 1.6384. + let (g, a) = motion_wire_to_deck([1000, -2000, 0], [10000, -5000, 0]); + assert_eq!(g, [800, -1600, 0]); + assert_eq!(a, [16384, -8192, 0]); + // Saturates rather than wraps. + let (_, a) = motion_wire_to_deck([0; 3], [32767, i16::MIN, 0]); + assert_eq!(a[0], i16::MAX); + assert_eq!(a[1], i16::MIN); + } +} diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 922dc6b..5d96803 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1917,6 +1917,36 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool } } +/// Runtime degrade for the Linux UHID backends (DualSense / DualShock 4 / Steam Deck): if +/// `/dev/uhid` can't be opened for write *now*, fall back to the uinput X-Box 360 pad rather than a +/// dead controller (the UHID device-create would just fail). Cheap — opens + drops the char device, +/// no `UHID_CREATE2`, so no device is created. A no-op on non-Linux (those backends are UMDF/uinput). +#[cfg(target_os = "linux")] +fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { + let needs_uhid = matches!( + chosen, + GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck + ); + if needs_uhid + && std::fs::OpenOptions::new() + .write(true) + .open("/dev/uhid") + .is_err() + { + tracing::warn!( + wanted = chosen.as_str(), + "/dev/uhid not writable — falling back to the X-Box 360 pad" + ); + return GamepadPref::Xbox360; + } + chosen +} + +#[cfg(not(target_os = "linux"))] +fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { + chosen +} + /// Resolve the client's gamepad-backend preference (the env/logging shell around /// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { @@ -1927,6 +1957,10 @@ fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { cfg!(target_os = "linux"), cfg!(target_os = "windows"), ); + // Runtime degrade (separate from the compile-time platform check above): the Linux UHID + // backends need `/dev/uhid` usable *now*, else creating the device just fails and the controller + // goes dead — fall back to the always-available uinput X-Box 360 pad instead. + let chosen = degrade_if_no_uhid(chosen); match pref { GamepadPref::Auto => { // The operator's env knob deserves a diagnostic when it didn't drive the diff --git a/design/steam-controller-deck-support.md b/design/steam-controller-deck-support.md index 8fe4edd..c0d9918 100644 --- a/design/steam-controller-deck-support.md +++ b/design/steam-controller-deck-support.md @@ -1,12 +1,26 @@ # Rich Steam Controller & Steam Deck support -> **Status:** **M0–M4 GREEN — the full Steam Controller/Deck pipeline is built (2026-06-29).** Host: -> the virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD= -> steamdeck`), and the protocol carries the rich inputs. Clients: the Linux + Windows SDL clients -> capture + send them; the Decky plugin has the Steam Deck mode + Disable-Steam-Input UX; the C-ABI -> has the `TouchpadEx` send path; Apple/Android round-trip the type. Remaining is **validation, not -> construction** (see below) + the deferred extras (M5 fallback-remap polish, M6 SteamOS-host -> conflict check, M7 Windows UMDF Steam driver). +> **Status:** **M0–M5 GREEN — full pipeline + fallback polish built (2026-06-29).** Host: the +> virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD=steamdeck`), +> the protocol carries the rich inputs, and the **fallback remap** keeps them from silently dropping +> on a non-Steam backend. Clients: the Linux + Windows SDL clients capture + send them; the Decky +> plugin has the Steam Deck mode + Disable-Steam-Input UX; the C-ABI has the `TouchpadEx` send path; +> Apple/Android round-trip the type. Remaining is **validation, not construction** (see below) + the +> deferred extras (M6 SteamOS-host conflict check, M7 Windows UMDF Steam driver). +> +> **M5 (fallback remap + degrade ladder) result:** new pure, unit-tested `inject/proto/steam_remap.rs`: +> (1) **motion rescale** `motion_wire_to_deck` — the wire carries DualSense-convention units (what +> every client capture emits), the Deck's `hid-steam` report wants 16 LSB/°·s + 16384 LSB/g, so the +> Deck backend now rescales (gyro ×16/20, accel ×16384/10000) — a real **Deck↔Deck gyro/accel +> correctness fix**; (2) **`fold_paddles`** + `RemapConfig` (`PUNKTFUNK_STEAM_REMAP=paddles=drop| +> stickclicks|shoulders`, default drop) wired into the DualSense + DS4 managers so a client's back +> grips aren't silently lost on a PlayStation fallback (those pads have no back-button HID slot; the +> uinput Xbox pad already exposes them as `BTN_TRIGGER_HAPPY5-8`). Plus a **runtime degrade ladder** +> in `resolve_gamepad`: a UHID backend (DualSense/DS4/SteamDeck) on a host where `/dev/uhid` isn't +> writable now falls back to the uinput Xbox 360 pad instead of a dead controller. The throwaway M0/M1 +> spike is deleted (M2's `#[ignore]`d backend test subsumes it). On-box backend test still green; +> workspace clippy/fmt/test green. *Deferred as optional `RemapConfig` growth: gyro→mouse / trackpad→ +> stick/mouse synthesis on an Xbox target (no IMU/touchpad slot — currently a documented drop).* > > **M4 (complete) result:** *(desktop capture — see the prior entry.)* Plus: **C-ABI** — > `PunktfunkRichInputEx` (size-prefixed superset) + `punktfunk_connection_send_rich_input2` (the