diff --git a/Cargo.lock b/Cargo.lock index fc2cb90..bf997d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2331,6 +2331,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2839,12 +2850,14 @@ dependencies = [ "tracing", "tracing-subscriber", "ureq", + "usbip-sim", "utoipa", "utoipa-axum", "utoipa-scalar", "wasapi", "wayland-backend", "wayland-client", + "wayland-protocols", "wayland-protocols-misc", "wayland-protocols-wlr", "wayland-scanner", @@ -4236,6 +4249,17 @@ dependencies = [ "serde", ] +[[package]] +name = "usbip-sim" +version = "0.8.0" +dependencies = [ + "log", + "num-derive", + "num-traits", + "serde", + "tokio", +] + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 2bf1b12..edc8212 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/punktfunk-core", "crates/punktfunk-host", + "crates/punktfunk-host/vendor/usbip-sim", "crates/pf-driver-proto", "clients/probe", "clients/linux", diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index a32db88..6912fe8 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -767,6 +767,7 @@ fn start_session_with(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>, connector, frames.take().expect("Connected delivered once"), app.gamepad.escape_events(), + app.gamepad.disconnect_events(), handle.stop.clone(), inhibit, &title, diff --git a/clients/linux/src/gamepad.rs b/clients/linux/src/gamepad.rs index 93c5512..f25ab81 100644 --- a/clients/linux/src/gamepad.rs +++ b/clients/linux/src/gamepad.rs @@ -18,7 +18,7 @@ use punktfunk_core::quic::{HidOutput, RichInput}; use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; /// Motion scale constants, shared convention with the Swift client (`GamepadWire`): /// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands @@ -33,8 +33,15 @@ const G: f32 = 9.80665; /// is the only way out. Four simultaneous buttons that no game uses as a deliberate /// combo, so it can't be triggered by normal play. Still forwarded to the host (the user /// is leaving anyway); we only also raise the escape signal. +/// +/// **Escalation:** a quick press leaves fullscreen / releases capture; *holding* the same +/// chord for [`DISCONNECT_HOLD`] ends the session. Deliberately NOT the Steam / QAM buttons — +/// those are the marquee pass-through controls that now reach the host's game-mode UI. const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK]; +/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press). +const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); + #[derive(Clone, Debug)] pub struct PadInfo { pub id: u32, @@ -90,6 +97,9 @@ pub struct GamepadService { /// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave /// fullscreen + release capture. escape_rx: async_channel::Receiver<()>, + /// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page + /// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D). + disconnect_rx: async_channel::Receiver<()>, } impl GamepadService { @@ -99,11 +109,12 @@ impl GamepadService { let pinned = Arc::new(Mutex::new(None)); let (ctl, ctl_rx) = std::sync::mpsc::channel(); let (escape_tx, escape_rx) = async_channel::unbounded(); + let (disconnect_tx, disconnect_rx) = async_channel::unbounded(); let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone()); if let Err(e) = std::thread::Builder::new() .name("punktfunk-gamepad".into()) .spawn(move || { - if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) { + if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) { tracing::warn!(error = %e, "gamepad service ended — pads disabled"); } }) @@ -116,6 +127,7 @@ impl GamepadService { pinned, ctl, escape_rx, + disconnect_rx, } } @@ -125,6 +137,12 @@ impl GamepadService { self.escape_rx.clone() } + /// A receiver that yields one `()` when the escape chord is held past [`DISCONNECT_HOLD`] + /// (controller disconnect). A fresh clone per call; the stream page spawns a future on it. + pub fn disconnect_events(&self) -> async_channel::Receiver<()> { + self.disconnect_rx.clone() + } + pub fn pads(&self) -> Vec { self.pads.lock().unwrap().clone() } @@ -274,8 +292,15 @@ struct Worker { last_accel: [i16; 3], /// Raises the UI escape signal; the escape chord fires it once per press. escape_tx: async_channel::Sender<()>, + /// Raises the UI disconnect signal when the escape chord is held past [`DISCONNECT_HOLD`]. + disconnect_tx: async_channel::Sender<()>, /// The escape chord is fully held — latched so it fires once, not every poll. chord_armed: bool, + /// When the escape chord became fully held (drives the hold-to-disconnect escalation); `None` + /// when the chord is broken. + chord_since: Option, + /// The disconnect signal already fired for the current hold — latched so it fires once. + disconnect_fired: bool, } impl Worker { @@ -347,28 +372,61 @@ impl Worker { self.last_axis = [i32::MIN; 6]; self.held_touches.clear(); } + // A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too. + self.reset_chord(); } /// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it - /// fires once per press). Called after each button-down updates `held_buttons`. + /// fires once per press) and start the hold-to-disconnect timer. Called after each + /// button-down updates `held_buttons`. fn maybe_fire_escape(&mut self) { if self.chord_armed { return; } if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) { self.chord_armed = true; + self.chord_since = Some(Instant::now()); let _ = self.escape_tx.try_send(()); - tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen"); + tracing::info!( + "gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen (hold to disconnect)" + ); + } + } + + /// Fire the disconnect signal once the escape chord has been continuously held past + /// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events. + fn maybe_fire_disconnect(&mut self) { + if self.disconnect_fired { + return; + } + if let Some(since) = self.chord_since { + if since.elapsed() >= DISCONNECT_HOLD { + self.disconnect_fired = true; + let _ = self.disconnect_tx.try_send(()); + tracing::info!("gamepad escape chord held — disconnecting"); + } } } /// Re-arm once the chord is broken (any of its buttons released). fn rearm_escape(&mut self) { if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) { - self.chord_armed = false; + self.reset_chord(); } } + /// Clear the escape/disconnect chord latches. Called at every session boundary + /// ([`flush_held`](Self::flush_held) on detach/pad-switch + on attach): the hold-to-disconnect + /// path *always* ends the session while the chord is still physically held, so the matching + /// button-up events arrive after detach (dropped by the `attached` guard) and `rearm_escape` + /// never runs — without this the latched state would leak into the next session and either + /// swallow its first chord press or fire a stale disconnect on connect. + fn reset_chord(&mut self) { + self.chord_armed = false; + self.chord_since = None; + self.disconnect_fired = false; + } + /// Sensors stream only while a session wants them (they cost USB/BT bandwidth). fn set_sensors(&mut self, enabled: bool) { let Some(id) = self.active_id() else { return }; @@ -440,6 +498,7 @@ fn run( pinned_out: &Mutex>, ctl: &Receiver, escape_tx: &async_channel::Sender<()>, + disconnect_tx: &async_channel::Sender<()>, ) -> Result<(), String> { // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its // own thread. @@ -466,7 +525,10 @@ fn run( held_touches: std::collections::HashSet::new(), last_accel: [0; 3], escape_tx: escape_tx.clone(), + disconnect_tx: disconnect_tx.clone(), chord_armed: false, + chord_since: None, + disconnect_fired: false, }; let publish = |w: &Worker| { @@ -484,6 +546,7 @@ fn run( Ok(Ctl::Attach(c)) => { w.attached = Some(c); w.last_axis = [i32::MIN; 6]; + w.reset_chord(); // every session starts un-latched (Attach doesn't flush) w.set_sensors(true); } Ok(Ctl::Detach) => { @@ -646,6 +709,10 @@ fn run( } } + // Escalate a held escape chord to a disconnect (polled — the hold completes with no + // new button events; the chord itself is only detected while a session is attached). + w.maybe_fire_disconnect(); + // Feedback planes (this thread is their single consumer). The host re-sends // rumble state periodically, so a generous duration with refresh-on-update is // safe — a dropped stop heals within ~500 ms. diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index 31a7785..b01d5a3 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -124,12 +124,13 @@ impl Capture { } } -#[allow(clippy::too_many_lines)] +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] pub fn new( window: &adw::ApplicationWindow, connector: Arc, frames: async_channel::Receiver, escape_rx: async_channel::Receiver<()>, + disconnect_rx: async_channel::Receiver<()>, stop: Arc, inhibit_shortcuts: bool, title: &str, @@ -152,7 +153,7 @@ pub fn new( stats_label.set_margin_top(12); let hint = gtk::Label::new(Some( - "Click the stream to capture input · Ctrl+Alt+Shift+Q releases", + "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects", )); hint.add_css_class("osd"); hint.set_halign(gtk::Align::Center); @@ -163,7 +164,9 @@ pub fn new( // Flashed when entering fullscreen — the only exit affordances once the header bar is // hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the // only way out on a Steam Deck). - let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen")); + let fs_hint = gtk::Label::new(Some( + "F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)", + )); fs_hint.add_css_class("osd"); fs_hint.set_halign(gtk::Align::Center); fs_hint.set_valign(gtk::Align::Start); @@ -297,6 +300,7 @@ pub fn new( key.set_propagation_phase(gtk::PropagationPhase::Capture); let cap = capture.clone(); let window_k = window.clone(); + let stop_kb = stop.clone(); key.connect_key_pressed(move |_, keyval, keycode, state| { let chord = gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::ALT_MASK @@ -309,6 +313,13 @@ pub fn new( } return glib::Propagation::Stop; } + // Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host, + // the capture toggle alone can't end a stream, so this is the keyboard's explicit exit. + if state.contains(chord) && keyval.to_lower() == gdk::Key::d { + cap.release(); + stop_kb.store(true, Ordering::SeqCst); + return glib::Propagation::Stop; + } if keyval == gdk::Key::F11 { if window_k.is_fullscreen() { window_k.unfullscreen(); @@ -442,6 +453,24 @@ pub fn new( }) }; + // Controller disconnect (escape chord held past the hold threshold) → end the session, the + // controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, which pops + // this page (and fires `hidden` below). One-shot — the session is going away. + let disconnect_future = { + let window = window.clone(); + let cap = capture.clone(); + let stop_d = stop.clone(); + glib::spawn_future_local(async move { + if disconnect_rx.recv().await.is_ok() { + cap.release(); + if window.is_fullscreen() { + window.unfullscreen(); + } + stop_d.store(true, Ordering::SeqCst); + } + }) + }; + // The page's `hidden` fires once navigation away completes (back button, pop on // session end) — NOT on the transient unmap/map cycle a NavigationView push performs. { @@ -449,6 +478,7 @@ pub fn new( let stop_h = stop.clone(); let handlers = RefCell::new(Some((fs_handler, active_handler))); let escape_future = RefCell::new(Some(escape_future)); + let disconnect_future = RefCell::new(Some(disconnect_future)); page.connect_hidden(move |_| { tracing::debug!("stream page hidden — ending session"); if let Some((fs, active)) = handlers.borrow_mut().take() { @@ -458,6 +488,9 @@ pub fn new( if let Some(f) = escape_future.borrow_mut().take() { f.abort(); } + if let Some(f) = disconnect_future.borrow_mut().take() { + f.abort(); + } if window.is_fullscreen() { window.unfullscreen(); } diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 792770a..337459e 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -89,6 +89,9 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] } wayland-client = "0.31" wayland-protocols-wlr = { version = "0.3", features = ["client"] } wayland-protocols-misc = { version = "0.3", features = ["client"] } +# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global +# position), used by the KWin fake_input backend to map absolute coordinates under display scaling. +wayland-protocols = { version = "0.32", features = ["client"] } # Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin # virtual output sized to the client's resolution and get its PipeWire node (KRdp's path). # `wayland-backend` is referenced by the generated interface tables. @@ -119,6 +122,10 @@ ash = "0.38" # `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA # (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`. libloading = "0.8" +# Vendored + trimmed `usbip` server core (no libusb) — presents a virtual Steam Deck over USB/IP +# so the local `vhci_hcd` attaches it: the shippable, Secure-Boot-clean, Steam-Input-promotable +# virtual-Deck transport on non-SteamOS hosts (`inject/linux/steam_usbip.rs`). See the crate's NOTICE. +usbip-sim = { path = "vendor/usbip-sim" } [target.'cfg(target_os = "windows")'.dependencies] # Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 0c5d2d8..c7b581e 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -510,6 +510,12 @@ pub mod steam_proto; #[cfg(target_os = "linux")] #[path = "inject/proto/steam_remap.rs"] pub mod steam_remap; +/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean, +/// Steam-Input-promotable virtual-Deck transport on non-SteamOS hosts (Bazzite/generic), where +/// `dummy_hcd`/`raw_gadget` aren't built. In-tree + signed; no module build, no MOK. +#[cfg(target_os = "linux")] +#[path = "inject/linux/steam_usbip.rs"] +pub mod steam_usbip; /// 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/kwin_fake_input.rs b/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs index 92b4e6e..2b8e166 100644 --- a/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs +++ b/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs @@ -7,9 +7,14 @@ //! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's //! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux //! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr -//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space — which -//! on a headless box (single per-session virtual output at the origin, scale 1) equals the streamed -//! output's pixels. +//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space. +//! +//! Global compositor space is *logical* pixels (post display-scaling), which only equals the streamed +//! output's physical pixels at scale 1. Under a fractional/integer scale the logical edge sits at +//! `physical / scale`, so feeding the raw streamed pixel coordinate lands the cursor `scale×` too far +//! toward the bottom-right (top-left stays put). We therefore track each output's logical geometry +//! (position + size) via `xdg-output` and map the normalized client position into the matching +//! output's logical rectangle — the same shape the libei backend uses with its EI region. #![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)] // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). @@ -18,8 +23,14 @@ use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector}; use anyhow::{Context, Result}; use punktfunk_core::input::InputKind; +use std::time::{Duration, Instant}; +use wayland_client::protocol::wl_output::{self, WlOutput}; use wayland_client::protocol::wl_registry::{self, WlRegistry}; -use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle}; +use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum}; +use wayland_protocols::xdg::xdg_output::zv1::client::{ + zxdg_output_manager_v1::ZxdgOutputManagerV1, + zxdg_output_v1::{self, ZxdgOutputV1}, +}; // Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the // KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR. @@ -48,10 +59,39 @@ const AXIS_HORIZONTAL: u32 = 1; /// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend). const SCROLL_HORIZONTAL: u32 = 1; +/// One tracked output: its physical mode (to match the streamed resolution) and its logical geometry +/// (the global-compositor-space rectangle absolute coordinates are addressed in). `logical_w == 0` +/// means xdg-output hasn't reported its size yet. +struct OutputTrack { + /// Registry global id — also the dispatch user-data, so events route back to this entry. + name: u32, + wl_output: WlOutput, + xdg_output: Option, + /// Physical pixel mode from `wl_output.mode` (the `current` mode); matched against the streamed WxH. + mode_w: i32, + mode_h: i32, + /// Logical (post-scale) geometry from `xdg-output`. + logical_x: i32, + logical_y: i32, + logical_w: i32, + logical_h: i32, +} + /// Registry-bound globals (the Wayland dispatch state). #[derive(Default)] struct State { fake: Option, + xdg_mgr: Option, + outputs: Vec, +} + +impl State { + /// Create the `xdg_output` for a tracked output once both it and the manager exist. + fn ensure_xdg_output(o: &mut OutputTrack, mgr: &ZxdgOutputManagerV1, qh: &QueueHandle) { + if o.xdg_output.is_none() { + o.xdg_output = Some(mgr.get_xdg_output(&o.wl_output, qh, o.name)); + } + } } impl Dispatch for State { @@ -63,15 +103,57 @@ impl Dispatch for State { _: &Connection, qh: &QueueHandle, ) { - if let wl_registry::Event::Global { - name, - interface, - version, - } = event - { - if interface == "org_kde_kwin_fake_input" { - state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ())); + match event { + wl_registry::Event::Global { + name, + interface, + version, + } => match interface.as_str() { + "org_kde_kwin_fake_input" => { + state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ())); + } + "wl_output" => { + // v1 carries `mode` (all we need); bind no higher than the proxy's max (4). + let wl_output: WlOutput = registry.bind(name, version.min(4), qh, name); + let mut o = OutputTrack { + name, + wl_output, + xdg_output: None, + mode_w: 0, + mode_h: 0, + logical_x: 0, + logical_y: 0, + logical_w: 0, + logical_h: 0, + }; + if let Some(mgr) = state.xdg_mgr.clone() { + State::ensure_xdg_output(&mut o, &mgr, qh); + } + state.outputs.push(o); + } + "zxdg_output_manager_v1" => { + let mgr: ZxdgOutputManagerV1 = registry.bind(name, version.min(3), qh, ()); + // Outputs bound before the manager have no xdg_output yet — create them now. + for o in state.outputs.iter_mut() { + State::ensure_xdg_output(o, &mgr, qh); + } + state.xdg_mgr = Some(mgr); + } + _ => {} + }, + wl_registry::Event::GlobalRemove { name } => { + state.outputs.retain(|o| { + if o.name == name { + if let Some(x) = &o.xdg_output { + x.destroy(); + } + false + } else { + true + } + }); } + _ => {} } } } @@ -89,13 +171,86 @@ impl Dispatch for State { } } +impl Dispatch for State { + fn event( + state: &mut Self, + _: &WlOutput, + event: wl_output::Event, + name: &u32, + _: &Connection, + _: &QueueHandle, + ) { + // Only the *current* mode matters — a real monitor also advertises its other supported modes. + if let wl_output::Event::Mode { + flags: WEnum::Value(flags), + width, + height, + .. + } = event + { + if flags.contains(wl_output::Mode::Current) { + if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) { + o.mode_w = width; + o.mode_h = height; + } + } + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ZxdgOutputV1, + event: zxdg_output_v1::Event, + name: &u32, + _: &Connection, + _: &QueueHandle, + ) { + if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) { + match event { + zxdg_output_v1::Event::LogicalPosition { x, y } => { + o.logical_x = x; + o.logical_y = y; + } + zxdg_output_v1::Event::LogicalSize { width, height } => { + o.logical_w = width; + o.logical_h = height; + } + _ => {} + } + } + } +} + +// The manager has no events. +impl Dispatch for State { + fn event( + _: &mut Self, + _: &ZxdgOutputManagerV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + pub struct KwinFakeInjector { conn: Connection, queue: EventQueue, state: State, fake: FakeInput, + /// When output geometry was last re-read; throttles the per-event roundtrip (see `refresh_geometry`). + last_refresh: Option, } +/// How often the fake_input backend re-reads output geometry from the compositor. Output add/remove +/// (a new session's virtual output) and live scale/resolution changes are infrequent, so a lazy +/// poll on the injector's own thread is plenty and adds at most one local-socket roundtrip twice a +/// second — versus a blocking roundtrip on every single mouse-move event. +const GEO_REFRESH: Duration = Duration::from_millis(500); + impl KwinFakeInjector { pub fn open() -> Result { let conn = Connection::connect_to_env() @@ -122,13 +277,77 @@ impl KwinFakeInjector { .context("fake_input authenticate roundtrip")?; conn.flush().ok(); - tracing::info!("KWin fake_input ready (headless keyboard/mouse/touch — no portal)"); - Ok(Self { + // Settle output geometry (wl_output + xdg-output were bound during the registry roundtrip + // above; their logical_size arrives on a follow-up roundtrip). Best-effort — falls back to + // scale-1 mapping if xdg-output is absent. + let mut injector = Self { conn, queue, state, fake, - }) + last_refresh: None, + }; + injector.refresh_geometry(); + tracing::info!( + outputs = injector.state.outputs.len(), + "KWin fake_input ready (headless keyboard/mouse/touch — no portal)" + ); + Ok(injector) + } + + /// Re-read output geometry, throttled to [`GEO_REFRESH`]. A `roundtrip` both flushes any pending + /// `get_xdg_output` requests and reads the geometry events back. A wl_output that *appeared* this + /// round only gets its xdg_output created mid-dispatch, so its `logical_size` lands on a later + /// roundtrip — keep going (bounded) until every output is settled. + fn refresh_geometry(&mut self) { + let now = Instant::now(); + if let Some(t) = self.last_refresh { + if now.duration_since(t) < GEO_REFRESH { + return; + } + } + self.last_refresh = Some(now); + for _ in 0..3 { + if self.queue.roundtrip(&mut self.state).is_err() { + return; + } + let pending = + self.state.xdg_mgr.is_some() && self.state.outputs.iter().any(|o| o.logical_w == 0); + if !pending { + break; + } + } + } + + /// Resolve the logical (global-compositor-space) rectangle to map a normalized client position + /// into. Prefer the output whose physical mode matches the streamed `phys_w`×`phys_h` (the + /// per-session virtual output); fall back to the sole output, then — if xdg-output is unavailable + /// — to the streamed pixels at the origin (the pre-scaling behavior, correct at scale 1). + fn logical_target(&self, phys_w: i32, phys_h: i32) -> (f64, f64, f64, f64) { + let usable = || { + self.state + .outputs + .iter() + .filter(|o| o.logical_w > 0 && o.logical_h > 0) + }; + let chosen = usable() + .find(|o| o.mode_w == phys_w && o.mode_h == phys_h) + .or_else(|| { + let mut it = usable(); + match (it.next(), it.next()) { + (Some(only), None) => Some(only), + _ => None, + } + }); + match chosen { + Some(o) => ( + o.logical_x as f64, + o.logical_y as f64, + o.logical_w as f64, + o.logical_h as f64, + ), + None => (0.0, 0.0, phys_w as f64, phys_h as f64), + } } } @@ -139,12 +358,17 @@ impl InputInjector for KwinFakeInjector { self.fake.pointer_motion(event.x as f64, event.y as f64); } InputKind::MouseMoveAbs => { - let w = (event.flags >> 16) & 0xffff; - let h = event.flags & 0xffff; + let w = ((event.flags >> 16) & 0xffff) as i32; + let h = (event.flags & 0xffff) as i32; if w > 0 && h > 0 { - let x = event.x.clamp(0, w as i32) as f64; - let y = event.y.clamp(0, h as i32) as f64; - self.fake.pointer_motion_absolute(x, y); + self.refresh_geometry(); + let (lx, ly, lw, lh) = self.logical_target(w, h); + // Normalize in the streamed (physical) pixel space, then place inside the output's + // logical rectangle — so display scaling no longer offsets the cursor. + let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0); + let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0); + self.fake + .pointer_motion_absolute(lx + nx * lw, ly + ny * lh); } } InputKind::MouseButtonDown | InputKind::MouseButtonUp => { @@ -179,11 +403,15 @@ impl InputInjector for KwinFakeInjector { // Touch: id = event.code, coords in the client surface w×h packed into flags (same // absolute mapping as MouseMoveAbs). Each event is its own frame. InputKind::TouchDown | InputKind::TouchMove => { - let w = (event.flags >> 16) & 0xffff; - let h = event.flags & 0xffff; + let w = ((event.flags >> 16) & 0xffff) as i32; + let h = (event.flags & 0xffff) as i32; if w > 0 && h > 0 { - let x = event.x.clamp(0, w as i32) as f64; - let y = event.y.clamp(0, h as i32) as f64; + self.refresh_geometry(); + let (lx, ly, lw, lh) = self.logical_target(w, h); + let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0); + let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0); + let x = lx + nx * lw; + let y = ly + ny * lh; if event.kind == InputKind::TouchDown { self.fake.touch_down(event.code, x, y); } else { diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index 9c19f7d..04cefbc 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -240,11 +240,13 @@ impl Drop for SteamDeckPad { /// [`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). /// The transport a manager pad drives. UHID is universal but Steam Input won't promote it (a UHID -/// device has no USB interface number); the USB gadget is SteamOS-only but Steam Input *does* promote -/// it (it presents the controller on interface 2). Selected per-pad in [`SteamControllerManager::ensure`]. +/// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS) +/// and **usbip** (`vhci_hcd`, universal) both present the controller on USB interface 2, which Steam +/// Input *does* promote. Selected per-pad by [`open_transport`]. enum DeckTransport { Uhid(SteamDeckPad), Gadget(crate::inject::steam_gadget::SteamDeckGadget), + Usbip(crate::inject::steam_usbip::SteamDeckUsbip), } impl DeckTransport { @@ -254,22 +256,67 @@ impl DeckTransport { let _ = p.write_state(st); } DeckTransport::Gadget(g) => g.write_state(st), + DeckTransport::Usbip(u) => u.write_state(st), } } fn service(&mut self) -> Option<(u16, u16)> { match self { DeckTransport::Uhid(p) => p.service(), DeckTransport::Gadget(g) => g.service().rumble, + DeckTransport::Usbip(u) => u.service().rumble, } } fn in_mode_entry(&self) -> bool { match self { + // Only the UHID pad needs the gamepad-mode entry pulse: the promoted transports are + // read raw via hidraw by Steam Input, which bypasses the kernel's evdev mode gate. DeckTransport::Uhid(p) => p.in_mode_entry(), - DeckTransport::Gadget(_) => false, + DeckTransport::Gadget(_) | DeckTransport::Usbip(_) => false, } } } +/// Open the best Steam-Input-promotable Deck transport available, in preference order: +/// **`raw_gadget` (SteamOS validated fast-path) → `usbip`/`vhci_hcd` (universal, Secure-Boot-clean) +/// → UHID (universal, but `Interface: -1` so Steam Input won't promote it).** Each rung degrades to +/// the next on failure, so a host lacking the gadget modules still gets a *promotable* Deck via +/// usbip, and one lacking both still gets a working (if non-promoted) UHID pad. +fn open_transport(idx: u8) -> Result { + use crate::inject::{steam_gadget, steam_usbip}; + // 1. raw_gadget — the validated SteamOS fast-path (default on there). + if steam_gadget::gadget_preferred() { + steam_gadget::ensure_modules(); + match steam_gadget::SteamDeckGadget::open(idx) { + Ok(g) => { + tracing::info!( + index = idx, + "virtual Steam Deck created (USB gadget — Steam Input recognizes it)" + ); + return Ok(DeckTransport::Gadget(g)); + } + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "USB-gadget Deck unavailable — trying usbip") + } + } + } + // 2. usbip/vhci_hcd — the universal, in-tree, Secure-Boot-clean transport (default on elsewhere). + if steam_usbip::usbip_preferred() { + match steam_usbip::SteamDeckUsbip::open(idx) { + Ok(u) => return Ok(DeckTransport::Usbip(u)), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "usbip Deck unavailable — falling back to UHID") + } + } + } + // 3. UHID — universal fallback (works everywhere; Steam Input won't promote it). + let p = SteamDeckPad::open(idx)?; + tracing::info!( + index = idx, + "virtual Steam Deck created (UHID hid-steam — not Steam-Input-promoted)" + ); + Ok(DeckTransport::Uhid(p)) +} + pub struct SteamControllerManager { pads: Vec>, state: Vec, @@ -384,31 +431,8 @@ impl SteamControllerManager { if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { return; } - // Prefer the USB gadget on SteamOS (default there — the only transport Steam Input promotes); - // fall back to the universal UHID pad if the gadget is unavailable or disabled. - let opened = if crate::inject::steam_gadget::gadget_preferred() { - crate::inject::steam_gadget::ensure_modules(); - match crate::inject::steam_gadget::SteamDeckGadget::open(idx as u8) { - Ok(g) => { - tracing::info!( - index = idx, - "virtual Steam Deck created (USB gadget — Steam Input recognizes it)" - ); - Ok(DeckTransport::Gadget(g)) - } - Err(e) => { - tracing::warn!(error = %format!("{e:#}"), "USB-gadget Deck unavailable — falling back to UHID"); - SteamDeckPad::open(idx as u8).map(DeckTransport::Uhid) - } - } - } else { - SteamDeckPad::open(idx as u8).map(DeckTransport::Uhid) - }; - match opened { + match open_transport(idx as u8) { Ok(t) => { - if matches!(t, DeckTransport::Uhid(_)) { - tracing::info!(index = idx, "virtual Steam Deck created (UHID hid-steam)"); - } self.pads[idx] = Some(t); self.state[idx] = SteamState::neutral(); self.last_rumble[idx] = (0, 0); diff --git a/crates/punktfunk-host/src/inject/linux/steam_gadget.rs b/crates/punktfunk-host/src/inject/linux/steam_gadget.rs index b0e6764..e328640 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_gadget.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_gadget.rs @@ -70,23 +70,12 @@ const USB_RAW_EVENT_CONNECT: u32 = 1; const USB_RAW_EVENT_CONTROL: u32 = 2; const USB_SPEED_HIGH: u8 = 3; -// ---- captured-from-hardware descriptors (a real Steam Deck, 28DE:1205) ---- -#[rustfmt::skip] -const RDESC_MOUSE: &[u8] = &[ - 0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02, - 0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01, - 0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06, - 0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0]; -#[rustfmt::skip] -const RDESC_KBD: &[u8] = &[ - 0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01, - 0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65, - 0x75,0x08,0x95,0x06,0x81,0x00,0xc0]; -#[rustfmt::skip] -const RDESC_CTRL: &[u8] = &[ // the real Deck controller, interface 2 (Usage Page 0xFFFF) - 0x06,0xff,0xff,0x09,0x01,0xa1,0x01,0x09,0x02,0x09,0x03,0x15,0x00,0x26,0xff,0x00, - 0x75,0x08,0x95,0x40,0x81,0x02,0x09,0x06,0x09,0x07,0x15,0x00,0x26,0xff,0x00,0x75, - 0x08,0x95,0x40,0xb1,0x02,0xc0]; +// Captured-from-hardware Deck descriptors + the `0x83`/`0xAE` feature contract live in the shared +// [`super::steam_proto`] module (single source of truth, also used by the usbip transport). +use super::steam_proto::{ + deck_serial, deck_unit_id, feature_reply, neutral_deck_report, RDESC_DECK_CTRL as RDESC_CTRL, + RDESC_DECK_KBD as RDESC_KBD, RDESC_DECK_MOUSE as RDESC_MOUSE, +}; const DEV_DESC: [u8; 18] = [ 18, 1, 0x00, 0x02, // bLength, DEVICE, bcdUSB 2.00 @@ -246,9 +235,9 @@ impl SteamDeckGadget { bail!("raw_gadget RUN: {}", std::io::Error::last_os_error()); } - let serial = format!("PFDECK{index:04}"); - let unit_id = 0x5046_0000u32 | index as u32; // "PF" + index — a synthetic per-instance device id - let report = Arc::new(Mutex::new(neutral_report())); + let serial = deck_serial(index); + let unit_id = deck_unit_id(index); // "PF" + index — a synthetic per-instance device id + let report = Arc::new(Mutex::new(neutral_deck_report())); let feedback = Arc::new(Mutex::new(Default::default())); let running = Arc::new(AtomicBool::new(true)); let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1)); @@ -319,14 +308,6 @@ impl Drop for SteamDeckGadget { } } -fn neutral_report() -> [u8; 64] { - let mut r = [0u8; 64]; - r[0] = 0x01; - r[2] = 0x09; // ID_CONTROLLER_DECK_STATE - r[3] = 0x3C; - r -} - fn copy_cstr(dst: &mut [u8], s: &str) { let n = s.len().min(dst.len() - 1); dst[..n].copy_from_slice(&s.as_bytes()[..n]); @@ -488,58 +469,6 @@ fn handle_control( } } -/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command. Steam's -/// `GetControllerInfo` reads the `0x83` attributes + the `0xAE` serial; **serving the real `0x83` -/// blob is what stops Steam re-probing** (the gamepad-evdev churn). The contract (`0x83` 9-attribute -/// layout + the `0xAE` string format) was captured from a physical Steam Deck via hidraw. `unit_id` -/// stamps a per-instance value into the device-id attributes (`0x0a`/`0x04`) so a gadget never -/// collides with a real Deck or another gadget. -fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; 64] { - let cmd = last_set.first().copied().unwrap_or(0xAE); - let mut r = [0u8; 64]; - match cmd { - 0x83 => { - // GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)]. - r[0] = 0x83; - r[1] = 0x2d; - let attrs: [(u8, u32); 9] = [ - (0x01, 0x1205), // product id - (0x02, 0), - (0x0a, unit_id), // unit serial number (per-instance) - (0x04, unit_id ^ 0x5555_5555), - (0x09, 0x2e), - (0x0b, 0x0fa0), - (0x0d, 0), - (0x0c, 0), - (0x0e, 0), - ]; - let mut o = 2; - for (id, val) in attrs { - r[o] = id; - r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes()); - o += 5; - } - } - 0xAE => { - // GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr - // 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id. - let attr = last_set.get(2).copied().unwrap_or(0x01); - let b = serial.as_bytes(); - let len = b.len().clamp(1, 20); - r[0] = 0xAE; - r[1] = len as u8; - r[2] = attr; - r[3..3 + len].copy_from_slice(&b[..len]); - } - _ => { - // Settings read-back (e.g. 0x87): echo the host's last command + data. - let n = last_set.len().min(64); - r[..n].copy_from_slice(&last_set[..n]); - } - } - r -} - fn hid_desc_for(cfg: &[u8], idx: u8) -> Vec { // The HID descriptors live right after each interface descriptor in the config blob. // Offsets: cfg(9) | i0(9) h0(9) e0(7) | i1(9) h1(9) e1(7) | i2(9) h2(9) e2(7) @@ -586,7 +515,7 @@ fn stream_loop( let r = report .lock() .map(|g| *g) - .unwrap_or_else(|_| neutral_report()); + .unwrap_or_else(|_| neutral_deck_report()); let mut buf = [0u8; EPIO_HDR + 64]; buf[0..2].copy_from_slice(&(ep as u16).to_ne_bytes()); buf[4..8].copy_from_slice(&(64u32).to_ne_bytes()); diff --git a/crates/punktfunk-host/src/inject/linux/steam_usbip.rs b/crates/punktfunk-host/src/inject/linux/steam_usbip.rs new file mode 100644 index 0000000..a186f6d --- /dev/null +++ b/crates/punktfunk-host/src/inject/linux/steam_usbip.rs @@ -0,0 +1,733 @@ +//! Virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean, universal +//! alternative to [`super::steam_gadget`] (`raw_gadget` + `dummy_hcd`, SteamOS-only). +//! +//! Like the gadget, this presents a *real* 3-interface USB Steam Deck (mouse = interface 0, keyboard +//! = 1, **controller = 2**) — the interface-2 layout Steam's own driver filters on, so Steam Input +//! promotes it (a UHID Deck, `Interface: -1`, never is). Unlike the gadget it needs no out-of-tree +//! module: `vhci_hcd` is in-tree + signed on SteamOS, Bazzite, and ~every distro, loads under Secure +//! Boot, and needs no MOK. A userspace [`usbip_sim`] server emulates the Deck; the local `vhci_hcd` +//! attaches it. **Validated on Bazzite**: `vhci_hcd` enumerates the 3-interface Deck, `hid-steam` +//! binds it, and Steam reserves an XInput slot — identical recognition to the gadget. +//! +//! The device model + the USB/IP protocol come from the vendored [`usbip_sim`] crate (the upstream +//! `usbip` crate trimmed of its libusb host mode); the captured descriptors + the `0x83`/`0xAE` +//! feature contract come from the shared [`super::steam_proto`] (one source of truth with the gadget). +//! +//! **Attach** is in-process by default (no external `usbip` CLI dependency — the production goal): we +//! run the emulation server on a loopback TCP port, connect to it ourselves, perform the +//! `OP_REQ_IMPORT` handshake, then hand the connected socket fd to `vhci_hcd` via its sysfs `attach` +//! file. If anything in that path fails we fall back to the widely-packaged `usbip` CLI; if *that* +//! also fails, [`open`](SteamDeckUsbip::open) returns `Err` and the caller degrades to UHID. + +use super::steam_proto::{ + deck_serial, deck_unit_id, feature_reply, neutral_deck_report, parse_steam_output, + SteamFeedback, SteamState, RDESC_DECK_CTRL, RDESC_DECK_KBD, RDESC_DECK_MOUSE, +}; +use anyhow::{bail, Context, Result}; +use std::any::Any; +use std::collections::HashSet; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::os::fd::AsRawFd; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; +use usbip_sim::{ + Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbIpServer, + Version, +}; + +const STEAM_VENDOR: u16 = 0x28DE; +const STEAMDECK_PRODUCT: u16 = 0x1205; +/// The single device's USB/IP bus id (one device per server, so the fixed default is fine). +const BUS_ID: &str = "0-0-0"; +/// The usbip default TCP port — the server must listen here for the `usbip` CLI fallback to attach. +const USBIP_TCP_PORT: u16 = 3240; + +/// Build the 9-byte HID class descriptor inserted between the interface and endpoint descriptors. +fn hid_desc(report_len: usize, country: u8) -> Vec { + let l = report_len as u16; + #[rustfmt::skip] + let d = vec![0x09, 0x21, 0x10, 0x01, country, 1, 0x22, (l & 0xff) as u8, (l >> 8) as u8]; + d +} + +/// The Deck **controller** interface (vendor HID, interface 2): answers the HID feature reports +/// (descriptor / `0x83` attributes / `0xAE` serial), streams the current 64-byte state on the +/// interrupt-IN endpoint, and surfaces rumble written via SET_REPORT. +#[derive(Debug)] +struct ControllerHandler { + /// The current 64-byte Deck input report, shared with [`SteamDeckUsbip::write_state`]. + report: Arc>, + /// Rumble extracted from the kernel's SET_REPORTs, drained by [`SteamDeckUsbip::service`]. + feedback: Arc>, + /// The host's last SET_REPORT command (drives [`feature_reply`]). + last_set: Vec, + serial: String, + unit_id: u32, +} + +impl UsbInterfaceHandler for ControllerHandler { + fn get_class_specific_descriptor(&self) -> Vec { + hid_desc(RDESC_DECK_CTRL.len(), 33) + } + fn handle_urb( + &mut self, + _interface: &UsbInterface, + ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + req: &[u8], + ) -> std::io::Result> { + if ep.is_ep0() { + Ok(match (setup.request_type, setup.request) { + // GET report descriptor (standard, interface recipient). + (0x81, 0x06) if (setup.value >> 8) == 0x22 => RDESC_DECK_CTRL.to_vec(), + // HID GET_REPORT (feature) — the Deck `0x83`/`0xAE` contract. + (0xA1, 0x01) => feature_reply(&self.last_set, &self.serial, self.unit_id).to_vec(), + // HID SET_REPORT — remember the command (for the next feature reply) + surface rumble. + (0x21, 0x09) => { + self.last_set = req.to_vec(); + // `parse_steam_output` expects `[report-id(0), cmd, …]`; EP0 OUT data is `[cmd, …]`. + let mut framed = Vec::with_capacity(req.len() + 1); + framed.push(0); + framed.extend_from_slice(req); + let fb = parse_steam_output(&framed); + if fb.rumble.is_some() { + if let Ok(mut g) = self.feedback.lock() { + *g = fb; + } + } + vec![] + } + (0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL + _ => vec![], + }) + } else if let Direction::In = ep.direction() { + // Interrupt-IN poll: return the current report. The vendored sim paces interrupt-IN by + // bInterval (vhci_hcd does NOT throttle the server side), so this isn't a busy spin. + let r = self + .report + .lock() + .map(|g| *g) + .unwrap_or_else(|_| neutral_deck_report()); + Ok(r.to_vec()) + } else { + Ok(vec![]) + } + } + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +/// A minimal idle HID interface (mouse / keyboard) — serves only its report descriptor. +#[derive(Debug)] +struct IdleHidHandler { + report_desc: Vec, +} +impl UsbInterfaceHandler for IdleHidHandler { + fn get_class_specific_descriptor(&self) -> Vec { + hid_desc(self.report_desc.len(), 0) + } + fn handle_urb( + &mut self, + _i: &UsbInterface, + ep: UsbEndpoint, + _l: u32, + setup: SetupPacket, + _req: &[u8], + ) -> std::io::Result> { + if ep.is_ep0() && setup.request == 0x06 && (setup.value >> 8) == 0x22 { + Ok(self.report_desc.clone()) + } else { + Ok(vec![]) + } + } + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +fn boxed( + h: impl UsbInterfaceHandler + Send + 'static, +) -> Arc>> { + Arc::new(Mutex::new(Box::new(h))) +} +fn ep(addr: u8, mps: u16) -> UsbEndpoint { + UsbEndpoint { + address: addr, + attributes: 0x03, // interrupt + max_packet_size: mps, + interval: 4, + } +} + +/// Assemble the simulated 3-interface USB Deck. The controller handler shares `report` + `feedback` +/// with the owning [`SteamDeckUsbip`]. +fn build_device( + index: u8, + report: &Arc>, + feedback: &Arc>, +) -> UsbDevice { + let mut dev = UsbDevice::new(0); // one device per server; bus_id stays the default "0-0-0". + dev.vendor_id = STEAM_VENDOR; + dev.product_id = STEAMDECK_PRODUCT; + dev.usb_version = Version::from(0x0200u16); // bcdUSB 2.00 + dev.device_bcd = Version::from(0x0300u16); // bcdDevice 3.00 (matches the gadget) + dev.set_manufacturer_name("Valve Software"); + dev.set_product_name("Steam Deck Controller"); + dev.set_serial_number(&deck_serial(index)); + dev.with_interface( + 0x03, + 0x00, + 0x02, + Some("mouse"), + vec![ep(0x81, 8)], + boxed(IdleHidHandler { + report_desc: RDESC_DECK_MOUSE.to_vec(), + }), + ) + .with_interface( + 0x03, + 0x01, + 0x01, + Some("keyboard"), + vec![ep(0x82, 8)], + boxed(IdleHidHandler { + report_desc: RDESC_DECK_KBD.to_vec(), + }), + ) + .with_interface( + 0x03, + 0x00, + 0x00, + Some("controller"), + vec![ep(0x83, 64)], + boxed(ControllerHandler { + report: report.clone(), + feedback: feedback.clone(), + last_set: vec![], + serial: deck_serial(index), + unit_id: deck_unit_id(index), + }), + ) +} + +/// Owns the emulation-server thread (a dedicated current-thread tokio runtime) and stops it on drop. +/// Run on its own thread so `SteamDeckUsbip::open` works whether or not the caller is inside a tokio +/// runtime (creating a runtime inside one would panic). +struct ServerThread { + stop: Arc, + join: Option>, +} + +impl ServerThread { + /// Spawn the server on `listener`, serving exactly the one simulated `dev`. + fn spawn(listener: std::net::TcpListener, dev: UsbDevice) -> Result { + let stop = Arc::new(tokio::sync::Notify::new()); + let stop_t = stop.clone(); + let join = std::thread::Builder::new() + .name("pf-deck-usbip".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + tracing::error!(error = %e, "usbip server runtime build failed"); + return; + } + }; + rt.block_on(run_server( + listener, + Arc::new(UsbIpServer::new_simulated(vec![dev])), + stop_t, + )); + }) + .context("spawn usbip server thread")?; + Ok(ServerThread { + stop, + join: Some(join), + }) + } +} + +impl Drop for ServerThread { + fn drop(&mut self) { + self.stop.notify_one(); + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} + +/// Accept loop: serve each USB/IP connection with the vendored `usbip_sim::handler` until stopped. +async fn run_server( + listener: std::net::TcpListener, + server: Arc, + stop: Arc, +) { + let listener = match tokio::net::TcpListener::from_std(listener) { + Ok(l) => l, + Err(e) => { + tracing::error!(error = %e, "usbip TcpListener::from_std failed"); + return; + } + }; + loop { + tokio::select! { + _ = stop.notified() => break, + r = listener.accept() => match r { + Ok((mut sock, _)) => { + let server = server.clone(); + tokio::spawn(async move { + let _ = usbip_sim::handler(&mut sock, server).await; + }); + } + Err(e) => { + tracing::warn!(error = %e, "usbip accept error"); + break; + } + } + } + } +} + +/// A virtual Steam Deck presented over USB/IP. Dropping it detaches the `vhci_hcd` port (the device +/// disappears, Steam releases its slot) and stops the emulation server. +pub struct SteamDeckUsbip { + report: Arc>, + feedback: Arc>, + /// The `vhci_hcd` port we attached to — written to the sysfs `detach` file on drop. + vhci_port: u16, + /// Kept alive so the connected socket fd we handed to `vhci_hcd` stays valid (in-process attach + /// only; the CLI hands its own fd to the kernel and exits, so this is `None` there). + _client_sock: Option, + /// Emulation-server thread; dropped (stopped) after the detach. + _server: ServerThread, + seq: u32, +} + +impl SteamDeckUsbip { + /// Bind a virtual Deck and attach it locally via `vhci_hcd`. `index` varies only the serial. + /// Requires `vhci_hcd` loaded and root (the sysfs attach / the CLI both need it). Tries the + /// in-process sysfs attach first, then the `usbip` CLI; `PUNKTFUNK_USBIP_ATTACH=inproc|cli` + /// pins one path (for debugging). + pub fn open(index: u8) -> Result { + ensure_modules(); + if vhci_base().is_none() { + bail!( + "vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?" + ); + } + let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok(); + if mode.as_deref() != Some("cli") { + match Self::open_in_process(index) { + Ok(d) => return Ok(d), + Err(e) if mode.as_deref() == Some("inproc") => return Err(e), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI") + } + } + } + Self::open_via_cli(index) + } + + /// In-process attach: emulate on a loopback port, do the import handshake ourselves, hand the + /// connected socket to `vhci_hcd` via sysfs. No external dependency. + fn open_in_process(index: u8) -> Result { + let report = Arc::new(Mutex::new(neutral_deck_report())); + let feedback = Arc::new(Mutex::new(SteamFeedback::default())); + let dev = build_device(index, &report, &feedback); + + // An ephemeral loopback port (avoids contending the usbip default with another pad). + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?; + let port = listener + .local_addr() + .context("usbip server local_addr")? + .port(); + listener + .set_nonblocking(true) + .context("usbip listener set_nonblocking")?; + let server = ServerThread::spawn(listener, dev)?; + + // Connect to our own server and run the OP_REQ_IMPORT handshake. + let mut sock = connect_loopback(port).context("connect to usbip server")?; + let (devid, speed) = import_handshake(&mut sock).context("usbip import handshake")?; + + // Hand the connected socket to vhci_hcd. Clear BOTH timeouts first: the kernel's vhci rx/tx + // threads honour SO_RCVTIMEO/SO_SNDTIMEO on this socket, so the 3s handshake timeouts would + // otherwise tear the device down after 3s idle (rx) or a 3s-blocked send (tx). + let vhci_port = vhci_find_free_port(speed).context("find a free vhci port")?; + sock.set_read_timeout(None).ok(); + sock.set_write_timeout(None).ok(); + vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?; + + tracing::info!( + index, + vhci_port, + "virtual Steam Deck attached via usbip (in-process — Steam Input recognizes it)" + ); + Ok(SteamDeckUsbip { + report, + feedback, + vhci_port, + _client_sock: Some(sock), + _server: server, + seq: 0, + }) + } + + /// Fallback: emulate on the usbip default port and let the `usbip` CLI attach (it picks the vhci + /// port itself; we recover it by diffing the sysfs status). + fn open_via_cli(index: u8) -> Result { + let report = Arc::new(Mutex::new(neutral_deck_report())); + let feedback = Arc::new(Mutex::new(SteamFeedback::default())); + let dev = build_device(index, &report, &feedback); + + let listener = std::net::TcpListener::bind(("127.0.0.1", USBIP_TCP_PORT)) + .with_context(|| format!("bind usbip default port {USBIP_TCP_PORT} for CLI attach"))?; + listener + .set_nonblocking(true) + .context("usbip listener set_nonblocking")?; + let server = ServerThread::spawn(listener, dev)?; + + let before = vhci_used_ports(); + usbip_attach_cli().context("usbip CLI attach")?; + let vhci_port = wait_for_new_port(&before) + .context("could not determine the vhci port the usbip CLI attached to")?; + + tracing::info!( + index, + vhci_port, + "virtual Steam Deck attached via usbip (CLI — Steam Input recognizes it)" + ); + Ok(SteamDeckUsbip { + report, + feedback, + vhci_port, + _client_sock: None, + _server: server, + seq: 0, + }) + } + + /// Serialize `st` into the 64-byte Deck report streamed on the controller interrupt-IN endpoint. + pub fn write_state(&mut self, st: &SteamState) { + self.seq = self.seq.wrapping_add(1); + let mut r = [0u8; 64]; + super::steam_proto::serialize_deck_state(&mut r, st, self.seq); + if let Ok(mut g) = self.report.lock() { + *g = r; + } + } + + /// Drain any rumble feedback the kernel/Steam wrote to the device. + pub fn service(&mut self) -> SteamFeedback { + self.feedback + .lock() + .map(|mut f| std::mem::take(&mut *f)) + .unwrap_or_default() + } +} + +impl Drop for SteamDeckUsbip { + fn drop(&mut self) { + // Detach the vhci port first (the kernel closes its end of the socket + tears down the + // device); `_client_sock` + `_server` then drop, closing our side + stopping the server. + if let Err(e) = vhci_detach(self.vhci_port) { + tracing::debug!(port = self.vhci_port, error = %e, "vhci detach failed (device may already be gone)"); + } + } +} + +// ---- USB/IP import handshake (we act as the usbip *client* before handing the fd to the kernel) ---- + +const USBIP_VERSION: u16 = 0x0111; +const OP_REQ_IMPORT: u16 = 0x8003; + +/// Connect to our own loopback server, retrying briefly while the server thread comes up. +fn connect_loopback(port: u16) -> Result { + let addr = ("127.0.0.1", port); + let mut last = None; + for _ in 0..50 { + match TcpStream::connect(addr) { + Ok(s) => { + s.set_nodelay(true).ok(); + return Ok(s); + } + Err(e) => { + last = Some(e); + std::thread::sleep(Duration::from_millis(10)); + } + } + } + Err(anyhow::anyhow!( + "connect 127.0.0.1:{port}: {}", + last.map(|e| e.to_string()).unwrap_or_default() + )) +} + +/// Send `OP_REQ_IMPORT` for [`BUS_ID`] and read `OP_REP_IMPORT`, returning `(devid, speed)` parsed +/// from the device record (the same `devid = bus_num<<16 | dev_num` + speed `vhci_hcd` wants). The +/// whole 320-byte reply MUST be consumed here so the socket starts clean at the kernel's first +/// `USBIP_CMD_SUBMIT`. +fn import_handshake(sock: &mut TcpStream) -> Result<(u32, u32)> { + // Bounded so a non-responsive server can't head-block the per-session input thread (this talks + // to our own in-process loopback server, so a working handshake completes in well under a ms). + sock.set_read_timeout(Some(Duration::from_secs(1))).ok(); + sock.set_write_timeout(Some(Duration::from_secs(1))).ok(); + + let mut req = Vec::with_capacity(40); + req.extend_from_slice(&USBIP_VERSION.to_be_bytes()); + req.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes()); + req.extend_from_slice(&0u32.to_be_bytes()); // status + let mut busid = [0u8; 32]; + let b = BUS_ID.as_bytes(); + busid[..b.len()].copy_from_slice(b); + req.extend_from_slice(&busid); + sock.write_all(&req).context("send OP_REQ_IMPORT")?; + + // Reply: version(2) code(2) status(4), then the 312-byte device record on success. + let mut header = [0u8; 8]; + sock.read_exact(&mut header) + .context("read OP_REP_IMPORT header")?; + let status = u32::from_be_bytes([header[4], header[5], header[6], header[7]]); + if status != 0 { + bail!("OP_REP_IMPORT refused (status={status}) — device {BUS_ID} not exported?"); + } + let mut dev = [0u8; 312]; + sock.read_exact(&mut dev) + .context("read OP_REP_IMPORT device record")?; + // Device record layout: path[256], bus_id[32], bus_num(4 BE)@288, dev_num(4 BE)@292, speed(4)@296. + let be = |o: usize| u32::from_be_bytes([dev[o], dev[o + 1], dev[o + 2], dev[o + 3]]); + let bus_num = be(288); + let dev_num = be(292); + let speed = be(296); + Ok(((bus_num << 16) | dev_num, speed)) +} + +// ---- vhci_hcd sysfs plumbing ---- + +/// Best-effort load of `vhci_hcd` (in-tree + signed on SteamOS/Bazzite/most distros). +pub fn ensure_modules() { + let _ = Command::new("modprobe").arg("vhci_hcd").status(); +} + +/// Run `usbip attach -r 127.0.0.1 -b 0-0-0`, bounded by a deadline so a hung CLI can't head-block +/// the per-session input thread indefinitely (the caller runs this inline on that thread). +fn usbip_attach_cli() -> Result<()> { + let mut child = Command::new("usbip") + .args(["attach", "-r", "127.0.0.1", "-b", BUS_ID]) + .spawn() + .context("spawn `usbip attach` (is usbip-utils installed?)")?; + let deadline = Instant::now() + Duration::from_secs(6); + loop { + match child.try_wait().context("wait on `usbip attach`")? { + Some(st) if st.success() => return Ok(()), + Some(st) => bail!("`usbip attach` exited with {st}"), + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + bail!("`usbip attach` timed out (>6s) — killed"); + } + None => std::thread::sleep(Duration::from_millis(20)), + } + } +} + +/// Whether a usbip attach should be attempted at all. Default on (the universal Steam-promotable +/// transport on non-SteamOS hosts); `PUNKTFUNK_STEAM_USBIP=0` forces it off, `=1` forces it on. +/// [`open`](SteamDeckUsbip::open) still degrades gracefully if `vhci_hcd` turns out to be absent. +pub fn usbip_preferred() -> bool { + !matches!( + std::env::var("PUNKTFUNK_STEAM_USBIP").ok().as_deref(), + Some("0") | Some("false") + ) +} + +/// The `vhci_hcd.0` (or legacy `vhci_hcd`) platform sysfs directory, if present. +fn vhci_base() -> Option { + for p in [ + "/sys/devices/platform/vhci_hcd.0", + "/sys/devices/platform/vhci_hcd", + ] { + let base = Path::new(p); + if base.join("status").exists() { + return Some(base.to_path_buf()); + } + } + None +} + +fn read_status() -> Result { + let base = vhci_base().context("vhci_hcd sysfs not present")?; + std::fs::read_to_string(base.join("status")).context("read vhci_hcd status") +} + +/// One parsed `status` row: `(port, hub_is_superspeed, sta)`. Handles both the modern +/// `hub port sta …` and the legacy `port sta …` column layouts; returns `None` for header/blank rows. +fn parse_status_row(line: &str) -> Option<(u16, bool, u32)> { + let t: Vec<&str> = line.split_whitespace().collect(); + if t.is_empty() { + return None; + } + let (hub_ss, port_str, sta_str) = if t[0] == "hs" || t[0] == "ss" { + (Some(t[0] == "ss"), *t.get(1)?, *t.get(2)?) + } else if t[0].chars().all(|c| c.is_ascii_digit()) { + (None, t[0], *t.get(1)?) // legacy: port sta … + } else { + return None; // header ("hub"/"prt"/"port" …) + }; + let port = port_str.parse::().ok()?; + let sta = sta_str.parse::().ok()?; + Some((port, hub_ss.unwrap_or(false), sta)) +} + +/// `sta == 4` is `VDEV_ST_NULL` (a free port). +const VDEV_ST_NULL: u32 = 4; + +/// Pick a free `vhci_hcd` port matching the device speed (`usbip_speed >= 5` ⇒ SuperSpeed hub). +fn vhci_find_free_port(usbip_speed: u32) -> Result { + let want_ss = usbip_speed >= 5; + let status = read_status()?; + for line in status.lines() { + if let Some((port, is_ss, sta)) = parse_status_row(line) { + if sta == VDEV_ST_NULL && is_ss == want_ss { + return Ok(port); + } + } + } + // Speed-class match failed (legacy single-hub status): take any free port. + for line in status.lines() { + if let Some((port, _, sta)) = parse_status_row(line) { + if sta == VDEV_ST_NULL { + return Ok(port); + } + } + } + bail!("no free vhci_hcd port (all ports in use?)") +} + +/// Ports currently in use (`sta != VDEV_ST_NULL`) — snapshotted around a CLI attach to recover its port. +fn vhci_used_ports() -> HashSet { + read_status() + .unwrap_or_default() + .lines() + .filter_map(parse_status_row) + .filter(|&(_, _, sta)| sta != VDEV_ST_NULL) + .map(|(port, _, _)| port) + .collect() +} + +/// Poll the status file (briefly) for a port that became used since `before` — the one the CLI attached. +fn wait_for_new_port(before: &HashSet) -> Result { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(p) = vhci_used_ports().difference(before).copied().min() { + return Ok(p); + } + if Instant::now() >= deadline { + bail!("no newly-attached vhci port appeared after `usbip attach`"); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn vhci_attach(port: u16, sockfd: i32, devid: u32, speed: u32) -> Result<()> { + let base = vhci_base().context("vhci_hcd sysfs not present")?; + let line = format!("{port} {sockfd} {devid} {speed}"); + std::fs::write(base.join("attach"), line) + .with_context(|| format!("write vhci_hcd attach (port {port}) — root?")) +} + +fn vhci_detach(port: u16) -> Result<()> { + let base = vhci_base().context("vhci_hcd sysfs not present")?; + std::fs::write(base.join("detach"), format!("{port}")).context("write vhci_hcd detach") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `status` parser handles the modern `hub port sta …` layout, the legacy `port sta …` + /// layout, and skips header/blank lines — a slip here would mean attaching to a busy port. + #[test] + fn status_parser_handles_both_layouts() { + // modern + assert_eq!( + parse_status_row("hs 0000 004 000 00000000 000000 0-0"), + Some((0, false, 4)) + ); + assert_eq!( + parse_status_row("ss 0008 006 000 00000000 000000 0-0"), + Some((8, true, 6)) + ); + // legacy (no hub column) + assert_eq!( + parse_status_row("0001 004 000 00000000 000000 0-0"), + Some((1, false, 4)) + ); + // header / blank + assert_eq!( + parse_status_row("hub port sta spd dev sockfd local_busid"), + None + ); + assert_eq!(parse_status_row(""), None); + } + + /// A free HS port is preferred for an HS device; a free SS port for an SS device. + #[test] + fn free_port_selection_matches_speed() { + let status = "hub port sta spd dev sockfd local_busid\n\ + hs 0000 006 000 00000000 000000 0-0\n\ + hs 0001 004 000 00000000 000000 0-0\n\ + ss 0008 004 000 00000000 000000 0-0\n"; + // Reuse the parser directly (vhci_find_free_port reads sysfs; test the selection logic). + let hs = status + .lines() + .filter_map(parse_status_row) + .find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && !is_ss) + .map(|(p, _, _)| p); + let ss = status + .lines() + .filter_map(parse_status_row) + .find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && is_ss) + .map(|(p, _, _)| p); + assert_eq!(hs, Some(1)); + assert_eq!(ss, Some(8)); + } + + /// On-box smoke test (needs root + `vhci_hcd`): attach a virtual Deck, confirm `hid-steam` binds + /// it (the `Steam Deck` evdev appears) and that it tears down on drop. `#[ignore]`d in CI. + #[test] + #[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"] + fn usbip_deck_binds_and_tears_down() { + ensure_modules(); + let mut pad = SteamDeckUsbip::open(0).expect("open SteamDeckUsbip (root + vhci_hcd?)"); + let st = SteamState::from_gamepad(punktfunk_core::input::gamepad::BTN_A, 0, 0, 0, 0, 0, 0); + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + pad.write_state(&st); + let _ = pad.service(); + std::thread::sleep(Duration::from_millis(8)); + } + let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default(); + assert!( + devs.contains("Steam Deck"), + "hid-steam did not bind the usbip Deck" + ); + drop(pad); + std::thread::sleep(Duration::from_millis(300)); + 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/inject/proto/steam_proto.rs b/crates/punktfunk-host/src/inject/proto/steam_proto.rs index b903193..e9b44d3 100644 --- a/crates/punktfunk-host/src/inject/proto/steam_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/steam_proto.rs @@ -349,6 +349,117 @@ pub fn parse_steam_output(data: &[u8]) -> SteamFeedback { fb } +// =========================================================================================== +// Real-USB Deck device contract (the gadget + usbip transports present a *real* 3-interface USB +// Deck so Steam Input promotes it; the UHID path above uses the minimal [`STEAMDECK_RDESC`]). +// +// These descriptors are captured verbatim from a physical Steam Deck (28DE:1205): mouse = +// interface 0, keyboard = interface 1, **controller = interface 2** (the interface number Steam's +// own driver filters on — the reason a UHID Deck, `Interface: -1`, is never promoted). The +// `0x83`/`0xAE` feature contract is what stops Steam re-probing (the gamepad-evdev churn). Shared +// by [`super::super::steam_gadget`] (raw_gadget) and [`super::super::steam_usbip`] (usbip/vhci). +// =========================================================================================== + +/// Captured Deck **mouse** report descriptor (interface 0, EP 0x81). +#[rustfmt::skip] +pub const RDESC_DECK_MOUSE: &[u8] = &[ + 0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02, + 0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01, + 0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06, + 0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0]; +/// Captured Deck **keyboard** (boot) report descriptor (interface 1, EP 0x82). +#[rustfmt::skip] +pub const RDESC_DECK_KBD: &[u8] = &[ + 0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01, + 0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65, + 0x75,0x08,0x95,0x06,0x81,0x00,0xc0]; +/// Captured Deck **controller** report descriptor (interface 2, EP 0x83; Usage Page `0xFFFF`, +/// `bCountryCode 33`). The vendor-defined report the `hid-steam` driver binds. +#[rustfmt::skip] +pub const RDESC_DECK_CTRL: &[u8] = &[ + 0x06,0xff,0xff,0x09,0x01,0xa1,0x01,0x09,0x02,0x09,0x03,0x15,0x00,0x26,0xff,0x00, + 0x75,0x08,0x95,0x40,0x81,0x02,0x09,0x06,0x09,0x07,0x15,0x00,0x26,0xff,0x00,0x75, + 0x08,0x95,0x40,0xb1,0x02,0xc0]; + +/// Per-instance Deck unit id stamped into the `0x83` GET_ATTRIBUTES device-id attrs (`0x0a`/`0x04`) +/// so a virtual Deck never collides with a real one or another instance. `"PF"` high word + index. +pub fn deck_unit_id(index: u8) -> u32 { + 0x5046_0000 | index as u32 +} + +/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`; Steam rejects +/// a too-short/oddly-formatted one as "Invalid or missing unit serial number" and substitutes its +/// own — benign, but we present a clean 12-char one). Derived from [`deck_unit_id`] so the `0xAE` +/// serial reply and the `0x83` unit-id attrs stay consistent. +pub fn deck_serial(index: u8) -> String { + format!("PFDK{:08X}", deck_unit_id(index)) +} + +/// The neutral 64-byte Deck input report (header only, all controls released) — the report the +/// real-USB transports stream until the first [`serialize_deck_state`] call updates it. +pub fn neutral_deck_report() -> [u8; STEAM_REPORT_LEN] { + let mut r = [0u8; STEAM_REPORT_LEN]; + r[0] = 0x01; + r[2] = ID_CONTROLLER_DECK_STATE; + r[3] = 0x3C; + r +} + +/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command, for the *real-USB* +/// Deck (gadget + usbip). Steam's `GetControllerInfo` reads the `0x83` attributes + the `0xAE` +/// serial; **serving the real `0x83` blob is what stops Steam re-probing** (the gamepad-evdev churn). +/// The 9-attribute `0x83` layout + the `0xAE` string format were captured from a physical Deck via +/// hidraw. `unit_id` (see [`deck_unit_id`]) stamps a per-instance value into the device-id attrs. +/// +/// Note this is the raw 64-byte EP0 feature payload (command id first, no report-id prefix) — the USB +/// control path, distinct from [`serial_reply`] which carries the UHID report-id byte the kernel +/// strips. +pub fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; STEAM_REPORT_LEN] { + let cmd = last_set.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE); + let mut r = [0u8; STEAM_REPORT_LEN]; + match cmd { + ID_GET_ATTRIBUTES_VALUES => { + // GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)]. + r[0] = ID_GET_ATTRIBUTES_VALUES; + r[1] = 0x2d; + let attrs: [(u8, u32); 9] = [ + (0x01, 0x1205), // product id + (0x02, 0), + (0x0a, unit_id), // unit serial number (per-instance) + (0x04, unit_id ^ 0x5555_5555), + (0x09, 0x2e), + (0x0b, 0x0fa0), + (0x0d, 0), + (0x0c, 0), + (0x0e, 0), + ]; + let mut o = 2; + for (id, val) in attrs { + r[o] = id; + r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes()); + o += 5; + } + } + ID_GET_STRING_ATTRIBUTE => { + // GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr + // 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id. + let attr = last_set.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL); + let b = serial.as_bytes(); + let len = b.len().clamp(1, 20); + r[0] = ID_GET_STRING_ATTRIBUTE; + r[1] = len as u8; + r[2] = attr; + r[3..3 + len].copy_from_slice(&b[..len]); + } + _ => { + // Settings read-back (e.g. 0x87): echo the host's last command + data. + let n = last_set.len().min(STEAM_REPORT_LEN); + r[..n].copy_from_slice(&last_set[..n]); + } + } + r +} + #[cfg(test)] mod tests { use super::*; @@ -532,4 +643,42 @@ mod tests { d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble assert_eq!(parse_steam_output(&d).rumble, None); } + + /// The shared real-USB Deck feature contract (gadget + usbip): the `0x83` GET_ATTRIBUTES reply + /// carries the 9-attribute blob with the per-instance unit id, and the `0xAE` reply carries the + /// Steam-accepted serial — both keyed off the host's last SET_REPORT command. A slip here is the + /// gamepad-evdev churn (Steam re-probing). + #[test] + fn deck_feature_reply_contract() { + let serial = deck_serial(0); + let unit_id = deck_unit_id(0); + assert_eq!(serial, "PFDK50460000"); // 12-char alphanumeric, derived from the unit id + assert_eq!(serial.len(), 12); + + // 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot. + let r = feature_reply(&[ID_GET_ATTRIBUTES_VALUES], &serial, unit_id); + assert_eq!(r[0], ID_GET_ATTRIBUTES_VALUES); + assert_eq!(r[1], 0x2d); + assert_eq!(r[12], 0x0a); // 3rd attr id (slots at 2,7,12,…) + assert_eq!( + u32::from_le_bytes([r[13], r[14], r[15], r[16]]), + unit_id, + "unit serial attribute must carry the per-instance unit id" + ); + + // 0xAE GET_STRING_ATTRIBUTE: [0xAE, len, attr(0x01), ascii serial…]. + let r = feature_reply( + &[ID_GET_STRING_ATTRIBUTE, 0, ATTRIB_STR_UNIT_SERIAL], + &serial, + unit_id, + ); + assert_eq!(r[0], ID_GET_STRING_ATTRIBUTE); + assert_eq!(r[1] as usize, serial.len()); + assert_eq!(r[2], ATTRIB_STR_UNIT_SERIAL); + assert_eq!(&r[3..3 + serial.len()], serial.as_bytes()); + + // Distinct pad indices get distinct unit ids + serials (no collision between virtual Decks). + assert_ne!(deck_unit_id(0), deck_unit_id(1)); + assert_ne!(deck_serial(0), deck_serial(1)); + } } diff --git a/crates/punktfunk-host/vendor/usbip-sim/Cargo.toml b/crates/punktfunk-host/vendor/usbip-sim/Cargo.toml new file mode 100644 index 0000000..0f1a06a --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/Cargo.toml @@ -0,0 +1,33 @@ +# Vendored + trimmed copy of the `usbip` crate (jiegec/usbip v0.8.0, MIT), reduced to the +# USB/IP *server simulation* path only: we present a virtual Steam Deck and let the local +# `vhci_hcd` attach it. The upstream crate hard-depends on `rusb`→`libusb1-sys` (for its USB +# *host* mode, which we do not use and which would add a libusb runtime dep + break `musl`), +# so the host modules (`host.rs`, the `rusb`/`nusb` device constructors) and the helper +# interface handlers (`cdc.rs`/`hid.rs`) are removed. What remains — the device model, the +# USB/IP protocol framing, and the `UsbInterfaceHandler` trait — is pure `std` + `tokio` and +# carries no libusb dependency. See `NOTICE` for upstream attribution. +[package] +name = "usbip-sim" +version = "0.8.0" +edition = "2021" +description = "Trimmed usbip server-simulation core (no libusb) — vendored for the virtual Steam Deck" +license = "MIT" +publish = false + +[lib] +name = "usbip_sim" +path = "src/lib.rs" + +[dependencies] +log = "0.4" +num-derive = "0.4" +num-traits = "0.2" +# `time` is for the interrupt-IN pacing added in device.rs (punktfunk modification — see NOTICE). +tokio = { version = "1", features = ["rt", "net", "io-util", "sync", "time"] } +# Upstream gated its struct derives behind a `serde` feature; kept (off by default) so the +# `#[cfg(feature = "serde")]` attributes stay valid and the vendored diff stays minimal. +serde = { version = "1", features = ["derive"], optional = true } + +[features] +default = [] +serde = ["dep:serde"] diff --git a/crates/punktfunk-host/vendor/usbip-sim/LICENSE-MIT b/crates/punktfunk-host/vendor/usbip-sim/LICENSE-MIT new file mode 100644 index 0000000..8d0ea9b --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2025 Jiajie Chen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/punktfunk-host/vendor/usbip-sim/NOTICE b/crates/punktfunk-host/vendor/usbip-sim/NOTICE new file mode 100644 index 0000000..6a26fc0 --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/NOTICE @@ -0,0 +1,23 @@ +This crate (`usbip-sim`) is a vendored, trimmed copy of: + + usbip v0.8.0 + Copyright (c) Jiajie Chen and contributors + https://github.com/jiegec/usbip + Licensed under the MIT License. + +Modifications by the punktfunk project: + - Removed the USB host modules (`src/host.rs`) and the `rusb`/`nusb` device + constructors in `src/lib.rs` (`with_rusb_*`, `with_nusb_*`, `new_from_host*`), + eliminating the libusb runtime dependency (which also broke `musl`). + - Removed the example helper interface handlers `src/cdc.rs` and `src/hid.rs`. + - Replaced the `rusb::Direction` re-export and `rusb::Version` conversions with + local definitions. + - Dropped the in-crate test modules (kept the library surface only). + - Paced interrupt/bulk IN endpoint transfers by bInterval in `device.rs` + `handle_urb` (so a simulated interrupt-IN mimics a real device's + NAK-until-bInterval behaviour rather than free-running over the loopback + link); added the tokio `time` feature for it. + +Only the USB/IP server *simulation* path is retained: the device model, the +USB/IP wire protocol, and the `UsbInterfaceHandler` trait. The original MIT +license text is reproduced in LICENSE-MIT. diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/consts.rs b/crates/punktfunk-host/vendor/usbip-sim/src/consts.rs new file mode 100644 index 0000000..0ccc133 --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/src/consts.rs @@ -0,0 +1,122 @@ +use super::*; + +/// A list of known USB speeds +#[derive(Copy, Clone, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum UsbSpeed { + Unknown = 0x0, + Low, + Full, + High, + Wireless, + Super, + SuperPlus, +} + +/// A list of defined USB class codes +// https://www.usb.org/defined-class-codes +#[derive(Copy, Clone, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum ClassCode { + SeeInterface = 0, + Audio, + CDC, + HID, + Physical = 0x05, + Image, + Printer, + MassStorage, + Hub, + CDCData, + SmartCard, + ContentSecurity = 0x0D, + Video, + PersonalHealthcare, + AudioVideo, + Billboard, + TypeCBridge, + Diagnostic = 0xDC, + WirelessController = 0xE0, + Misc = 0xEF, + ApplicationSpecific = 0xFE, + VendorSpecific = 0xFF, +} + +/// A list of defined USB endpoint attributes +#[derive(Copy, Clone, Debug, FromPrimitive)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum EndpointAttributes { + Control = 0, + Isochronous, + Bulk, + Interrupt, +} + +/// USB endpoint direction: IN or OUT. +/// +/// Upstream re-exported `rusb::Direction`; vendored locally so this crate carries no libusb +/// dependency. `UsbEndpoint::direction()` returns this, and `device.rs` matches on the variants. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Direction { + /// Host → device (`bEndpointAddress` bit 7 clear). + Out, + /// Device → host (`bEndpointAddress` bit 7 set). + In, +} + +/// Emulated max packet size of EP0 +pub const EP0_MAX_PACKET_SIZE: u16 = 64; + +/// A list of defined USB standard requests +/// from USB 2.0 standard Table 9.4. Standard Request Codes +#[derive(Copy, Clone, Debug, FromPrimitive)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum StandardRequest { + GetStatus = 0, + ClearFeature = 1, + SetFeature = 3, + SetAddress = 5, + GetDescriptor = 6, + SetDescriptor = 7, + GetConfiguration = 8, + SetConfiguration = 9, + GetInterface = 10, + SetInterface = 11, + SynchFrame = 12, +} + +/// A list of defined USB descriptor types +/// from USB 2.0 standard Table 9.5. Descriptor Types +#[derive(Copy, Clone, Debug, FromPrimitive)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum DescriptorType { + /// DEVICE + Device = 1, + /// CONFIGURATION + Configuration = 2, + /// STRING + String = 3, + /// INTERFACE + Interface = 4, + /// ENDPOINT + Endpoint = 5, + /// DEVICE_QUALIFIER + DeviceQualifier = 6, + /// OTHER_SPEED_CONFIGURATION + OtherSpeedConfiguration = 7, + /// INTERFACE_POINTER + InterfacePower = 8, + /// OTG + OTG = 9, + /// DEBUG + Debug = 0xA, + /// INTERFACE_ASSOCIATION + InterfaceAssociation = 0xB, + /// BOS + BOS = 0xF, + // DEVICE CAPABILITY + DeviceCapability = 0x10, + /// SUPERSPEED_USB_ENDPOINT_COMPANION + SuperspeedUsbEndpointCompanion = 0x30, +} diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/device.rs b/crates/punktfunk-host/vendor/usbip-sim/src/device.rs new file mode 100644 index 0000000..e6f8ace --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/src/device.rs @@ -0,0 +1,555 @@ +use super::*; + +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Version { + pub major: u8, + pub minor: u8, + pub patch: u8, +} + +// (Upstream's `From` conversions removed — this crate has no libusb dependency.) + +/// bcdDevice +impl From for Version { + fn from(value: u16) -> Self { + Self { + major: (value >> 8) as u8, + minor: ((value >> 4) & 0xF) as u8, + patch: (value & 0xF) as u8, + } + } +} + +/// Represent a USB device +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct UsbDevice { + pub path: String, + pub bus_id: String, + pub bus_num: u32, + pub dev_num: u32, + pub speed: u32, + pub vendor_id: u16, + pub product_id: u16, + pub device_bcd: Version, + pub device_class: u8, + pub device_subclass: u8, + pub device_protocol: u8, + pub configuration_value: u8, + pub num_configurations: u8, + pub interfaces: Vec, + + #[cfg_attr(feature = "serde", serde(skip))] + pub device_handler: Option>>>, + + pub usb_version: Version, + + pub(crate) ep0_in: UsbEndpoint, + pub(crate) ep0_out: UsbEndpoint, + // strings + pub(crate) string_pool: HashMap, + pub(crate) string_configuration: u8, + pub(crate) string_manufacturer: u8, + pub(crate) string_product: u8, + pub(crate) string_serial: u8, +} + +impl UsbDevice { + pub fn new(index: u32) -> Self { + let mut res = Self { + path: "/sys/bus/0/0/0".to_string(), + bus_id: "0-0-0".to_string(), + dev_num: index, + speed: UsbSpeed::High as u32, + ep0_in: UsbEndpoint { + address: 0x80, + attributes: EndpointAttributes::Control as u8, + max_packet_size: EP0_MAX_PACKET_SIZE, + interval: 0, + }, + ep0_out: UsbEndpoint { + address: 0x00, + attributes: EndpointAttributes::Control as u8, + max_packet_size: EP0_MAX_PACKET_SIZE, + interval: 0, + }, + // configured by default + configuration_value: 1, + num_configurations: 1, + ..Self::default() + }; + res.string_configuration = res.new_string("Default Configuration"); + res.string_manufacturer = res.new_string("Manufacturer"); + res.string_product = res.new_string("Product"); + res.string_serial = res.new_string("Serial"); + res + } + + /// Returns the old value, if present. + pub fn set_configuration_name(&mut self, name: &str) -> Option { + let old = (self.string_configuration != 0) + .then(|| self.string_pool.remove(&self.string_configuration)) + .flatten(); + self.string_configuration = self.new_string(name); + old + } + + /// Unset configuration name and returns the old value, if present. + pub fn unset_configuration_name(&mut self) -> Option { + let old = (self.string_configuration != 0) + .then(|| self.string_pool.remove(&self.string_configuration)) + .flatten(); + self.string_configuration = 0; + old + } + + /// Returns the old value, if present. + pub fn set_serial_number(&mut self, name: &str) -> Option { + let old = (self.string_serial != 0) + .then(|| self.string_pool.remove(&self.string_serial)) + .flatten(); + self.string_serial = self.new_string(name); + old + } + + /// Unset serial number and returns the old value, if present. + pub fn unset_serial_number(&mut self) -> Option { + let old = (self.string_serial != 0) + .then(|| self.string_pool.remove(&self.string_serial)) + .flatten(); + self.string_serial = 0; + old + } + + /// Returns the old value, if present. + pub fn set_product_name(&mut self, name: &str) -> Option { + let old = (self.string_product != 0) + .then(|| self.string_pool.remove(&self.string_product)) + .flatten(); + self.string_product = self.new_string(name); + old + } + + /// Unset product name and returns the old value, if present. + pub fn unset_product_name(&mut self) -> Option { + let old = (self.string_product != 0) + .then(|| self.string_pool.remove(&self.string_product)) + .flatten(); + self.string_product = 0; + old + } + + /// Returns the old value, if present. + pub fn set_manufacturer_name(&mut self, name: &str) -> Option { + let old = (self.string_manufacturer != 0) + .then(|| self.string_pool.remove(&self.string_manufacturer)) + .flatten(); + self.string_manufacturer = self.new_string(name); + old + } + + /// Unset manufacturer name and returns the old value, if present. + pub fn unset_manufacturer_name(&mut self) -> Option { + let old = (self.string_manufacturer != 0) + .then(|| self.string_pool.remove(&self.string_manufacturer)) + .flatten(); + self.string_manufacturer = 0; + old + } + + pub fn with_interface( + mut self, + interface_class: u8, + interface_subclass: u8, + interface_protocol: u8, + name: Option<&str>, + endpoints: Vec, + handler: Arc>>, + ) -> Self { + let string_interface = name.map(|name| self.new_string(name)).unwrap_or(0); + let class_specific_descriptor = handler.lock().unwrap().get_class_specific_descriptor(); + self.interfaces.push(UsbInterface { + interface_class, + interface_subclass, + interface_protocol, + endpoints, + string_interface, + class_specific_descriptor, + handler, + }); + self + } + + pub fn with_device_handler( + mut self, + handler: Arc>>, + ) -> Self { + self.device_handler = Some(handler); + self + } + + pub(crate) fn new_string(&mut self, s: &str) -> u8 { + for i in 1.. { + if let std::collections::hash_map::Entry::Vacant(e) = self.string_pool.entry(i) { + e.insert(s.to_string()); + return i; + } + } + panic!("string poll exhausted") + } + + pub(crate) fn find_ep(&self, ep: u8) -> Option<(UsbEndpoint, Option<&UsbInterface>)> { + if ep == self.ep0_in.address { + Some((self.ep0_in, None)) + } else if ep == self.ep0_out.address { + Some((self.ep0_out, None)) + } else { + for intf in &self.interfaces { + for endpoint in &intf.endpoints { + if endpoint.address == ep { + return Some((*endpoint, Some(intf))); + } + } + } + None + } + } + + pub(crate) fn to_bytes(&self) -> Vec { + let mut result = Vec::with_capacity(312); + + let mut path = self.path.as_bytes().to_vec(); + debug_assert!(path.len() <= 256); + path.resize(256, 0); + result.extend_from_slice(path.as_slice()); + + let mut bus_id = self.bus_id.as_bytes().to_vec(); + debug_assert!(bus_id.len() <= 32); + bus_id.resize(32, 0); + result.extend_from_slice(bus_id.as_slice()); + + result.extend_from_slice(&self.bus_num.to_be_bytes()); + result.extend_from_slice(&self.dev_num.to_be_bytes()); + result.extend_from_slice(&self.speed.to_be_bytes()); + result.extend_from_slice(&self.vendor_id.to_be_bytes()); + result.extend_from_slice(&self.product_id.to_be_bytes()); + result.push(self.device_bcd.major); + result.push(self.device_bcd.minor); + result.push(self.device_class); + result.push(self.device_subclass); + result.push(self.device_protocol); + result.push(self.configuration_value); + result.push(self.num_configurations); + result.push(self.interfaces.len() as u8); + + result + } + + pub(crate) fn to_bytes_with_interfaces(&self) -> Vec { + let mut result = self.to_bytes(); + result.reserve(4 * self.interfaces.len()); + + for intf in &self.interfaces { + result.push(intf.interface_class); + result.push(intf.interface_subclass); + result.push(intf.interface_protocol); + result.push(0); // padding + } + + result + } + + pub(crate) async fn handle_urb( + &self, + ep: UsbEndpoint, + intf: Option<&UsbInterface>, + transfer_buffer_length: u32, + setup_packet: SetupPacket, + out_data: &[u8], + ) -> Result> { + use DescriptorType::*; + use Direction::*; + use EndpointAttributes::*; + use StandardRequest::*; + + match (FromPrimitive::from_u8(ep.attributes), ep.direction()) { + (Some(Control), In) => { + // control in + debug!("Control IN setup={setup_packet:x?}"); + match ( + setup_packet.request_type, + FromPrimitive::from_u8(setup_packet.request), + ) { + (0b10000000, Some(GetDescriptor)) => { + // high byte: type + match FromPrimitive::from_u16(setup_packet.value >> 8) { + Some(Device) => { + debug!("Get device descriptor"); + // Standard Device Descriptor + let mut desc = vec![ + 0x12, // bLength + Device as u8, // bDescriptorType: Device + self.usb_version.minor, + self.usb_version.major, // bcdUSB: USB 2.0 + self.device_class, // bDeviceClass + self.device_subclass, // bDeviceSubClass + self.device_protocol, // bDeviceProtocol + self.ep0_in.max_packet_size as u8, // bMaxPacketSize0 + self.vendor_id as u8, // idVendor + (self.vendor_id >> 8) as u8, + self.product_id as u8, // idProduct + (self.product_id >> 8) as u8, + self.device_bcd.minor, // bcdDevice + self.device_bcd.major, + self.string_manufacturer, // iManufacturer + self.string_product, // iProduct + self.string_serial, // iSerial + self.num_configurations, // bNumConfigurations + ]; + + // requested len too short: wLength < real length + if setup_packet.length < desc.len() as u16 { + desc.resize(setup_packet.length as usize, 0); + } + Ok(desc) + } + Some(BOS) => { + debug!("Get BOS descriptor"); + let mut desc = vec![ + 0x05, // bLength + BOS as u8, // bDescriptorType: BOS + 0x05, 0x00, // wTotalLength + 0x00, // bNumCapabilities + ]; + + // requested len too short: wLength < real length + if setup_packet.length < desc.len() as u16 { + desc.resize(setup_packet.length as usize, 0); + } + Ok(desc) + } + Some(Configuration) => { + debug!("Get configuration descriptor"); + // Standard Configuration Descriptor + let mut desc = vec![ + 0x09, // bLength + Configuration as u8, // bDescriptorType: Configuration + 0x00, + 0x00, // wTotalLength: to be filled below + self.interfaces.len() as u8, // bNumInterfaces + self.configuration_value, // bConfigurationValue + self.string_configuration, // iConfiguration + 0x80, // bmAttributes: Bus Powered + 0x32, // bMaxPower: 100mA + ]; + for (i, intf) in self.interfaces.iter().enumerate() { + let mut intf_desc = vec![ + 0x09, // bLength + Interface as u8, // bDescriptorType: Interface + i as u8, // bInterfaceNum + 0x00, // bAlternateSettings + intf.endpoints.len() as u8, // bNumEndpoints + intf.interface_class, // bInterfaceClass + intf.interface_subclass, // bInterfaceSubClass + intf.interface_protocol, // bInterfaceProtocol + intf.string_interface, //iInterface + ]; + // class specific endpoint + let mut specific = intf.class_specific_descriptor.clone(); + intf_desc.append(&mut specific); + // endpoint descriptors + for endpoint in &intf.endpoints { + let mut ep_desc = vec![ + 0x07, // bLength + Endpoint as u8, // bDescriptorType: Endpoint + endpoint.address, // bEndpointAddress + endpoint.attributes, // bmAttributes + endpoint.max_packet_size as u8, + (endpoint.max_packet_size >> 8) as u8, // wMaxPacketSize + endpoint.interval, // bInterval + ]; + intf_desc.append(&mut ep_desc); + } + desc.append(&mut intf_desc); + } + // length + let len = desc.len() as u16; + desc[2] = len as u8; + desc[3] = (len >> 8) as u8; + + // requested len too short: wLength < real length + if setup_packet.length < desc.len() as u16 { + desc.resize(setup_packet.length as usize, 0); + } + Ok(desc) + } + Some(String) => { + debug!("Get string descriptor"); + let index = setup_packet.value as u8; + if index == 0 { + // String Descriptor Zero, Specifying Languages Supported by the Device + // language ids + let mut desc = vec![ + 4, // bLength + DescriptorType::String as u8, // bDescriptorType + 0x09, + 0x04, // wLANGID[0], en-US + ]; + // requested len too short: wLength < real length + if setup_packet.length < desc.len() as u16 { + desc.resize(setup_packet.length as usize, 0); + } + Ok(desc) + } else if let Some(s) = &self.string_pool.get(&index) { + // UNICODE String Descriptor + let bytes: Vec = s.encode_utf16().collect(); + let mut desc = vec![ + 2 + bytes.len() as u8 * 2, // bLength + DescriptorType::String as u8, // bDescriptorType + ]; + for byte in bytes { + desc.push(byte as u8); + desc.push((byte >> 8) as u8); + } + + // requested len too short: wLength < real length + if setup_packet.length < desc.len() as u16 { + desc.resize(setup_packet.length as usize, 0); + } + Ok(desc) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Invalid string index: {index}"), + )) + } + } + Some(DeviceQualifier) => { + debug!("Get device qualifier descriptor"); + // Device_Qualifier Descriptor + let mut desc = vec![ + 0x0A, // bLength + DeviceQualifier as u8, // bDescriptorType: Device Qualifier + self.usb_version.minor, + self.usb_version.major, // bcdUSB + self.device_class, // bDeviceClass + self.device_subclass, // bDeviceSUbClass + self.device_protocol, // bDeviceProtocol + self.ep0_in.max_packet_size as u8, // bMaxPacketSize0 + self.num_configurations, // bNumConfigurations + 0x00, // bReserved + ]; + + // requested len too short: wLength < real length + if setup_packet.length < desc.len() as u16 { + desc.resize(setup_packet.length as usize, 0); + } + Ok(desc) + } + _ => { + warn!("unknown desc type: {setup_packet:x?}"); + Ok(vec![]) + } + } + } + _ if setup_packet.request_type & 0xF == 1 => { + // to interface + // see https://www.beyondlogic.org/usbnutshell/usb6.shtml + // only low 8 bits are valid + let intf = &self.interfaces[setup_packet.index as usize & 0xFF]; + let mut handler = intf.handler.lock().unwrap(); + handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data) + } + _ if setup_packet.request_type & 0xF == 0 && self.device_handler.is_some() => { + // to device + // see https://www.beyondlogic.org/usbnutshell/usb6.shtml + let lock = self.device_handler.as_ref().unwrap(); + let mut handler = lock.lock().unwrap(); + handler.handle_urb(transfer_buffer_length, setup_packet, out_data) + } + _ => unimplemented!("control in"), + } + } + (Some(Control), Out) => { + // control out + debug!("Control OUT setup={setup_packet:x?}"); + match ( + setup_packet.request_type, + FromPrimitive::from_u8(setup_packet.request), + ) { + (0b00000000, Some(SetConfiguration)) => { + let mut desc = vec![ + self.configuration_value, // bConfigurationValue + ]; + + // requested len too short: wLength < real length + if setup_packet.length < desc.len() as u16 { + desc.resize(setup_packet.length as usize, 0); + } + Ok(desc) + } + _ if setup_packet.request_type & 0xF == 1 => { + // to interface + // see https://www.beyondlogic.org/usbnutshell/usb6.shtml + // only low 8 bits are valid + let intf = &self.interfaces[setup_packet.index as usize & 0xFF]; + let mut handler = intf.handler.lock().unwrap(); + handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data) + } + _ if setup_packet.request_type & 0xF == 0 && self.device_handler.is_some() => { + // to device + // see https://www.beyondlogic.org/usbnutshell/usb6.shtml + let lock = self.device_handler.as_ref().unwrap(); + let mut handler = lock.lock().unwrap(); + handler.handle_urb(transfer_buffer_length, setup_packet, out_data) + } + _ => unimplemented!("control out"), + } + } + (Some(_), _) => { + // others (interrupt / bulk / iso transfers to an endpoint) + // punktfunk modification: pace IN transfers by bInterval so a virtual interrupt-IN + // endpoint mimics a real device's NAK-until-bInterval behaviour instead of + // free-running as fast as the transport allows (vhci_hcd does not throttle the + // server side, so an unpaced sim would spin the loopback link). HS bInterval N → + // 2^(N-1) microframes × 125µs. + if let In = ep.direction() { + let n = ep.interval.clamp(1, 16) as u32; + let period_us = (1u32 << (n - 1)) * 125; + tokio::time::sleep(std::time::Duration::from_micros(period_us as u64)).await; + } + let intf = intf.unwrap(); + let mut handler = intf.handler.lock().unwrap(); + handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data) + } + _ => unimplemented!("transfer to {:?}", ep), + } + } +} + +/// A handler for URB targeting the device +pub trait UsbDeviceHandler: std::fmt::Debug { + /// Handle a URB(USB Request Block) targeting at this device + /// + /// When the lower 4 bits of `bmRequestType` is zero and the URB is not handled by the library, this function is called. + /// The resulting data should not exceed `transfer_buffer_length` + fn handle_urb( + &mut self, + transfer_buffer_length: u32, + setup: SetupPacket, + req: &[u8], + ) -> Result>; + + /// Helper to downcast to actual struct + /// + /// Please implement it as: + /// ```ignore + /// fn as_any(&mut self) -> &mut dyn Any { + /// self + /// } + /// ``` + fn as_any(&mut self) -> &mut dyn Any; +} + +// (In-crate test module removed in the vendored copy — see NOTICE.) diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/endpoint.rs b/crates/punktfunk-host/vendor/usbip-sim/src/endpoint.rs new file mode 100644 index 0000000..19b5c29 --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/src/endpoint.rs @@ -0,0 +1,31 @@ +use super::*; + +/// Represent a USB endpoint +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct UsbEndpoint { + /// bEndpointAddress + pub address: u8, + /// bmAttributes + pub attributes: u8, + /// wMaxPacketSize + pub max_packet_size: u16, + /// bInterval + pub interval: u8, +} + +impl UsbEndpoint { + /// Get direction from MSB of address + pub fn direction(&self) -> Direction { + if self.address & 0x80 != 0 { + Direction::In + } else { + Direction::Out + } + } + + /// Whether this is endpoint zero + pub fn is_ep0(&self) -> bool { + self.address & 0x7F == 0 + } +} diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/interface.rs b/crates/punktfunk-host/vendor/usbip-sim/src/interface.rs new file mode 100644 index 0000000..1a9733a --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/src/interface.rs @@ -0,0 +1,45 @@ +use super::*; + +/// Represent a USB interface +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct UsbInterface { + pub interface_class: u8, + pub interface_subclass: u8, + pub interface_protocol: u8, + pub endpoints: Vec, + pub string_interface: u8, + pub class_specific_descriptor: Vec, + + #[cfg_attr(feature = "serde", serde(skip))] + pub handler: Arc>>, +} + +/// A handler of a custom usb interface +pub trait UsbInterfaceHandler: std::fmt::Debug { + /// Return the class specific descriptor which is inserted between interface descriptor and endpoint descriptor + fn get_class_specific_descriptor(&self) -> Vec; + + /// Handle a URB(USB Request Block) targeting at this interface + /// + /// Can be one of: control transfer to ep0 or other types of transfer to its endpoint. + /// The resulting data should not exceed `transfer_buffer_length`. + fn handle_urb( + &mut self, + interface: &UsbInterface, + ep: UsbEndpoint, + transfer_buffer_length: u32, + setup: SetupPacket, + req: &[u8], + ) -> Result>; + + /// Helper to downcast to actual struct + /// + /// Please implement it as: + /// ```ignore + /// fn as_any(&mut self) -> &mut dyn Any { + /// self + /// } + /// ``` + fn as_any(&mut self) -> &mut dyn Any; +} diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/lib.rs b/crates/punktfunk-host/vendor/usbip-sim/src/lib.rs new file mode 100644 index 0000000..dd11ca4 --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/src/lib.rs @@ -0,0 +1,250 @@ +//! A USB/IP server (simulation path only). +//! +//! Vendored + trimmed from `usbip` v0.8.0 (jiegec/usbip, MIT); the USB *host* modules and the +//! `rusb`/`nusb` device constructors are removed so this carries no libusb dependency. See `NOTICE`. + +use log::*; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; +use std::any::Any; +use std::collections::HashMap; +use std::io::{ErrorKind, Result}; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::sync::RwLock; +use usbip_protocol::UsbIpCommand; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +mod consts; +mod device; +mod endpoint; +mod interface; +mod setup; +pub mod usbip_protocol; +mod util; +pub use consts::*; +pub use device::*; +pub use endpoint::*; +pub use interface::*; +pub use setup::*; +pub use util::*; + +use crate::usbip_protocol::{UsbIpResponse, USBIP_RET_SUBMIT, USBIP_RET_UNLINK}; + +/// Main struct of a USB/IP server +#[derive(Default, Debug)] +pub struct UsbIpServer { + available_devices: RwLock>, + used_devices: RwLock>, +} + +impl UsbIpServer { + /// Create a [UsbIpServer] with simulated devices + pub fn new_simulated(devices: Vec) -> Self { + Self { + available_devices: RwLock::new(devices), + used_devices: RwLock::new(HashMap::new()), + } + } + + pub async fn add_device(&self, device: UsbDevice) { + self.available_devices.write().await.push(device); + } + + pub async fn remove_device(&self, bus_id: &str) -> Result<()> { + let mut available_devices = self.available_devices.write().await; + + if let Some(device) = available_devices.iter().position(|d| d.bus_id == bus_id) { + available_devices.remove(device); + Ok(()) + } else if let Some(device) = self + .used_devices + .read() + .await + .values() + .find(|d| d.bus_id == bus_id) + { + Err(std::io::Error::other(format!( + "Device {} is in use", + device.bus_id + ))) + } else { + Err(std::io::Error::new( + ErrorKind::NotFound, + format!("Device {bus_id} not found"), + )) + } + } +} + +pub async fn handler( + mut socket: &mut T, + server: Arc, +) -> Result<()> { + let mut current_import_device_id: Option = None; + loop { + let command = UsbIpCommand::read_from_socket(&mut socket).await; + if let Err(err) = command { + if let Some(dev_id) = current_import_device_id { + let mut used_devices = server.used_devices.write().await; + let mut available_devices = server.available_devices.write().await; + match used_devices.remove(&dev_id) { + Some(dev) => available_devices.push(dev), + None => unreachable!(), + } + } + + if err.kind() == ErrorKind::UnexpectedEof { + info!("Remote closed the connection"); + return Ok(()); + } else { + return Err(err); + } + } + + let used_devices = server.used_devices.read().await; + let mut current_import_device = current_import_device_id + .clone() + .and_then(|ref id| used_devices.get(id)); + + match command.unwrap() { + UsbIpCommand::OpReqDevlist { .. } => { + trace!("Got OP_REQ_DEVLIST"); + let devices = server.available_devices.read().await; + + // OP_REP_DEVLIST + UsbIpResponse::op_rep_devlist(&devices) + .write_to_socket(socket) + .await?; + trace!("Sent OP_REP_DEVLIST"); + } + UsbIpCommand::OpReqImport { busid, .. } => { + trace!("Got OP_REQ_IMPORT"); + + current_import_device_id = None; + current_import_device = None; + std::mem::drop(used_devices); + + let mut used_devices = server.used_devices.write().await; + let mut available_devices = server.available_devices.write().await; + let busid_compare = + &busid[..busid.iter().position(|&x| x == 0).unwrap_or(busid.len())]; + for (i, dev) in available_devices.iter().enumerate() { + if busid_compare == dev.bus_id.as_bytes() { + let dev = available_devices.remove(i); + let dev_id = dev.bus_id.clone(); + used_devices.insert(dev.bus_id.clone(), dev); + current_import_device_id = dev_id.clone().into(); + current_import_device = Some(used_devices.get(&dev_id).unwrap()); + break; + } + } + + let res = if let Some(dev) = current_import_device { + UsbIpResponse::op_rep_import_success(dev) + } else { + UsbIpResponse::op_rep_import_fail() + }; + res.write_to_socket(socket).await?; + trace!("Sent OP_REP_IMPORT"); + } + UsbIpCommand::UsbIpCmdSubmit { + mut header, + transfer_buffer_length, + setup, + data, + .. + } => { + trace!("Got USBIP_CMD_SUBMIT"); + let device = current_import_device.unwrap(); + + let out = header.direction == 0; + let real_ep = if out { header.ep } else { header.ep | 0x80 }; + + header.command = USBIP_RET_SUBMIT.into(); + + let res = match device.find_ep(real_ep as u8) { + None => { + warn!("Endpoint {real_ep:02x?} not found"); + UsbIpResponse::usbip_ret_submit_fail(&header) + } + Some((ep, intf)) => { + trace!("->Endpoint {ep:02x?}"); + trace!("->Setup {setup:02x?}"); + trace!("->Request {data:02x?}"); + let resp = device + .handle_urb( + ep, + intf, + transfer_buffer_length, + SetupPacket::parse(&setup), + &data, + ) + .await; + + match resp { + Ok(resp) => { + if out { + trace!("<-Wrote {}", data.len()); + } else { + trace!("<-Resp {resp:02x?}"); + } + UsbIpResponse::usbip_ret_submit_success(&header, 0, 0, resp, vec![]) + } + Err(err) => { + warn!("Error handling URB: {err}"); + UsbIpResponse::usbip_ret_submit_fail(&header) + } + } + } + }; + res.write_to_socket(socket).await?; + trace!("Sent USBIP_RET_SUBMIT"); + } + UsbIpCommand::UsbIpCmdUnlink { + mut header, + unlink_seqnum, + } => { + trace!("Got USBIP_CMD_UNLINK for {unlink_seqnum:10x?}"); + + header.command = USBIP_RET_UNLINK.into(); + + let res = UsbIpResponse::usbip_ret_unlink_success(&header); + res.write_to_socket(socket).await?; + trace!("Sent USBIP_RET_UNLINK"); + } + } + } +} + +/// Spawn a USB/IP server at `addr` using [TcpListener] +pub async fn server(addr: SocketAddr, server: Arc) { + let listener = TcpListener::bind(addr).await.expect("bind to addr"); + + let server = async move { + loop { + match listener.accept().await { + Ok((mut socket, _addr)) => { + info!("Got connection from {:?}", socket.peer_addr()); + let new_server = server.clone(); + tokio::spawn(async move { + let res = handler(&mut socket, new_server).await; + info!("Handler ended with {res:?}"); + }); + } + Err(err) => { + warn!("Got error {err:?}"); + } + } + } + }; + + server.await +} + +// (Host-mode constructors and in-crate tests removed in the vendored copy — see NOTICE.) diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/setup.rs b/crates/punktfunk-host/vendor/usbip-sim/src/setup.rs new file mode 100644 index 0000000..9da7261 --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/src/setup.rs @@ -0,0 +1,31 @@ +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Parse the SETUP packet of control transfers +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct SetupPacket { + /// bmRequestType + pub request_type: u8, + /// bRequest + pub request: u8, + /// wValue + pub value: u16, + /// wIndex + pub index: u16, + /// wLength + pub length: u16, +} + +impl SetupPacket { + /// Parse a [SetupPacket] from raw setup packet + pub fn parse(setup: &[u8; 8]) -> SetupPacket { + SetupPacket { + request_type: setup[0], + request: setup[1], + value: ((setup[3] as u16) << 8) | (setup[2] as u16), + index: ((setup[5] as u16) << 8) | (setup[4] as u16), + length: ((setup[7] as u16) << 8) | (setup[6] as u16), + } + } +} diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/usbip_protocol.rs b/crates/punktfunk-host/vendor/usbip-sim/src/usbip_protocol.rs new file mode 100644 index 0000000..a139e2d --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/src/usbip_protocol.rs @@ -0,0 +1,498 @@ +//! USB/IP protocol structs +//! +//! This module contains declarations of all structs used in the USB/IP protocol, +//! as well as functions to serialize and deserialize them to/from byte arrays, +//! and functions to send and receive them over a socket. +//! +//! They are based on the [Linux kernel documentation](https://docs.kernel.org/usb/usbip_protocol.html). + +use log::trace; +use std::io::Result; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::UsbDevice; + +/// USB/IP protocol version +/// +/// This is currently the only supported version of USB/IP +/// for this library. +pub const USBIP_VERSION: u16 = 0x0111; + +/// Command code: Retrieve the list of exported USB devices +pub const OP_REQ_DEVLIST: u16 = 0x8005; +/// Command code: import a remote USB device +pub const OP_REQ_IMPORT: u16 = 0x8003; +/// Reply code: The list of exported USB devices +pub const OP_REP_DEVLIST: u16 = 0x0005; +/// Reply code: Reply to import +pub const OP_REP_IMPORT: u16 = 0x0003; + +/// Command code: Submit an URB +pub const USBIP_CMD_SUBMIT: u16 = 0x0001; +/// Command code: Unlink an URB +pub const USBIP_CMD_UNLINK: u16 = 0x0002; +/// Reply code: Reply for submitting an URB +pub const USBIP_RET_SUBMIT: u16 = 0x0003; +/// Reply code: Reply for URB unlink +pub const USBIP_RET_UNLINK: u16 = 0x0004; + +/// USB/IP direction +/// +/// NOTE: Must not be confused with rusb::Direction, +/// which has the opposite enum values. This is only for +/// internal use in the USB/IP protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + Out = 0, + In = 1, +} + +/// Common header for all context sensitive packets +/// +/// All commands/responses which rely on a device being attached +/// to a client use this header. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct UsbIpHeaderBasic { + pub command: u32, + pub seqnum: u32, + pub devid: u32, + pub direction: u32, + pub ep: u32, +} + +impl UsbIpHeaderBasic { + /// Converts a byte array into a [UsbIpHeaderBasic]. + pub fn from_bytes(bytes: &[u8; 20]) -> Self { + let result = UsbIpHeaderBasic { + command: u32::from_be_bytes(bytes[0..4].try_into().unwrap()), + seqnum: u32::from_be_bytes(bytes[4..8].try_into().unwrap()), + devid: u32::from_be_bytes(bytes[8..12].try_into().unwrap()), + direction: u32::from_be_bytes(bytes[12..16].try_into().unwrap()), + ep: u32::from_be_bytes(bytes[16..20].try_into().unwrap()), + }; + // The direction should be 0 or 1 + debug_assert!(result.direction & 1 == result.direction); + result + } + + /// Converts the [UsbIpHeaderBasic] into a byte array. + pub fn to_bytes(&self) -> [u8; 20] { + let mut result = [0u8; 20]; + result[0..4].copy_from_slice(&self.command.to_be_bytes()); + result[4..8].copy_from_slice(&self.seqnum.to_be_bytes()); + result[8..12].copy_from_slice(&self.devid.to_be_bytes()); + result[12..16].copy_from_slice(&self.direction.to_be_bytes()); + result[16..20].copy_from_slice(&self.ep.to_be_bytes()); + result + } + + pub(crate) async fn read_from_socket_with_command( + socket: &mut T, + command: u16, + ) -> Result { + let seqnum = socket.read_u32().await?; + let devid = socket.read_u32().await?; + let direction = socket.read_u32().await?; + // The direction should be 0 or 1 + debug_assert!(direction & 1 == direction); + let ep = socket.read_u32().await?; + + Ok(UsbIpHeaderBasic { + command: command.into(), + seqnum, + devid, + direction, + ep, + }) + } +} + +/// Client side commands from the Virtual Host Controller +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum UsbIpCommand { + OpReqDevlist { + status: u32, + }, + OpReqImport { + status: u32, + busid: [u8; 32], + }, + UsbIpCmdSubmit { + header: UsbIpHeaderBasic, + transfer_flags: u32, + transfer_buffer_length: u32, + start_frame: u32, + number_of_packets: u32, + interval: u32, + setup: [u8; 8], + data: Vec, + iso_packet_descriptor: Vec, + }, + UsbIpCmdUnlink { + header: UsbIpHeaderBasic, + unlink_seqnum: u32, + }, +} + +impl UsbIpCommand { + /// Constructs a [UsbIpCommand] from a socket + /// + /// This will consume a variable amount of bytes from the socket. + /// It might fail if the bytes does not follow the USB/IP protocol properly. + pub async fn read_from_socket(socket: &mut T) -> Result { + let version: u16 = socket.read_u16().await?; + + if version != 0 && version != USBIP_VERSION { + return Err(std::io::Error::other(format!( + "Unknown version: {version:#04X}" + ))); + } + + let command: u16 = socket.read_u16().await?; + + trace!( + "Received command: {:#04X} ({}), parsing...", + command, + match command { + OP_REQ_DEVLIST => "OP_REQ_DEVLIST", + OP_REQ_IMPORT => "OP_REQ_IMPORT", + USBIP_CMD_SUBMIT => "USBIP_CMD_SUBMIT", + USBIP_CMD_UNLINK => "USBIP_CMD_UNLINK", + _ => "Unknown", + } + ); + + match command { + OP_REQ_DEVLIST => { + let status = socket.read_u32().await?; + debug_assert!(status == 0); + + Ok(UsbIpCommand::OpReqDevlist { status }) + } + OP_REQ_IMPORT => { + let status = socket.read_u32().await?; + debug_assert!(status == 0); + let mut busid = [0; 32]; + socket.read_exact(&mut busid).await?; + + Ok(UsbIpCommand::OpReqImport { status, busid }) + } + USBIP_CMD_SUBMIT => { + let header = + UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_SUBMIT) + .await?; + let transfer_flags = socket.read_u32().await?; + let transfer_buffer_length = socket.read_u32().await?; + let start_frame = socket.read_u32().await?; + let number_of_packets = socket.read_u32().await?; + let interval = socket.read_u32().await?; + + let mut setup = [0; 8]; + socket.read_exact(&mut setup).await?; + + let data = if header.direction == Direction::In as u32 { + vec![] + } else { + let mut data = vec![0; transfer_buffer_length as usize]; + socket.read_exact(&mut data).await?; + data + }; + + // The kernel docs specifies that this should be set to 0xFFFFFFFF for all + // non-ISO packets, however the actual implementation resorts to 0x00000000 + // https://stackoverflow.com/questions/76899798/usb-ip-what-is-the-size-of-the-iso-packet-descriptor + let iso_packet_descriptor = + if number_of_packets != 0 && number_of_packets != 0xFFFFFFFF { + let mut result = vec![0; 16 * number_of_packets as usize]; + socket.read_exact(&mut result).await?; + result + } else { + vec![] + }; + + Ok(UsbIpCommand::UsbIpCmdSubmit { + header, + transfer_flags, + transfer_buffer_length, + start_frame, + number_of_packets, + interval, + setup, + data, + iso_packet_descriptor, + }) + } + USBIP_CMD_UNLINK => { + let header = + UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_UNLINK) + .await?; + let unlink_seqnum = socket.read_u32().await?; + + let mut _padding = [0; 24]; + socket.read_exact(&mut _padding).await?; + + Ok(UsbIpCommand::UsbIpCmdUnlink { + header, + unlink_seqnum, + }) + } + _ => Err(std::io::Error::other(format!( + "Unknown command: {command:#04X}" + ))), + } + } + + /// Converts the [UsbIpCommand] into a byte vector + pub fn to_bytes(&self) -> Vec { + match *self { + UsbIpCommand::OpReqDevlist { status } => { + let mut result = Vec::with_capacity(8); + result.extend_from_slice(&USBIP_VERSION.to_be_bytes()); + result.extend_from_slice(&OP_REQ_DEVLIST.to_be_bytes()); + result.extend_from_slice(&status.to_be_bytes()); + result + } + UsbIpCommand::OpReqImport { status, busid } => { + let mut result = Vec::with_capacity(40); + result.extend_from_slice(&USBIP_VERSION.to_be_bytes()); + result.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes()); + result.extend_from_slice(&status.to_be_bytes()); + result.extend_from_slice(&busid); + result + } + UsbIpCommand::UsbIpCmdSubmit { + ref header, + transfer_flags, + transfer_buffer_length, + start_frame, + number_of_packets, + interval, + setup, + ref data, + ref iso_packet_descriptor, + } => { + debug_assert!( + header.direction != Direction::Out as u32 + || transfer_buffer_length == data.len() as u32 + ); + + let mut result = Vec::with_capacity(48 + data.len() + iso_packet_descriptor.len()); + result.extend_from_slice(&header.to_bytes()); + result.extend_from_slice(&transfer_flags.to_be_bytes()); + result.extend_from_slice(&transfer_buffer_length.to_be_bytes()); + result.extend_from_slice(&start_frame.to_be_bytes()); + result.extend_from_slice(&number_of_packets.to_be_bytes()); + result.extend_from_slice(&interval.to_be_bytes()); + result.extend_from_slice(&setup); + result.extend_from_slice(data); + result.extend_from_slice(iso_packet_descriptor); + result + } + UsbIpCommand::UsbIpCmdUnlink { + ref header, + unlink_seqnum, + } => { + let mut result = Vec::with_capacity(48); + result.extend_from_slice(&header.to_bytes()); + result.extend_from_slice(&unlink_seqnum.to_be_bytes()); + result.extend_from_slice(&[0; 24]); + result + } + } + } +} + +/// Server side responses from the USB Host +#[derive(Clone)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub enum UsbIpResponse { + OpRepDevlist { + status: u32, + device_count: u32, + devices: Vec, + }, + OpRepImport { + status: u32, + device: Option, + }, + UsbIpRetSubmit { + header: UsbIpHeaderBasic, + status: u32, + actual_length: u32, + start_frame: u32, + number_of_packets: u32, + error_count: u32, + transfer_buffer: Vec, + iso_packet_descriptor: Vec, + }, + UsbIpRetUnlink { + header: UsbIpHeaderBasic, + status: u32, + }, +} + +impl UsbIpResponse { + /// Converts the [UsbIpResponse] into a byte vector + pub fn to_bytes(&self) -> Vec { + match *self { + Self::OpRepDevlist { + status, + device_count, + ref devices, + } => { + let mut result = Vec::with_capacity( + 12 + devices.len() * 312 + + devices + .iter() + .map(|d| d.interfaces.len() * 4) + .sum::(), + ); + result.extend_from_slice(&USBIP_VERSION.to_be_bytes()); + result.extend_from_slice(&OP_REP_DEVLIST.to_be_bytes()); + result.extend_from_slice(&status.to_be_bytes()); + result.extend_from_slice(&device_count.to_be_bytes()); + for dev in devices { + result.extend_from_slice(&dev.to_bytes_with_interfaces()); + } + result + } + Self::OpRepImport { status, ref device } => { + let mut result = Vec::with_capacity(320); + result.extend_from_slice(&USBIP_VERSION.to_be_bytes()); + result.extend_from_slice(&OP_REP_IMPORT.to_be_bytes()); + result.extend_from_slice(&status.to_be_bytes()); + if let Some(device) = device { + result.extend_from_slice(&device.to_bytes()); + } + result + } + Self::UsbIpRetSubmit { + ref header, + status, + actual_length, + start_frame, + number_of_packets, + error_count, + ref transfer_buffer, + ref iso_packet_descriptor, + } => { + let mut result = + Vec::with_capacity(48 + transfer_buffer.len() + iso_packet_descriptor.len()); + + debug_assert!(header.command == USBIP_RET_SUBMIT.into()); + debug_assert!(if header.direction == Direction::In as u32 { + actual_length == transfer_buffer.len() as u32 + } else { + actual_length == 0 + }); + + result.extend_from_slice(&header.to_bytes()); + result.extend_from_slice(&status.to_be_bytes()); + result.extend_from_slice(&actual_length.to_be_bytes()); + result.extend_from_slice(&start_frame.to_be_bytes()); + result.extend_from_slice(&number_of_packets.to_be_bytes()); + result.extend_from_slice(&error_count.to_be_bytes()); + result.extend_from_slice(&[0; 8]); + result.extend_from_slice(transfer_buffer); + result.extend_from_slice(iso_packet_descriptor); + result + } + Self::UsbIpRetUnlink { ref header, status } => { + let mut result = Vec::with_capacity(48); + + debug_assert!(header.command == USBIP_RET_UNLINK.into()); + + result.extend_from_slice(&header.to_bytes()); + result.extend_from_slice(&status.to_be_bytes()); + result.extend_from_slice(&[0; 24]); + result + } + } + } + + pub async fn write_to_socket(&self, socket: &mut T) -> Result<()> { + socket.write_all(&self.to_bytes()).await + } + + /// Constructs a OP_REP_DEVLIST response + pub fn op_rep_devlist(devices: &[UsbDevice]) -> Self { + Self::OpRepDevlist { + status: 0, + device_count: devices.len() as u32, + devices: devices.to_vec(), + } + } + + /// Constructs a successful OP_REP_IMPORT response + pub fn op_rep_import_success(device: &UsbDevice) -> Self { + Self::OpRepImport { + status: 0, + device: Some(device.clone()), + } + } + + /// Constructs a failed OP_REP_IMPORT response + pub fn op_rep_import_fail() -> Self { + Self::OpRepImport { + status: 1, + device: None, + } + } + + /// Constructs a successful OP_REP_IMPORT response + pub fn usbip_ret_submit_success( + header: &UsbIpHeaderBasic, + start_frame: u32, + number_of_packets: u32, + transfer_buffer: Vec, + iso_packet_descriptor: Vec, + ) -> Self { + Self::UsbIpRetSubmit { + header: header.clone(), + status: 0, + actual_length: transfer_buffer.len() as u32, + start_frame, + number_of_packets, + error_count: 0, + transfer_buffer, + iso_packet_descriptor, + } + } + + /// Constructs a failed OP_REP_IMPORT response + pub fn usbip_ret_submit_fail(header: &UsbIpHeaderBasic) -> Self { + Self::UsbIpRetSubmit { + header: header.clone(), + status: 1, + actual_length: 0, + start_frame: 0, + number_of_packets: 0, + error_count: 0, + transfer_buffer: vec![], + iso_packet_descriptor: vec![], + } + } + + /// Constructs a successful OP_REP_IMPORT response + pub fn usbip_ret_unlink_success(header: &UsbIpHeaderBasic) -> Self { + Self::UsbIpRetUnlink { + header: header.clone(), + status: 0, + } + } + + /// Constructs a failed OP_REP_IMPORT response. + pub fn usbip_ret_unlink_fail(header: &UsbIpHeaderBasic) -> Self { + Self::UsbIpRetUnlink { + header: header.clone(), + status: 1, + } + } +} + +// (In-crate test module removed in the vendored copy — see NOTICE.) diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/util.rs b/crates/punktfunk-host/vendor/usbip-sim/src/util.rs new file mode 100644 index 0000000..bce11c6 --- /dev/null +++ b/crates/punktfunk-host/vendor/usbip-sim/src/util.rs @@ -0,0 +1,10 @@ +/// Check validity of a USB descriptor +pub fn verify_descriptor(desc: &[u8]) { + let mut offset = 0; + while offset < desc.len() { + offset += desc[offset] as usize; // length + } + assert_eq!(offset, desc.len()); +} + +// (In-crate test module removed in the vendored copy — see NOTICE.) diff --git a/design/README.md b/design/README.md index 1d11652..77ab739 100644 --- a/design/README.md +++ b/design/README.md @@ -34,6 +34,8 @@ holds the full originals. | [`apple-stage2-presenter.md`](apple-stage2-presenter.md) | Apple stage-2 (VTDecompressionSession + CAMetalLayer) presenter | **Shipped (opt-in)** — make-default + iOS open | | [`game-library-stores.md`](game-library-stores.md) | Multi-store game library | **Phases 1–4 shipped** — 6 providers + 8 Qs open | | [`dualsense-haptics.md`](dualsense-haptics.md) | DualSense advanced-haptics feasibility | **HID shipped**; audio haptics deferred (3 walls) | +| [`steam-controller-deck-support.md`](steam-controller-deck-support.md) | Rich Steam Controller / Steam Deck **input fidelity** (paddles · trackpads · gyro → virtual `hid-steam`) | **Design + M0 GREEN** (Linux bind proven); M1+ open | +| [`controller-only-mode.md`](controller-only-mode.md) | Controller-only **session shape** — Deck/desktop as a remote gamepad, no video/audio (complements ↑) | **Design** — not yet implemented | | [`archive/windows-secure-desktop.md`](archive/windows-secure-desktop.md) | Two-process WGC secure-desktop design | **Archived** — shipped but now a fallback (IDD-push primary) | Plus `research/gamestream-protocol-research.json` — raw Moonlight/GameStream wire reference (data, not prose). @@ -74,6 +76,10 @@ owning doc.) **Game library** - 6 remaining providers (Desktop/Flatpak, itch.io, Ubisoft Connect, Amazon Games, Battle.net, EA app); the `/library/art//` mgmt endpoint; refactor `library.rs` into a `library/` dir; 8 open design questions; optional SteamGridDB v2 enrichment. → `game-library-stores` +**Controllers / input** +- Rich Steam Controller / Steam Deck capture + virtual `hid-steam` inject (M1+ — Linux UHID, then clients, then deferred Windows UMDF). → `steam-controller-deck-support` +- Controller-only session shape (Deck/desktop as a remote gamepad, no video/audio) — `session_flags`/`SESSION_INPUT_ONLY` protocol bit + host skip-data-plane branch + client controller-only path. → `controller-only-mode` + **Multi-user / sessions** - gamescope per-session input/audio isolation (independent desktops) — the 4 plumbing items, deferred. → `gamescope-multiuser`, `implementation-plan` diff --git a/design/controller-only-mode.md b/design/controller-only-mode.md new file mode 100644 index 0000000..d7a0440 --- /dev/null +++ b/design/controller-only-mode.md @@ -0,0 +1,299 @@ +# Controller-only mode (Deck / desktop as a remote gamepad) + +> **Status:** **DESIGN — not yet implemented.** Locked decisions (2026-06-29): build the +> **full-fidelity** path directly (no plain-Xbox interim), capture this as a doc before code. +> This is the **session-shape** complement to `design/steam-controller-deck-support.md` (which is +> the **input-fidelity** work: capturing the Deck's paddles/trackpads/gyro and injecting a virtual +> `hid-steam`/DualSense device). Controller-only mode reuses that capture + inject pipeline +> verbatim; it only adds a negotiated "no video / no audio" session shape so the Deck can be a +> wireless gamepad for a PC **without** the wasteful return video stream. + +## 1. Goal + the use case + +Let a punktfunk client (a Steam Deck, but also any desktop with a controller) connect to a +punktfunk host and forward **only controller input** — no video stream, no audio stream. The host +user watches their **own** monitor; the Deck is just a wireless, full-fidelity gamepad. Rumble / +lightbar / adaptive-trigger / HID feedback still flows back on the existing side planes. + +Concretely this turns the Deck into: + +- a **couch controller** for a PC wired to a TV — gyro aim, both trackpads, the 4 back grips, with + **lower** latency than streaming (no encode/decode round-trip at all), and no bandwidth wasted on + a video feed you aren't looking at; +- one of **several** Decks/pads driving one shared-screen PC for local co-op (rides the multi-pad + work already in flight); +- a way to use the Deck's superior input surface (trackpads + gyro) on a game running on a + beefier host. + +**Non-goal (for v1):** forwarding a real keyboard/mouse. The Deck's trackpads/gyro are carried as +*gamepad* fidelity (DualSense/Steam touchpad + motion planes), which is display-independent (§5). +A genuine keyboard/mouse forward rides the libei/portal pointer path, which is *not* +display-independent — deferred to a follow-on (§9). + +## 2. Does SteamOS already do this? — No, and that's the opening + +Researched 2026-06-29 (web). Summary of why this is worth building: + +- **Officially**, Valve's only endorsed "Deck as a controller for another PC" path is **Steam + Remote Play / Steam Link in reverse** — but it **always streams the game's video to the Deck** + even though you're looking at the PC's monitor. A dedicated controller-only mode has been + requested since **July 2022** (Steam community thread) and the matching `ValveSoftware/SteamOS` + issue **#1623 is "Closed as not planned."** SteamOS 3.8 (Jun 2026) and the new standalone Steam + Controller (2026 hardware) did **not** add it. +- **Community/DIY** splits three ways, all low-popularity (25–129★), several stale or "currently + broken": USB-C HID gadget (**GadgetDeck** — wired-only, BIOS Dual-Role toggle, no gyro/trackpad, + unmaintained), Bluetooth HID (**steamdeck-bt-controller-emulator** — pairing/recognition pain + BT + latency), and network (**Deckpad** → commercial *paid* VirtualHere USB-over-IP, "currently + broken"; **swicd-remote-gamepad** → ViGEm, Windows-only, experimental). +- **The ceiling every existing tool hits:** none cleanly carries the Deck's **gyro + dual trackpads + + the 4 back buttons (L4/L5/R4/R5)** to the remote host, because Steam's emulated Xbox pad + (`28DE:11FF`) hides them. + +punktfunk is uniquely positioned: it already has a low-latency QUIC input back-channel, host-side +virtual Xbox-360 / DualSense / DS4 (and, in flight, `hid-steam`) pad injection with rumble/lightbar/ +adaptive-trigger feedback, and SDL3 capture. Controller-only mode + the +`steam-controller-deck-support.md` capture work together deliver exactly the gap nobody else fills. + +Sources (load-bearing): SteamOS issue #1623 (closed not-planned) +; Steam community controller-only-mode request +; Valve FAQ +; GadgetDeck +; Deckpad ; +Steam Deck HID deep-dive . + +## 3. The key synergy: controller-only mode *dissolves* the Game-Mode capture wall + +`steam-controller-deck-support.md` §6 / Wall A: on the Deck, SDL3's HIDAPI driver can open the raw +`28DE:1205` and expose paddles + both trackpads + gyro as a first-class SDL gamepad — **but in Deck +Game Mode, Steam Input grabs the device exclusively** and re-presents it as the gutted `28DE:11FF` +virtual XInput pad, so the rich controls silently vanish. The only escape there is the +disable-Steam-Input-per-title UX. + +**Controller-only mode's natural launch context avoids that wall entirely.** The use case is "the +Deck is a controller, no game runs on the Deck" → it runs as a **desktop-mode / standalone app**, +where Steam Input is **not** managing the internal pad, so SDL3 binds `28DE:1205` and gets full +fidelity with no UX gymnastics. So the two features are mutually reinforcing: controller-only mode +is the very scenario in which full-fidelity Deck capture "just works." + +The capture-side rule is therefore the same one §6 documents, and the client **must verify at +runtime it opened `28DE:1205` (HIDAPI GUID ending `6800`), not `28DE:11FF`** — if it only sees +`11FF`, Steam owns the pad and gyro/trackpad/grips are unavailable; surface that to the user. + +## 4. Architecture — input plane is already decoupled from video + +A punktfunk/1 native session already runs **two independent transports** (verified in-tree): + +- a **QUIC** control connection that carries the `Hello`/`Welcome`/`Start` handshake **and every + side plane** as datagrams demuxed by first byte: input `0xC8`, rich input `0xCC` (DualSense/Deck + touchpad + motion), mic uplink `0xCB`, audio `0xC9`, rumble `0xCA`, HID-out `0xCD`; +- a **raw-UDP data plane** (`Session`, FEC + AES-GCM) that carries **only** the video AUs. + +**Input never touches the UDP data plane** — it rides QUIC datagrams. So an input-only session is +"run the QUIC handshake + side planes, never bind/open the UDP data plane." The honest work is +making **both** ends *skip the data plane* and, on the host, *not spin up a virtual display + +encoder* (a desktop PC the operator is watching has no reason to allocate a headless virtual output +or burn an NVENC slot). + +Critically: **gamepads are system-global kernel devices, not tied to any virtual output.** The +per-session `PadBackend` (`punktfunk1.rs:1396`) creates an Xbox-360 pad on `/dev/uinput`, or a +DualSense/DS4/Steam pad on `/dev/uhid` — all visible to Steam/Proton/every game on the host's real +seat with **zero** display involvement. Rumble/HID feedback (`0xCA`/`0xCD`) and DualSense/Deck +touchpad+motion (`0xCC` rich input, `apply_rich` at `:1467`/`:1606`) flow on the same per-session +input thread, also display-independent. So a controller-only session needs **no virtual display, no +compositor, no portal grant, no encoder** — just the input thread + the pad backend. + +`clients/probe --input-test` already proves the shape: it connects, streams scripted gamepad +datagrams the host injects into a real pad, and never decodes video. + +## 5. Protocol / ABI change — one `session_flags` byte (additive, fwd-compatible) + +Reuse the **exact** trailing-byte back-compat discipline `Hello`/`Welcome` already apply to +`compositor`/`gamepad`/`video_caps`/`audio_channels` (`quic.rs:655-882`). Add a **session-flags** +byte as the new last trailing field on both. + +``` +quic.rs (core): + pub const SESSION_INPUT_ONLY: u8 = 0x01; // bit 0 of session_flags + + Hello { …, audio_channels: u8, session_flags: u8 } // new trailing byte after audio_channels + Welcome { …, audio_channels: u8, session_flags: u8 } // echoes the resolved shape (offset 66) +``` + +### 5.1 Encode (placeholder discipline) + +`Hello::encode` already emits `video_caps` / `audio_channels` only when non-default, emitting +upstream placeholders so each lands at a deterministic offset. Extend the same logic: + +``` +need_placeholders = video_caps != 0 || audio_channels != 2 || session_flags != 0; +// video_caps emitted when video_caps != 0 || audio_channels != 2 || session_flags != 0 +// audio_channels emitted when audio_channels != 2 || session_flags != 0 +// session_flags emitted when session_flags != 0 (one more trailing byte, after audio_channels) +``` + +`Hello::decode` reads it one past `audio_channels` (i.e. `video_caps_off + 2`), defaulting to `0` +(no flags) when absent → an older peer requests an ordinary video session, byte-identical wire. +`Welcome` is simpler (fixed-position trailing bytes): append `session_flags` at **offset 66**, +`b.get(66).copied().unwrap_or(0)`. + +> **Why a flags byte, not an overloaded `Mode{0,0,0}` sentinel:** a flag is explicit and leaves +> room for future session-shape bits (e.g. audio-only, input+audio). `Mode` stays strictly a +> display mode. (Open question 7.1 — confirm with the user, but this is the recommendation.) + +### 5.2 ABI + +- New `connect_ex` rung in the existing ladder (the `connect_exN` precedent) that takes a + `session_flags` (or a bool `input_only`) and stores it on `NativeClient`; legacy `connect_ex*` + stay byte-for-byte. Regenerate `include/punktfunk_core.h` (CI fails on drift). +- New constant `PUNKTFUNK_SESSION_INPUT_ONLY = 0x01`. +- `next_au` / `next_frame` / `next_audio` simply return `NoFrame`/`Closed` in an input-only + connection; `send_input` / `send_rich_input` / `next_rumble` / `next_hidout` are unchanged — they + are already the full input-only surface. + +## 6. Host changes — `serve_session` branch (`punktfunk1.rs:508`) + +Branch on `hello.session_flags & SESSION_INPUT_ONLY`. When set: + +| Step | Today (video session) | Input-only | +|---|---|---| +| `--max-concurrent` permit (`:640` `_permit`) | acquired (NVENC slot) | **skip** — input-only must not consume a GPU slot (§7.4) | +| `validate_dimensions` (`:654`) | required | **skip** | +| `resolve_compositor` (`:668`) | required (Virtual source) | **skip** — no virtual display | +| bit-depth / chroma / HDR / 444 probes, bitrate clamp | run | **skip** | +| `Welcome` (`:794`) | real mode + udp_port + caps | **sentinel** mode `{0,0,0}` + `udp_port=0` + `session_flags=INPUT_ONLY`; still carries the resolved `gamepad` backend | +| `Start::decode` (`:845`) | read client udp port | read + **ignore** (harmless no-op) | +| `input_thread` spawn (`:980`) | spawn | **spawn (unchanged)** — this is the whole point | +| client→host datagram demux (`:989`) | spawn | **spawn (unchanged)** — `0xC8`/`0xCC`/`0xCB` in, `0xCA`/`0xCD` out | +| `audio_thread` (`:1032`, already gated on `source==Virtual`) | spawn | **skip** (add `&& !input_only`) | +| `virtual_stream(SessionContext{…})` (`:1155`) — the only place a display+encoder open and the UDP socket binds | run | **replace** with `await stop / conn.closed()` — no UDP bind happens (the bind lives inside that block), no display, no encoder | +| teardown (`:1190+`) | releases input thread + pads + held keys | **unchanged** — already correct | + +Net host change is mostly **guards and deletions in one function**; no hot-path, FEC, crypto, or +injector changes. `cfg`-clean on Windows (no compositor/`virtual_stream` there either; the XUSB/UMDF +pads are likewise system-global, SendInput is irrelevant in a gamepad-only mode). + +### 6.1 Pad backend / fidelity (reuses `steam-controller-deck-support.md` verbatim) + +The full-fidelity path is **entirely** the in-flight Deck/DualSense inject work — controller-only +mode adds nothing here, it just runs it without video: + +- Deck → host resolves the **Steam `hid-steam`** backend (Linux UHID) when available so Steam Input + re-emits `28DE:11FF` with the user's bindings + correct glyphs; trackpads → `RichInput::TouchpadEx` + (`0xCC 0x03`), gyro/accel → `RichInput::Motion` (`0xCC 0x02`), back grips → `BTN_PADDLE1..4` + (`0xC8` bits). See that doc §4–§7. +- Where a real Steam pad is unavailable, the **DualSense remap fallback** (`steam_remap.rs`) folds + Steam-only inputs into a virtual DualSense (gyro→motion, right pad→touchpad, grips→configured + fallback) so nothing is silently dropped. +- `GamepadPref` resolution policy is that doc's §7 — **unchanged**; the Welcome echoes the real + resolved backend (honest downgrade). + +## 7. Client changes + +### 7.1 Core connector (`client.rs::worker_main:714`) + +Thread an `input_only` flag in. When set: do the full `Hello`/`Welcome` handshake and spawn the +input/mic/rich/ctrl/datagram-demux tasks (so `send_input`/`send_rich_input` + `next_rumble`/ +`next_hidout` keep working), but **skip** the UDP port reservation + `Start`-derived +`UdpTransport::connect` + `Session` + the data-plane pump (`:810-849,1041`). `next_frame`/ +`next_audio` return `Closed`/`NoFrame`. + +### 7.2 Deck / Linux client (`clients/linux`) — a "Use as controller" path + +Add a connect path that opens the connection **input-only** and runs **only** the app-lifetime +`GamepadService` (`clients/linux/src/gamepad.rs`) — it already `attach`es the connector, forwards +pads via `send_input` (`:161`), DualSense/Deck touchpad+motion via `send_rich_input`, and drains +`next_rumble` (`:566`)/`next_hidout` (`:583`). **Do not** run `session.rs`'s video/audio pump +(`:135-200`) — no decoder, no video window, no PipeWire player. UI is a minimal "Connected as +controller — · " status surface (battery, latency, a Disconnect button), no video +widget. + +On the Deck specifically: set `SDL_JOYSTICK_HIDAPI_STEAMDECK`/`HIDAPI_STEAM` before `sdl3::init()`, +resolve `GamepadPref::SteamDeck` from VID `0x28DE`, and **assert the opened device is `28DE:1205` +(HIDAPI GUID `…6800`), not `11FF`** — if `11FF`, show "Steam is managing this controller; full +gyro/trackpad/grip fidelity needs desktop mode / Steam Input off." (Capture mechanics = +`steam-controller-deck-support.md` §6.) + +### 7.3 Other clients + +`clients/windows` gets the same input-only connect + SDL `GamepadService`-only path (XUSB/UMDF pads +are system-global; no SwapChainPanel/decode). Apple/Android: parity is optional and lower-priority — +the connector ABI already exposes everything; a "controller-only" UI mode is a small add once the +core flag lands. Scope per the user's roadmap, not blocking. + +### 7.4 Session lifetime / accounting + +An input-only session is long-lived (until disconnect), like a video session, but must **not** +count against `--max-concurrent` (it holds no NVENC) and should be **exempt** from any `--seconds` +duration cap. The mgmt API / web console should list it distinctly (it is **not** a "stream" — no +fps/bitrate/mode), and `--max-sessions` accounting should likely treat it as its own class +(open question 8.x). mDNS advertisement is unchanged (the host already advertises native service; +input-only is a per-session negotiation, not a service variant). + +## 8. Security / trust — unchanged + +A controller-only session is a **full punktfunk/1 session**: same SPAKE2 PIN pairing / TOFU / +`--require-pairing` gate, same QUIC client-auth + pinned-fingerprint trust. The *only* difference is +the absent data plane. No new attack surface — if anything less (no UDP socket, no FEC reassembler, +no decoder fed attacker bytes). The host still requires `/dev/uinput` (+ `/dev/uhid` for DualSense/ +Steam) writable — the documented `input` group + `60-punktfunk.rules` setup. + +## 9. Milestones + +- **M1 — protocol + ABI:** `session_flags` byte + `SESSION_INPUT_ONLY` on `Hello`/`Welcome` with + round-trip + old-peer-default unit tests; new `connect_ex` rung; regenerate the C header. +- **M2 — host branch:** `serve_session` input-only path (skip display/encoder/audio/permit, keep + input thread + pads), `cfg`-clean on Windows. Loopback test: handshake completes, **no UDP data + plane binds**, gamepad events still inject into a real uinput pad. +- **M3 — Deck/Linux client:** "Use as controller" connect + `GamepadService`-only run; `28DE:1205` + vs `11FF` runtime check + user messaging. +- **M4 — full fidelity on-glass:** with `steam-controller-deck-support.md`'s Deck capture + inject, + validate Deck (desktop mode) → host: paddles + both trackpads + gyro reach the host pad, Steam + Input re-emits with bindings/glyphs, rumble returns. Glass-to-glass input latency vs a wired pad. +- **M5 — Windows client parity + mgmt/web "controller session" surfacing.** +- **M6 (deferred) — keyboard/mouse forward** over the libei/portal path (needs the active-session + RemoteDesktop grant; not display-independent). + +## 10. Risks / open questions + +**Open questions (decide with the user):** + +1. **`session_flags` byte vs `Mode{0,0,0}` sentinel** for "no video" — recommend the explicit flags + byte (room for future shapes). *(Recommended; confirm.)* +2. Should an input-only session be **exempt** from `--max-concurrent` and `--seconds`? (Recommend + yes — it holds no GPU.) +3. Should mgmt/web track controller-only sessions as a distinct class (no fps/bitrate/mode), and + should `--max-sessions` count them? +4. Deck default backend: **Steam `hid-steam`** (best — Steam Input bindings/glyphs) vs **DualSense** + (works off-Steam too). Tie to `steam-controller-deck-support.md`'s resolution policy. + +**Risks:** + +- **Capture wall (inherited):** full fidelity requires SDL to bind `28DE:1205`; if the Deck is in + Game Mode / Steam owns the pad, it degrades to `11FF` (sticks/buttons only). Mitigated by the + desktop-mode use case + runtime check + user messaging (§3, §7.2). +- **Host `/dev/uinput`(+`/dev/uhid`) perms** — a normal desktop PC operator must do the one-time + input-group/udev setup for the virtual pad to appear (already documented). +- **Handshake assumes a data plane:** `Start{client_udp_port}` + non-zero `udp_port` in `Welcome` + must become harmless no-ops (send 0 / ignore) without shifting the legacy wire — the trailing-byte + placeholder discipline is fragile; **add round-trip + old-peer tests** (M1). +- **Control-channel messages** (LossReport/Reconfigure/Probe/ClockProbe) assume a data plane; the + host (`:881`) + client (`:935`) control tasks must tolerate an input-only session with none — + mostly already fine since those are reactive. +- **Windows parity:** verify the input-only branch compiles `cfg`-clean where there is no + compositor/`virtual_stream`. + +## 11. Validation plan + +**Loopback (no hardware):** +- `quic.rs`: `session_flags` encode/decode round-trip; old-peer (no flags byte) → ordinary session; + flags coexist with non-default `video_caps`/`audio_channels` (placeholder offsets hold). +- Host: an input-only synthetic host+client asserting (a) handshake completes, (b) **no UDP socket + binds**, (c) no display/encoder opens, (d) scripted `0xC8`/`0xCC` events inject into a real pad, + (e) `0xCA`/`0xCD` feedback returns. Extend the `clients/probe` / `test-loopback.sh` harness. + +**On-box / on-glass (with `steam-controller-deck-support.md` landed):** +- Deck (desktop mode) → this host, controller-only: confirm `28DE:1205` opens (not `11FF`); paddles + + both trackpads + gyro reach the host pad; Steam Input on the host re-emits with bindings/glyphs; + rumble round-trips; measure input-only latency vs a wired pad and vs the streaming path. +- Confirm the host opens **no** virtual output / encoder (logs) and the session does **not** consume + a `--max-concurrent` slot (run alongside N video sessions). diff --git a/design/steam-deck-passthrough-plan.md b/design/steam-deck-passthrough-plan.md index c57c2d1..29a918a 100644 --- a/design/steam-deck-passthrough-plan.md +++ b/design/steam-deck-passthrough-plan.md @@ -1,12 +1,33 @@ # Plan: production-ready Steam Deck pass-through (client) + shippable virtual Deck on any Linux host -> **Status (2026-06-29):** architecture validated end-to-end on hardware; this is the build plan to -> ship it. Companion doc: [`steam-controller-deck-support.md`](steam-controller-deck-support.md) +> **Status (2026-06-29): BUILT — code-complete, all CI checks green on Linux (build · `clippy +> -D warnings` · `fmt` · ~270 tests), adversarially reviewed; NOT yet on-glass validated, NOT pushed.** +> Implemented in one pass: +> - **usbip/`vhci_hcd` transport** (`crates/punktfunk-host/src/inject/linux/steam_usbip.rs`) presenting +> a real interface-2 USB Deck, with **in-process vhci attach** (sysfs `OP_REQ_IMPORT` handshake) and a +> bounded `usbip`-CLI fallback. Backed by a **vendored, libusb-free trim** of the `usbip` crate +> (`crates/punktfunk-host/vendor/usbip-sim/`, MIT, see its NOTICE). +> - **Selection ladder** `raw_gadget` (SteamOS) → `usbip` (`vhci_hcd`, universal) → UHID +> (`steam_controller.rs::open_transport`); `PUNKTFUNK_STEAM_USBIP=0/1`, `PUNKTFUNK_USBIP_ATTACH=inproc|cli`. +> - **Shared Deck device contract** (captured descriptors + `0x83`/`0xAE` `feature_reply` + a +> Steam-accepted serial) consolidated into `steam_proto.rs`; the gadget now reuses it. +> - **Client leave-shortcuts**: keyboard **Ctrl+Alt+Shift+D** + controller **hold the escape chord +> (L1+R1+Start+Select) ≥1.5 s** → disconnect (short press still leaves fullscreen). Steam/QAM are NOT +> in the chord. Linux client only for now (windows/apple/android mirror is future work). +> +> **Decisions taken** (the plan's open questions): vendor-trim the crate (no libusb) ✓; in-process +> attach primary + CLI fallback ✓; escalate the existing escape chord (long-hold) ✓; keep BOTH +> `raw_gadget` (SteamOS fast-path) and usbip (universal) behind the transport ladder ✓. +> +> **Remaining = §6 on-glass validation** (Bazzite `192.168.1.41` + Deck `192.168.1.253`): confirm the +> in-process usbip attach promotes the Deck in game mode (Steam + QAM reach the game-mode UI), the +> raw_gadget path still works on SteamOS (regression), and the leave-shortcuts fire. The dev box has no +> Steam + no root, so this could not be run here. +> +> Companion doc: [`steam-controller-deck-support.md`](steam-controller-deck-support.md) > (the virtual-Deck design + §11 the interface-2 / gadget story). The virtual Steam Deck that Steam > Input promotes is **already built, hardware-validated, and default-on for SteamOS** -> (`raw_gadget`/`dummy_hcd`, glass-confirmed in-game on a real Deck). What remains is (a) exact -> Deck pass-through from the Linux *client* incl. the Steam + QAM buttons, (b) a **shippable** virtual -> Deck on non-SteamOS Linux hosts (Bazzite etc.) via **usbip/`vhci_hcd`**, and (c) a leave-shortcut. +> (`raw_gadget`/`dummy_hcd`, glass-confirmed in-game on a real Deck). ## Goal