feat(host/steam): shippable usbip/vhci_hcd virtual Deck + client leave-shortcuts
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled

Steam Deck pass-through (design/steam-deck-passthrough-plan.md), code-complete +
all CI checks green on Linux + adversarially reviewed; on-glass validation pending:

- usbip/`vhci_hcd` virtual Deck transport (inject/linux/steam_usbip.rs) for
  non-SteamOS hosts (Bazzite/generic) — presents a real interface-2 USB Deck so
  Steam Input promotes it. In-process vhci attach (loopback OP_REQ_IMPORT handshake
  → sysfs attach) with a bounded `usbip`-CLI fallback; detach on drop.
- Backed by a vendored, libusb-free trim of the `usbip` crate
  (crates/punktfunk-host/vendor/usbip-sim, MIT + NOTICE; host/cdc/hid + rusb/nusb
  removed; interrupt-IN paced by bInterval).
- Selection ladder raw_gadget (SteamOS fast-path) → usbip (universal) → UHID,
  with PUNKTFUNK_STEAM_USBIP / PUNKTFUNK_USBIP_ATTACH knobs.
- Shared Deck descriptors + the 0x83/0xAE feature contract + a Steam-accepted
  serial consolidated into steam_proto.rs; the raw_gadget backend reuses them.
- Linux client leave-shortcuts: Ctrl+Alt+Shift+D + holding the escape chord
  (L1+R1+Start+Select) >=1.5s end the session (short press still exits
  fullscreen); the chord state resets across sessions.

Also bundles in-progress work already staged in the tree:
- host(kwin): xdg-output logical-geometry mapping so the KWin fake_input backend
  places absolute coordinates correctly under display scaling.
- docs: design/README index entries + design/controller-only-mode.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 19:17:00 +00:00
parent 831b37b4b7
commit 580b1ea7a7
26 changed files with 3292 additions and 145 deletions
+6
View File
@@ -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 {
@@ -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<ZxdgOutputV1>,
/// 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<FakeInput>,
xdg_mgr: Option<ZxdgOutputManagerV1>,
outputs: Vec<OutputTrack>,
}
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<State>) {
if o.xdg_output.is_none() {
o.xdg_output = Some(mgr.get_xdg_output(&o.wl_output, qh, o.name));
}
}
}
impl Dispatch<WlRegistry, ()> for State {
@@ -63,15 +103,57 @@ impl Dispatch<WlRegistry, ()> for State {
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<FakeInput, ()> for State {
}
}
impl Dispatch<WlOutput, u32> for State {
fn event(
state: &mut Self,
_: &WlOutput,
event: wl_output::Event,
name: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
// 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<ZxdgOutputV1, u32> for State {
fn event(
state: &mut Self,
_: &ZxdgOutputV1,
event: zxdg_output_v1::Event,
name: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
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<ZxdgOutputManagerV1, ()> for State {
fn event(
_: &mut Self,
_: &ZxdgOutputManagerV1,
_: <ZxdgOutputManagerV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
pub struct KwinFakeInjector {
conn: Connection,
queue: EventQueue<State>,
state: State,
fake: FakeInput,
/// When output geometry was last re-read; throttles the per-event roundtrip (see `refresh_geometry`).
last_refresh: Option<Instant>,
}
/// 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<Self> {
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 {
@@ -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<DeckTransport> {
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<Option<DeckTransport>>,
state: Vec<SteamState>,
@@ -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);
@@ -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<u8> {
// 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());
@@ -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<u8> {
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<Mutex<[u8; 64]>>,
/// Rumble extracted from the kernel's SET_REPORTs, drained by [`SteamDeckUsbip::service`].
feedback: Arc<Mutex<SteamFeedback>>,
/// The host's last SET_REPORT command (drives [`feature_reply`]).
last_set: Vec<u8>,
serial: String,
unit_id: u32,
}
impl UsbInterfaceHandler for ControllerHandler {
fn get_class_specific_descriptor(&self) -> Vec<u8> {
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<Vec<u8>> {
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<u8>,
}
impl UsbInterfaceHandler for IdleHidHandler {
fn get_class_specific_descriptor(&self) -> Vec<u8> {
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<Vec<u8>> {
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<Mutex<Box<dyn UsbInterfaceHandler + Send>>> {
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<Mutex<[u8; 64]>>,
feedback: &Arc<Mutex<SteamFeedback>>,
) -> 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<tokio::sync::Notify>,
join: Option<JoinHandle<()>>,
}
impl ServerThread {
/// Spawn the server on `listener`, serving exactly the one simulated `dev`.
fn spawn(listener: std::net::TcpListener, dev: UsbDevice) -> Result<ServerThread> {
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<UsbIpServer>,
stop: Arc<tokio::sync::Notify>,
) {
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<Mutex<[u8; 64]>>,
feedback: Arc<Mutex<SteamFeedback>>,
/// 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<TcpStream>,
/// 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<SteamDeckUsbip> {
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<SteamDeckUsbip> {
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<SteamDeckUsbip> {
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<TcpStream> {
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<PathBuf> {
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<String> {
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::<u16>().ok()?;
let sta = sta_str.parse::<u32>().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<u16> {
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<u16> {
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<u16>) -> Result<u16> {
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"
);
}
}
@@ -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));
}
}