From 95308d352b0fe02dc8502a17d038d543a3949c4b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 29 Jun 2026 11:32:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(host/steam):=20M2=20=E2=80=94=20virtual=20?= =?UTF-8?q?Steam=20Deck=20as=20a=20wired=20PadBackend=20(Linux)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the virtual hid-steam device a selectable per-session host gamepad, end-to-end on Linux: PUNKTFUNK_GAMEPAD=steamdeck now builds a SteamControllerManager that creates a /dev/uhid 28DE:1205 Deck, enters gamepad_mode, and feeds the byte-exact Deck report (M1). - inject/linux/steam_controller.rs: SteamControllerManager / SteamDeckPad, mirroring dualsense.rs (open/create2, GET/SET_REPORT pump, heartbeat, RAII destroy). Two Steam-specific quirks beyond the DualSense path: * gamepad_mode entry — best-effort `lizard_mode=0` via sysfs, plus a b9.6 creation pulse (MODE_ENTER) so steam_do_deck_input_event stops early-returning, plus an anti-toggle guard (MENU_HOLD_CAP) so a long in-game Start-hold can't flip gamepad_mode back off. * UHID_SET_REPORT answered err=0 (DualSense omits it; the kernel stalls ~5s/cmd otherwise); the 0xEB rumble report parsed onto the 0xCA plane. - core config.rs: GamepadPref::SteamDeck (wire byte 6) + SteamController (byte 5, reserved — folds to Xbox360 until its backend lands); from_u8 / from_name / as_str. Forward-compatible (unknown byte -> Auto); the C-ABI PUNKTFUNK_GAMEPAD_* constants stay M3, so no generated-header drift. - punktfunk1.rs: PadBackend::SteamDeck variant + select / handle / apply_rich / pump / heartbeat arms; pick_gamepad Linux arm. On-box: an #[ignore]d backend test (backend_binds_and_input_flows) drives the real SteamDeckPad — it binds hid-steam (gamepad + IMU evdevs), enters gamepad mode, BTN_A reaches the evdev, and the device tears down on drop. Workspace clippy/fmt/test green. Not pushed. Next: M3 (protocol/ABI wire) + M4 (client capture). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-core/src/config.rs | 26 +- crates/punktfunk-host/src/inject.rs | 9 + .../src/inject/linux/steam_controller.rs | 464 ++++++++++++++++++ crates/punktfunk-host/src/punktfunk1.rs | 19 + design/steam-controller-deck-support.md | 27 +- 5 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 crates/punktfunk-host/src/inject/linux/steam_controller.rs diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 102c7af..1cf885d 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -137,8 +137,9 @@ impl CompositorPref { /// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is /// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID); /// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single -/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`), appended to -/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`). +/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, +/// `5 = SteamController`, `6 = SteamDeck`), appended to `Hello`/`Welcome` — older peers simply +/// omit/ignore it (an unknown byte degrades to `Auto`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum GamepadPref { /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). @@ -155,10 +156,19 @@ pub enum GamepadPref { /// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like /// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host. DualShock4, + /// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — dual trackpads, gyro, + /// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet + /// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)* + SteamController, + /// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl. + /// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input + /// with native glyphs when Steam runs on the host. Needs Linux UHID. + SteamDeck, } impl GamepadPref { - /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`. + /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, + /// `5 = SteamController`, `6 = SteamDeck`. pub const fn to_u8(self) -> u8 { match self { GamepadPref::Auto => 0, @@ -166,6 +176,8 @@ impl GamepadPref { GamepadPref::DualSense => 2, GamepadPref::XboxOne => 3, GamepadPref::DualShock4 => 4, + GamepadPref::SteamController => 5, + GamepadPref::SteamDeck => 6, } } @@ -177,6 +189,8 @@ impl GamepadPref { 2 => GamepadPref::DualSense, 3 => GamepadPref::XboxOne, 4 => GamepadPref::DualShock4, + 5 => GamepadPref::SteamController, + 6 => GamepadPref::SteamDeck, _ => GamepadPref::Auto, } } @@ -192,12 +206,14 @@ impl GamepadPref { GamepadPref::XboxOne } "dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4, + "steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck, + "steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController, _ => return None, }) } /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`, - /// `"dualshock4"`). + /// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`). pub fn as_str(self) -> &'static str { match self { GamepadPref::Auto => "auto", @@ -205,6 +221,8 @@ impl GamepadPref { GamepadPref::DualSense => "dualsense", GamepadPref::XboxOne => "xboxone", GamepadPref::DualShock4 => "dualshock4", + GamepadPref::SteamController => "steamcontroller", + GamepadPref::SteamDeck => "steamdeck", } } } diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index ae24440..45ce3f8 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -491,6 +491,15 @@ pub mod gamepad; #[cfg(target_os = "windows")] #[path = "inject/windows/gamepad_raii.rs"] mod gamepad_raii; +/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck. +#[cfg(target_os = "linux")] +#[path = "inject/linux/steam_controller.rs"] +pub mod steam_controller; +/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck +/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]). +#[cfg(target_os = "linux")] +#[path = "inject/proto/steam_proto.rs"] +pub mod steam_proto; /// 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/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs new file mode 100644 index 0000000..91a79b5 --- /dev/null +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -0,0 +1,464 @@ +//! Virtual Steam Deck controller via UHID — the Steam analogue of the virtual DualSense +//! ([`super::dualsense`]). A UHID device with Valve VID `28DE` / Deck PID `1205` is bound by the +//! kernel `hid-steam` driver, which exposes a full Steam Deck gamepad evdev (incl. the four back +//! grips) **plus** a separate IMU evdev, and — when Steam runs on the host — is re-grabbed by Steam +//! Input with native glyphs + trackpad/gyro/back-button bindings. +//! +//! The transport-independent contract (descriptor, byte-exact serializer, the `XInput`/rich +//! mappers, the rumble parser) lives in [`super::steam_proto`]; this module is the `/dev/uhid` +//! plumbing + the two Steam-specific lifecycle quirks the DualSense path lacks: +//! +//! 1. **`gamepad_mode` entry.** `steam_do_deck_input_event` early-returns under the default +//! `lizard_mode` until `gamepad_mode` is toggled on — which the kernel only does when the `b9.6` +//! Steam/menu-right button is held ~450 ms with no hidraw client open. So on the first pad we +//! best-effort clear `lizard_mode` via sysfs (needs root; bypasses the gate entirely) AND every +//! pad pulses `b9.6` for [`MODE_ENTER`] at creation. After that an **anti-toggle guard** caps any +//! continuous `b9.6` (a long in-game Start-hold) below the kernel's 450 ms threshold so play can +//! never accidentally flip `gamepad_mode` back off. +//! 2. **`UHID_SET_REPORT`.** Steam feedback (`0xEB` rumble) + the kernel's settings/serial writes +//! arrive as FEATURE set-reports that MUST be answered `err = 0`, or the kernel stalls ~5 s per +//! command (the DualSense backend only services GET_REPORT + OUTPUT). + +use super::steam_proto::{ + btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT, + STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, +}; +use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use anyhow::{Context, Result}; +use punktfunk_core::quic::{HidOutput, RichInput}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +// /dev/uhid event ABI — same layout as the DualSense backend. +const UHID_PATH: &str = "/dev/uhid"; +const UHID_DESTROY: u32 = 1; +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 HID_MAX_DESCRIPTOR_SIZE: usize = 4096; +const UHID_EVENT_SIZE: usize = 4 + 4372; +const BUS_USB: u16 = 0x03; + +/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs +/// ~450 ms continuous; give margin). +const MODE_ENTER: Duration = Duration::from_millis(650); +/// Cap continuous `b9.6` (Start) below the kernel's 450 ms mode-switch threshold: after this long +/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off. +const MENU_HOLD_CAP: Duration = Duration::from_millis(350); + +fn 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]); +} + +/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event` +/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the +/// per-pad `b9.6` pulse + guard handle it instead. +fn try_clear_lizard_mode() { + static TRIED: AtomicBool = AtomicBool::new(false); + if TRIED.swap(true, Ordering::Relaxed) { + return; + } + match std::fs::write("/sys/module/hid_steam/parameters/lizard_mode", "N") { + Ok(()) => { + tracing::info!("cleared hid_steam lizard_mode (Steam Deck gamepad events always flow)") + } + Err(e) => tracing::debug!( + error = %e, + "could not clear hid_steam lizard_mode (no root?) — using the gamepad_mode pulse + guard" + ), + } +} + +/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears +/// down the bound `hid-steam` interface + both evdevs). +pub struct SteamDeckPad { + fd: File, + seq: u32, + created: Instant, + /// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not. + menu_hold_since: Option, +} + +impl SteamDeckPad { + pub fn open(index: u8) -> Result { + try_clear_lizard_mode(); + let fd = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(UHID_PATH) + .with_context(|| { + format!("open {UHID_PATH} (is the uhid udev rule installed + are you in 'input'?)") + })?; + let mut pad = SteamDeckPad { + fd, + seq: 0, + created: Instant::now(), + menu_hold_since: None, + }; + pad.send_create2(index).context("UHID_CREATE2 Steam Deck")?; + Ok(pad) + } + + fn send_create2(&mut self, index: u8) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); + put_cstr(&mut ev, 4, 128, &format!("Punktfunk Steam Deck {index}")); // name[128] + put_cstr(&mut ev, 132, 64, &format!("punktfunk/steam/{index}")); // phys[64] + put_cstr(&mut ev, 196, 64, &format!("punktfunk-steam-{index}")); // 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()); + ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes()); + 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); + self.fd.write_all(&ev).context("write UHID_CREATE2")?; + Ok(()) + } + + /// Serialize `st` (with the gamepad-mode entry overlay + anti-toggle guard applied) and write it. + pub fn write_state(&mut self, st: &SteamState) -> Result<()> { + self.seq = self.seq.wrapping_add(1); + let mut s = *st; + s.buttons = self.effective_buttons(st.buttons); + let mut r = [0u8; STEAM_REPORT_LEN]; + serialize_deck_state(&mut r, &s, self.seq); + + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); + ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size + ev[6..6 + r.len()].copy_from_slice(&r); // input2.data + self.fd.write_all(&ev).context("write UHID_INPUT2")?; + Ok(()) + } + + /// True while still pulsing the mode-switch at creation (the caller force-writes during this). + fn in_mode_entry(&self) -> bool { + self.created.elapsed() < MODE_ENTER + } + + /// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but + /// drop `b9.6` for one frame whenever it's been continuously held past [`MENU_HOLD_CAP`]. + fn effective_buttons(&mut self, mut buttons: u64) -> u64 { + if self.in_mode_entry() { + return btn::STEAM_MENU_RIGHT; + } + if buttons & btn::MENU != 0 { + let now = Instant::now(); + match self.menu_hold_since { + None => self.menu_hold_since = Some(now), + Some(since) if now.duration_since(since) >= MENU_HOLD_CAP => { + buttons &= !btn::MENU; // one-frame release resets the kernel's mode-switch timer + self.menu_hold_since = None; + } + Some(_) => {} + } + } else { + self.menu_hold_since = None; + } + buttons + } + + /// Service the device, non-blocking: answer the kernel's GET_REPORT (serial) + SET_REPORT + /// (settings / rumble — ack `err=0`) and parse any rumble feedback (`0xEB`, on either the + /// SET_REPORT or OUTPUT path) into `(low, high)` for the universal rumble plane. + pub fn service(&mut self) -> Option<(u16, u16)> { + let mut rumble = None; + let mut ev = [0u8; UHID_EVENT_SIZE]; + while let Ok(n) = self.fd.read(&mut ev) { + if n < UHID_EVENT_SIZE { + break; + } + match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) { + UHID_OUTPUT => { + let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize; + let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE); + if let Some(r) = parse_steam_output(&ev[4..end]).rumble { + rumble = Some(r); + } + } + UHID_GET_REPORT => { + let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); + let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01")); + } + UHID_SET_REPORT => { + let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); + // SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack. + let end = (12 + 16).min(UHID_EVENT_SIZE); + if let Some(r) = parse_steam_output(&ev[12..end]).rumble { + rumble = Some(r); + } + let _ = self.reply_set_report(id); + } + _ => {} // Start/Stop/Open/Close — ignore + } + } + rumble + } + + fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes()); + ev[4..8].copy_from_slice(&id.to_ne_bytes()); + ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 + ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes()); + ev[12..12 + data.len()].copy_from_slice(data); + self.fd.write_all(&ev).context("UHID_GET_REPORT_REPLY")?; + Ok(()) + } + + fn reply_set_report(&mut self, id: u32) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes()); + ev[4..8].copy_from_slice(&id.to_ne_bytes()); + ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack) + self.fd.write_all(&ev).context("UHID_SET_REPORT_REPLY")?; + Ok(()) + } +} + +impl Drop for SteamDeckPad { + fn drop(&mut self) { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes()); + let _ = self.fd.write_all(&ev); + } +} + +/// All virtual Steam Deck pads of a session — the Steam analogue of +/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=steamdeck`. +/// Button/stick frames arrive via [`handle`](Self::handle); the right trackpad + motion via +/// [`apply_rich`](Self::apply_rich); [`pump`](Self::pump) services the kernel handshake + routes +/// rumble back; [`heartbeat`](Self::heartbeat) keeps the pad alive (and drives the mode-entry pulse). +pub struct SteamControllerManager { + pads: Vec>, + state: Vec, + last_rumble: Vec<(u16, u16)>, + last_write: Vec, + broken: bool, +} + +impl Default for SteamControllerManager { + fn default() -> SteamControllerManager { + SteamControllerManager::new() + } +} + +impl SteamControllerManager { + pub fn new() -> SteamControllerManager { + SteamControllerManager { + pads: (0..MAX_PADS).map(|_| None).collect(), + state: vec![SteamState::neutral(); MAX_PADS], + last_rumble: vec![(0, 0); MAX_PADS], + last_write: vec![Instant::now(); MAX_PADS], + broken: false, + } + } + + pub fn handle(&mut self, ev: &GamepadEvent) { + match ev { + GamepadEvent::Arrival { index, kind, .. } => { + tracing::info!(index, kind, "controller arrival (Steam Deck)"); + self.ensure(*index as usize); + } + GamepadEvent::State(f) => { + let idx = f.index as usize; + if idx >= MAX_PADS { + return; + } + for (i, slot) in self.pads.iter_mut().enumerate() { + if slot.is_some() && f.active_mask & (1 << i) == 0 { + tracing::info!(index = i, "controller unplugged (Steam Deck)"); + *slot = None; + self.state[i] = SteamState::neutral(); + self.last_rumble[i] = (0, 0); + } + } + if f.active_mask & (1 << idx) == 0 { + return; + } + self.ensure(idx); + // Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion + // arrive separately and must survive a button-only frame). + let prev = self.state[idx]; + let mut s = SteamState::from_gamepad( + f.buttons, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + f.left_trigger, + f.right_trigger, + ); + s.rpad_x = prev.rpad_x; + s.rpad_y = prev.rpad_y; + s.lpad_x = prev.lpad_x; + s.lpad_y = prev.lpad_y; + s.gyro = prev.gyro; + s.accel = prev.accel; + s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH); + self.state[idx] = s; + self.write(idx); + } + } + } + + /// Apply a rich client→host event (right trackpad / motion) to an existing pad. + pub fn apply_rich(&mut self, rich: RichInput) { + let idx = match rich { + RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + }; + if idx >= MAX_PADS || self.pads[idx].is_none() { + return; + } + self.state[idx].apply_rich(rich); + self.write(idx); + } + + fn write(&mut self, idx: usize) { + let st = self.state[idx]; + if let Some(pad) = self.pads[idx].as_mut() { + let _ = pad.write_state(&st); + } + self.last_write[idx] = Instant::now(); + } + + /// Re-emit each live pad's current report when silent past `max_gap`, and force a steady stream + /// while a pad is still pulsing its gamepad-mode entry (so the `b9.6` toggle completes even with + /// no game input). + pub fn heartbeat(&mut self, max_gap: Duration) { + let now = Instant::now(); + for i in 0..self.pads.len() { + let Some(pad) = self.pads[i].as_ref() else { + continue; + }; + if pad.in_mode_entry() || now.duration_since(self.last_write[i]) >= max_gap { + self.write(i); + } + } + } + + fn ensure(&mut self, idx: usize) { + if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + return; + } + match SteamDeckPad::open(idx as u8) { + Ok(p) => { + tracing::info!(index = idx, "virtual Steam Deck created (UHID hid-steam)"); + self.pads[idx] = Some(p); + self.state[idx] = SteamState::neutral(); + self.last_rumble[idx] = (0, 0); + self.last_write[idx] = Instant::now(); + } + Err(e) => { + tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — controller input disabled"); + self.broken = true; + } + } + } + + /// Service every pad: answer the kernel handshake and forward rumble on the universal plane. + /// `rumble` fires `(index, low, high)` only on a level change. The Steam Deck has no rich + /// host→client feedback plane (no lightbar / adaptive triggers), so `hidout` goes unused. + pub fn pump(&mut self, mut rumble: impl FnMut(u16, u16, u16), _hidout: impl FnMut(HidOutput)) { + for i in 0..self.pads.len() { + let Some(pad) = self.pads[i].as_mut() else { + continue; + }; + if let Some(r) = pad.service() { + if self.last_rumble[i] != r { + self.last_rumble[i] = r; + rumble(i as u16, r.0, r.1); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Find the evdev node for a kernel input device by exact name (e.g. `"Steam Deck"`). + fn find_node(name: &str) -> Option { + let devs = std::fs::read_to_string("/proc/bus/input/devices").ok()?; + for block in devs.split("\n\n") { + if !block + .lines() + .any(|l| l.trim() == format!("N: Name=\"{name}\"")) + { + continue; + } + for l in block.lines() { + if let Some(h) = l.strip_prefix("H: Handlers=") { + if let Some(ev) = h.split_whitespace().find(|t| t.starts_with("event")) { + return Some(format!("/dev/input/{ev}")); + } + } + } + } + None + } + + /// Read the evdev's current key bitmap (`EVIOCGKEY`) and test whether `code` is down. + fn key_is_down(node: &str, code: u16) -> bool { + use std::os::unix::io::AsRawFd; + let Ok(f) = std::fs::File::open(node) else { + return false; + }; + let mut bits = [0u8; 96]; + const EVIOCGKEY: libc::c_ulong = (2 << 30) | (96 << 16) | (0x45 << 8) | 0x18; + // SAFETY: EVIOCGKEY copies the current key-state bitmap of the evdev behind the valid fd + // `f` into `bits`; 96 bytes covers KEY_MAX/8, so the kernel never writes past the buffer. + let rc = unsafe { libc::ioctl(f.as_raw_fd(), EVIOCGKEY, bits.as_mut_ptr()) }; + rc >= 0 && (bits[(code / 8) as usize] >> (code % 8)) & 1 == 1 + } + + /// On-box smoke test for the real backend: a `SteamDeckPad` must bind `hid-steam` (creating both + /// the gamepad + IMU evdevs), enter `gamepad_mode` via the creation pulse, and land a held button + /// on the evdev (`BTN_A`, code 0x130) — proving the entry overlay + byte-exact serialize path — + /// then tear the device down on drop. Touches `/dev/uhid`, so it is `#[ignore]`d in CI; run on a + /// box with `hid-steam` + `input`-group access: `cargo test -p punktfunk-host -- --ignored`. + #[test] + #[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"] + fn backend_binds_and_input_flows() { + const BTN_A: u16 = 0x130; + let mut pad = SteamDeckPad::open(0).expect("open SteamDeckPad (/dev/uhid + input group?)"); + // Drive past MODE_ENTER (the b9.6 pulse) then hold BTN_A, servicing the handshake. + let mut st = SteamState::neutral(); + st.buttons = btn::A; + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(1200) { + let _ = pad.service(); + pad.write_state(&st).expect("write_state"); + std::thread::sleep(Duration::from_millis(4)); + } + let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default(); + assert!(devs.contains("Steam Deck"), "gamepad evdev not created"); + assert!( + devs.contains("Steam Deck Motion Sensors"), + "IMU evdev not created" + ); + let node = find_node("Steam Deck").expect("gamepad evdev node"); + assert!( + key_is_down(&node, BTN_A), + "BTN_A not down — gamepad_mode entry or serialize failed" + ); + drop(pad); + std::thread::sleep(Duration::from_millis(200)); + let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default(); + assert!( + !devs.contains("Steam Deck Motion Sensors"), + "device not torn down on drop" + ); + } +} diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 1b56046..922dc6b 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1399,6 +1399,8 @@ enum PadBackend { DualSense(crate::inject::dualsense::DualSenseManager), #[cfg(target_os = "linux")] DualShock4(crate::inject::dualshock4::DualShock4Manager), + #[cfg(target_os = "linux")] + SteamDeck(crate::inject::steam_controller::SteamControllerManager), #[cfg(target_os = "windows")] DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager), #[cfg(target_os = "windows")] @@ -1420,6 +1422,12 @@ impl PadBackend { tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)"); return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new()); } + GamepadPref::SteamDeck => { + tracing::info!("gamepad backend: virtual Steam Deck (UHID hid-steam)"); + return PadBackend::SteamDeck( + crate::inject::steam_controller::SteamControllerManager::new(), + ); + } GamepadPref::XboxOne => { tracing::info!("gamepad backend: uinput X-Box One/Series pad"); return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity( @@ -1455,6 +1463,8 @@ impl PadBackend { PadBackend::DualSense(m) => m.handle(ev), #[cfg(target_os = "linux")] PadBackend::DualShock4(m) => m.handle(ev), + #[cfg(target_os = "linux")] + PadBackend::SteamDeck(m) => m.handle(ev), #[cfg(target_os = "windows")] PadBackend::DualSenseWindows(m) => m.handle(ev), #[cfg(target_os = "windows")] @@ -1471,6 +1481,8 @@ impl PadBackend { PadBackend::DualSense(m) => m.apply_rich(_rich), #[cfg(target_os = "linux")] PadBackend::DualShock4(m) => m.apply_rich(_rich), + #[cfg(target_os = "linux")] + PadBackend::SteamDeck(m) => m.apply_rich(_rich), #[cfg(target_os = "windows")] PadBackend::DualSenseWindows(m) => m.apply_rich(_rich), #[cfg(target_os = "windows")] @@ -1496,6 +1508,8 @@ impl PadBackend { PadBackend::DualSense(m) => m.pump(rumble, hidout), #[cfg(target_os = "linux")] PadBackend::DualShock4(m) => m.pump(rumble, hidout), + #[cfg(target_os = "linux")] + PadBackend::SteamDeck(m) => m.pump(rumble, hidout), #[cfg(target_os = "windows")] PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout), #[cfg(target_os = "windows")] @@ -1515,6 +1529,8 @@ impl PadBackend { PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), #[cfg(target_os = "linux")] PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)), + #[cfg(target_os = "linux")] + PadBackend::SteamDeck(m) => m.heartbeat(std::time::Duration::from_millis(8)), #[cfg(target_os = "windows")] PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)), #[cfg(target_os = "windows")] @@ -1894,6 +1910,9 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on // Windows (XInput can't tell them apart anyway). GamepadPref::XboxOne if linux => GamepadPref::XboxOne, + // Steam Deck: Linux UHID hid-steam. The classic Steam Controller's backend isn't built yet, + // so it folds to Xbox360 for now (Windows Steam devices are M7). + GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, _ => GamepadPref::Xbox360, } } diff --git a/design/steam-controller-deck-support.md b/design/steam-controller-deck-support.md index 7e7e197..2c7565d 100644 --- a/design/steam-controller-deck-support.md +++ b/design/steam-controller-deck-support.md @@ -1,11 +1,26 @@ # Rich Steam Controller & Steam Deck support -> **Status:** **M0 + M1 GREEN — Linux virtual Deck binds AND is byte-exact, 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 our full input report is parsed -> field-for-field. Next: M2 (the `SteamControllerManager` UHID backend + `PadBackend` wiring). This -> remains the design + milestone plan; the Steam analogue of the shipped virtual DualSense -> (`design/windows-dualsense-scoping.md`). +> **Status:** **M0–M2 GREEN — Linux virtual Deck binds, is byte-exact, AND is a wired host backend, +> on-box (2026-06-29).** The greenfield virtual `hid-steam` device works end-to-end as a selectable +> host gamepad backend: `PUNKTFUNK_GAMEPAD=steamdeck` builds a per-session `SteamControllerManager` +> that creates a `/dev/uhid` `28DE:1205` device, enters `gamepad_mode`, and feeds the byte-exact Deck +> report. Next: M3 (the protocol/ABI wire surface — back-button bits, `TouchpadEx`, the C-ABI +> `GamepadPref` constants) + M4 (client capture). This remains the design + milestone plan; the Steam +> analogue of the shipped virtual DualSense (`design/windows-dualsense-scoping.md`). +> +> **M2 result (backend + wiring, on-box):** `inject/linux/steam_controller.rs` +> (`SteamControllerManager`/`SteamDeckPad`, mirroring `dualsense.rs`) is wired into `PadBackend` +> (new `SteamDeck` variant + `select`/`handle`/`apply_rich`/`pump`/`heartbeat` arms) and selectable +> via `GamepadPref::SteamDeck` (core enum byte 6 + `pick_gamepad` Linux arm; `SteamController` = byte +> 5 is reserved, folds to Xbox360 until its backend lands). Two Steam-specific quirks beyond the +> DualSense path: (1) **`gamepad_mode` entry** — best-effort `lizard_mode=0` via sysfs + a `b9.6` +> creation pulse (`MODE_ENTER` 650 ms) + an **anti-toggle guard** (`MENU_HOLD_CAP` 350 ms) so a long +> in-game Start-hold can't flip `gamepad_mode` off; (2) **`UHID_SET_REPORT`** answered `err=0` +> (DualSense omits it) + the `0xEB` rumble parsed onto the universal 0xCA plane. An `#[ignore]`d +> on-box test (`backend_binds_and_input_flows`) drives the real backend: it binds `hid-steam` +> (gamepad + IMU evdevs), enters gamepad mode, `BTN_A` reaches the evdev, and the device tears down +> on drop. Workspace clippy/fmt/test green; no generated-header drift (the C-ABI `GamepadPref` +> constants are M3). > > **M1 result (byte-exact serializer, on-box):** `inject/proto/steam_proto.rs` now carries the full > Deck contract transcribed verbatim from the kernel `steam_do_deck_input_event` /