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
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user