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:
Generated
+24
@@ -2331,6 +2331,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -2839,12 +2850,14 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"usbip-sim",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-axum",
|
"utoipa-axum",
|
||||||
"utoipa-scalar",
|
"utoipa-scalar",
|
||||||
"wasapi",
|
"wasapi",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
"wayland-protocols-misc",
|
"wayland-protocols-misc",
|
||||||
"wayland-protocols-wlr",
|
"wayland-protocols-wlr",
|
||||||
"wayland-scanner",
|
"wayland-scanner",
|
||||||
@@ -4236,6 +4249,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "usbip-sim"
|
||||||
|
version = "0.8.0"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
|
|||||||
@@ -767,6 +767,7 @@ fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>,
|
|||||||
connector,
|
connector,
|
||||||
frames.take().expect("Connected delivered once"),
|
frames.take().expect("Connected delivered once"),
|
||||||
app.gamepad.escape_events(),
|
app.gamepad.escape_events(),
|
||||||
|
app.gamepad.disconnect_events(),
|
||||||
handle.stop.clone(),
|
handle.stop.clone(),
|
||||||
inhibit,
|
inhibit,
|
||||||
&title,
|
&title,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use punktfunk_core::quic::{HidOutput, RichInput};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
|
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
|
||||||
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
|
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
|
||||||
@@ -33,8 +33,15 @@ const G: f32 = 9.80665;
|
|||||||
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
|
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
|
||||||
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
|
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
|
||||||
/// is leaving anyway); we only also raise the escape signal.
|
/// is leaving anyway); we only also raise the escape signal.
|
||||||
|
///
|
||||||
|
/// **Escalation:** a quick press leaves fullscreen / releases capture; *holding* the same
|
||||||
|
/// chord for [`DISCONNECT_HOLD`] ends the session. Deliberately NOT the Steam / QAM buttons —
|
||||||
|
/// those are the marquee pass-through controls that now reach the host's game-mode UI.
|
||||||
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
|
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
|
||||||
|
|
||||||
|
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||||
|
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PadInfo {
|
pub struct PadInfo {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -90,6 +97,9 @@ pub struct GamepadService {
|
|||||||
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||||
/// fullscreen + release capture.
|
/// fullscreen + release capture.
|
||||||
escape_rx: async_channel::Receiver<()>,
|
escape_rx: async_channel::Receiver<()>,
|
||||||
|
/// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page
|
||||||
|
/// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D).
|
||||||
|
disconnect_rx: async_channel::Receiver<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GamepadService {
|
impl GamepadService {
|
||||||
@@ -99,11 +109,12 @@ impl GamepadService {
|
|||||||
let pinned = Arc::new(Mutex::new(None));
|
let pinned = Arc::new(Mutex::new(None));
|
||||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||||
|
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
|
||||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||||
if let Err(e) = std::thread::Builder::new()
|
if let Err(e) = std::thread::Builder::new()
|
||||||
.name("punktfunk-gamepad".into())
|
.name("punktfunk-gamepad".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
|
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -116,6 +127,7 @@ impl GamepadService {
|
|||||||
pinned,
|
pinned,
|
||||||
ctl,
|
ctl,
|
||||||
escape_rx,
|
escape_rx,
|
||||||
|
disconnect_rx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +137,12 @@ impl GamepadService {
|
|||||||
self.escape_rx.clone()
|
self.escape_rx.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A receiver that yields one `()` when the escape chord is held past [`DISCONNECT_HOLD`]
|
||||||
|
/// (controller disconnect). A fresh clone per call; the stream page spawns a future on it.
|
||||||
|
pub fn disconnect_events(&self) -> async_channel::Receiver<()> {
|
||||||
|
self.disconnect_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pads(&self) -> Vec<PadInfo> {
|
pub fn pads(&self) -> Vec<PadInfo> {
|
||||||
self.pads.lock().unwrap().clone()
|
self.pads.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
@@ -274,8 +292,15 @@ struct Worker {
|
|||||||
last_accel: [i16; 3],
|
last_accel: [i16; 3],
|
||||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||||
escape_tx: async_channel::Sender<()>,
|
escape_tx: async_channel::Sender<()>,
|
||||||
|
/// Raises the UI disconnect signal when the escape chord is held past [`DISCONNECT_HOLD`].
|
||||||
|
disconnect_tx: async_channel::Sender<()>,
|
||||||
/// The escape chord is fully held — latched so it fires once, not every poll.
|
/// The escape chord is fully held — latched so it fires once, not every poll.
|
||||||
chord_armed: bool,
|
chord_armed: bool,
|
||||||
|
/// When the escape chord became fully held (drives the hold-to-disconnect escalation); `None`
|
||||||
|
/// when the chord is broken.
|
||||||
|
chord_since: Option<Instant>,
|
||||||
|
/// The disconnect signal already fired for the current hold — latched so it fires once.
|
||||||
|
disconnect_fired: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Worker {
|
impl Worker {
|
||||||
@@ -347,28 +372,61 @@ impl Worker {
|
|||||||
self.last_axis = [i32::MIN; 6];
|
self.last_axis = [i32::MIN; 6];
|
||||||
self.held_touches.clear();
|
self.held_touches.clear();
|
||||||
}
|
}
|
||||||
|
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
|
||||||
|
self.reset_chord();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
|
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
|
||||||
/// fires once per press). Called after each button-down updates `held_buttons`.
|
/// fires once per press) and start the hold-to-disconnect timer. Called after each
|
||||||
|
/// button-down updates `held_buttons`.
|
||||||
fn maybe_fire_escape(&mut self) {
|
fn maybe_fire_escape(&mut self) {
|
||||||
if self.chord_armed {
|
if self.chord_armed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||||
self.chord_armed = true;
|
self.chord_armed = true;
|
||||||
|
self.chord_since = Some(Instant::now());
|
||||||
let _ = self.escape_tx.try_send(());
|
let _ = self.escape_tx.try_send(());
|
||||||
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen");
|
tracing::info!(
|
||||||
|
"gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen (hold to disconnect)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire the disconnect signal once the escape chord has been continuously held past
|
||||||
|
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
|
||||||
|
fn maybe_fire_disconnect(&mut self) {
|
||||||
|
if self.disconnect_fired {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(since) = self.chord_since {
|
||||||
|
if since.elapsed() >= DISCONNECT_HOLD {
|
||||||
|
self.disconnect_fired = true;
|
||||||
|
let _ = self.disconnect_tx.try_send(());
|
||||||
|
tracing::info!("gamepad escape chord held — disconnecting");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-arm once the chord is broken (any of its buttons released).
|
/// Re-arm once the chord is broken (any of its buttons released).
|
||||||
fn rearm_escape(&mut self) {
|
fn rearm_escape(&mut self) {
|
||||||
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||||
self.chord_armed = false;
|
self.reset_chord();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear the escape/disconnect chord latches. Called at every session boundary
|
||||||
|
/// ([`flush_held`](Self::flush_held) on detach/pad-switch + on attach): the hold-to-disconnect
|
||||||
|
/// path *always* ends the session while the chord is still physically held, so the matching
|
||||||
|
/// button-up events arrive after detach (dropped by the `attached` guard) and `rearm_escape`
|
||||||
|
/// never runs — without this the latched state would leak into the next session and either
|
||||||
|
/// swallow its first chord press or fire a stale disconnect on connect.
|
||||||
|
fn reset_chord(&mut self) {
|
||||||
|
self.chord_armed = false;
|
||||||
|
self.chord_since = None;
|
||||||
|
self.disconnect_fired = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||||
fn set_sensors(&mut self, enabled: bool) {
|
fn set_sensors(&mut self, enabled: bool) {
|
||||||
let Some(id) = self.active_id() else { return };
|
let Some(id) = self.active_id() else { return };
|
||||||
@@ -440,6 +498,7 @@ fn run(
|
|||||||
pinned_out: &Mutex<Option<u32>>,
|
pinned_out: &Mutex<Option<u32>>,
|
||||||
ctl: &Receiver<Ctl>,
|
ctl: &Receiver<Ctl>,
|
||||||
escape_tx: &async_channel::Sender<()>,
|
escape_tx: &async_channel::Sender<()>,
|
||||||
|
disconnect_tx: &async_channel::Sender<()>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||||
// own thread.
|
// own thread.
|
||||||
@@ -466,7 +525,10 @@ fn run(
|
|||||||
held_touches: std::collections::HashSet::new(),
|
held_touches: std::collections::HashSet::new(),
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
escape_tx: escape_tx.clone(),
|
escape_tx: escape_tx.clone(),
|
||||||
|
disconnect_tx: disconnect_tx.clone(),
|
||||||
chord_armed: false,
|
chord_armed: false,
|
||||||
|
chord_since: None,
|
||||||
|
disconnect_fired: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let publish = |w: &Worker| {
|
let publish = |w: &Worker| {
|
||||||
@@ -484,6 +546,7 @@ fn run(
|
|||||||
Ok(Ctl::Attach(c)) => {
|
Ok(Ctl::Attach(c)) => {
|
||||||
w.attached = Some(c);
|
w.attached = Some(c);
|
||||||
w.last_axis = [i32::MIN; 6];
|
w.last_axis = [i32::MIN; 6];
|
||||||
|
w.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||||
w.set_sensors(true);
|
w.set_sensors(true);
|
||||||
}
|
}
|
||||||
Ok(Ctl::Detach) => {
|
Ok(Ctl::Detach) => {
|
||||||
@@ -646,6 +709,10 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
|
||||||
|
// new button events; the chord itself is only detected while a session is attached).
|
||||||
|
w.maybe_fire_disconnect();
|
||||||
|
|
||||||
// Feedback planes (this thread is their single consumer). The host re-sends
|
// Feedback planes (this thread is their single consumer). The host re-sends
|
||||||
// rumble state periodically, so a generous duration with refresh-on-update is
|
// rumble state periodically, so a generous duration with refresh-on-update is
|
||||||
// safe — a dropped stop heals within ~500 ms.
|
// safe — a dropped stop heals within ~500 ms.
|
||||||
|
|||||||
@@ -124,12 +124,13 @@ impl Capture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
frames: async_channel::Receiver<DecodedFrame>,
|
frames: async_channel::Receiver<DecodedFrame>,
|
||||||
escape_rx: async_channel::Receiver<()>,
|
escape_rx: async_channel::Receiver<()>,
|
||||||
|
disconnect_rx: async_channel::Receiver<()>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -152,7 +153,7 @@ pub fn new(
|
|||||||
stats_label.set_margin_top(12);
|
stats_label.set_margin_top(12);
|
||||||
|
|
||||||
let hint = gtk::Label::new(Some(
|
let hint = gtk::Label::new(Some(
|
||||||
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases",
|
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects",
|
||||||
));
|
));
|
||||||
hint.add_css_class("osd");
|
hint.add_css_class("osd");
|
||||||
hint.set_halign(gtk::Align::Center);
|
hint.set_halign(gtk::Align::Center);
|
||||||
@@ -163,7 +164,9 @@ pub fn new(
|
|||||||
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
||||||
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
||||||
// only way out on a Steam Deck).
|
// only way out on a Steam Deck).
|
||||||
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen"));
|
let fs_hint = gtk::Label::new(Some(
|
||||||
|
"F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)",
|
||||||
|
));
|
||||||
fs_hint.add_css_class("osd");
|
fs_hint.add_css_class("osd");
|
||||||
fs_hint.set_halign(gtk::Align::Center);
|
fs_hint.set_halign(gtk::Align::Center);
|
||||||
fs_hint.set_valign(gtk::Align::Start);
|
fs_hint.set_valign(gtk::Align::Start);
|
||||||
@@ -297,6 +300,7 @@ pub fn new(
|
|||||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
let window_k = window.clone();
|
let window_k = window.clone();
|
||||||
|
let stop_kb = stop.clone();
|
||||||
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||||
let chord = gdk::ModifierType::CONTROL_MASK
|
let chord = gdk::ModifierType::CONTROL_MASK
|
||||||
| gdk::ModifierType::ALT_MASK
|
| gdk::ModifierType::ALT_MASK
|
||||||
@@ -309,6 +313,13 @@ pub fn new(
|
|||||||
}
|
}
|
||||||
return glib::Propagation::Stop;
|
return glib::Propagation::Stop;
|
||||||
}
|
}
|
||||||
|
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
||||||
|
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
||||||
|
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||||
|
cap.release();
|
||||||
|
stop_kb.store(true, Ordering::SeqCst);
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
if keyval == gdk::Key::F11 {
|
if keyval == gdk::Key::F11 {
|
||||||
if window_k.is_fullscreen() {
|
if window_k.is_fullscreen() {
|
||||||
window_k.unfullscreen();
|
window_k.unfullscreen();
|
||||||
@@ -442,6 +453,24 @@ pub fn new(
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Controller disconnect (escape chord held past the hold threshold) → end the session, the
|
||||||
|
// controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, which pops
|
||||||
|
// this page (and fires `hidden` below). One-shot — the session is going away.
|
||||||
|
let disconnect_future = {
|
||||||
|
let window = window.clone();
|
||||||
|
let cap = capture.clone();
|
||||||
|
let stop_d = stop.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if disconnect_rx.recv().await.is_ok() {
|
||||||
|
cap.release();
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
window.unfullscreen();
|
||||||
|
}
|
||||||
|
stop_d.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// The page's `hidden` fires once navigation away completes (back button, pop on
|
// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||||
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
||||||
{
|
{
|
||||||
@@ -449,6 +478,7 @@ pub fn new(
|
|||||||
let stop_h = stop.clone();
|
let stop_h = stop.clone();
|
||||||
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||||
let escape_future = RefCell::new(Some(escape_future));
|
let escape_future = RefCell::new(Some(escape_future));
|
||||||
|
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||||
page.connect_hidden(move |_| {
|
page.connect_hidden(move |_| {
|
||||||
tracing::debug!("stream page hidden — ending session");
|
tracing::debug!("stream page hidden — ending session");
|
||||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||||
@@ -458,6 +488,9 @@ pub fn new(
|
|||||||
if let Some(f) = escape_future.borrow_mut().take() {
|
if let Some(f) = escape_future.borrow_mut().take() {
|
||||||
f.abort();
|
f.abort();
|
||||||
}
|
}
|
||||||
|
if let Some(f) = disconnect_future.borrow_mut().take() {
|
||||||
|
f.abort();
|
||||||
|
}
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
window.unfullscreen();
|
window.unfullscreen();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
|||||||
wayland-client = "0.31"
|
wayland-client = "0.31"
|
||||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||||
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
||||||
|
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
|
||||||
|
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
|
||||||
|
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||||
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
|
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
|
||||||
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
|
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
|
||||||
# `wayland-backend` is referenced by the generated interface tables.
|
# `wayland-backend` is referenced by the generated interface tables.
|
||||||
@@ -119,6 +122,10 @@ ash = "0.38"
|
|||||||
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
|
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
|
||||||
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
|
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
|
||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
|
# Vendored + trimmed `usbip` server core (no libusb) — presents a virtual Steam Deck over USB/IP
|
||||||
|
# so the local `vhci_hcd` attaches it: the shippable, Secure-Boot-clean, Steam-Input-promotable
|
||||||
|
# virtual-Deck transport on non-SteamOS hosts (`inject/linux/steam_usbip.rs`). See the crate's NOTICE.
|
||||||
|
usbip-sim = { path = "vendor/usbip-sim" }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
|
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
|
||||||
|
|||||||
@@ -510,6 +510,12 @@ pub mod steam_proto;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/proto/steam_remap.rs"]
|
#[path = "inject/proto/steam_remap.rs"]
|
||||||
pub mod steam_remap;
|
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.
|
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
pub mod gamepad {
|
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
|
//! 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
|
//! `$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
|
//! 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
|
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space.
|
||||||
//! on a headless box (single per-session virtual output at the origin, scale 1) equals the streamed
|
//!
|
||||||
//! output's pixels.
|
//! 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)]
|
#![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).
|
// 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 super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use punktfunk_core::input::InputKind;
|
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::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
|
// 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.
|
// 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).
|
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend).
|
||||||
const SCROLL_HORIZONTAL: u32 = 1;
|
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).
|
/// Registry-bound globals (the Wayland dispatch state).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct State {
|
struct State {
|
||||||
fake: Option<FakeInput>,
|
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 {
|
impl Dispatch<WlRegistry, ()> for State {
|
||||||
@@ -63,15 +103,57 @@ impl Dispatch<WlRegistry, ()> for State {
|
|||||||
_: &Connection,
|
_: &Connection,
|
||||||
qh: &QueueHandle<Self>,
|
qh: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
if let wl_registry::Event::Global {
|
match event {
|
||||||
|
wl_registry::Event::Global {
|
||||||
name,
|
name,
|
||||||
interface,
|
interface,
|
||||||
version,
|
version,
|
||||||
} = event
|
} => match interface.as_str() {
|
||||||
{
|
"org_kde_kwin_fake_input" => {
|
||||||
if interface == "org_kde_kwin_fake_input" {
|
|
||||||
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
|
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 {
|
pub struct KwinFakeInjector {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
queue: EventQueue<State>,
|
queue: EventQueue<State>,
|
||||||
state: State,
|
state: State,
|
||||||
fake: FakeInput,
|
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 {
|
impl KwinFakeInjector {
|
||||||
pub fn open() -> Result<Self> {
|
pub fn open() -> Result<Self> {
|
||||||
let conn = Connection::connect_to_env()
|
let conn = Connection::connect_to_env()
|
||||||
@@ -122,13 +277,77 @@ impl KwinFakeInjector {
|
|||||||
.context("fake_input authenticate roundtrip")?;
|
.context("fake_input authenticate roundtrip")?;
|
||||||
conn.flush().ok();
|
conn.flush().ok();
|
||||||
|
|
||||||
tracing::info!("KWin fake_input ready (headless keyboard/mouse/touch — no portal)");
|
// Settle output geometry (wl_output + xdg-output were bound during the registry roundtrip
|
||||||
Ok(Self {
|
// 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,
|
conn,
|
||||||
queue,
|
queue,
|
||||||
state,
|
state,
|
||||||
fake,
|
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);
|
self.fake.pointer_motion(event.x as f64, event.y as f64);
|
||||||
}
|
}
|
||||||
InputKind::MouseMoveAbs => {
|
InputKind::MouseMoveAbs => {
|
||||||
let w = (event.flags >> 16) & 0xffff;
|
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||||||
let h = event.flags & 0xffff;
|
let h = (event.flags & 0xffff) as i32;
|
||||||
if w > 0 && h > 0 {
|
if w > 0 && h > 0 {
|
||||||
let x = event.x.clamp(0, w as i32) as f64;
|
self.refresh_geometry();
|
||||||
let y = event.y.clamp(0, h as i32) as f64;
|
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||||||
self.fake.pointer_motion_absolute(x, y);
|
// 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 => {
|
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
|
// 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.
|
// absolute mapping as MouseMoveAbs). Each event is its own frame.
|
||||||
InputKind::TouchDown | InputKind::TouchMove => {
|
InputKind::TouchDown | InputKind::TouchMove => {
|
||||||
let w = (event.flags >> 16) & 0xffff;
|
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||||||
let h = event.flags & 0xffff;
|
let h = (event.flags & 0xffff) as i32;
|
||||||
if w > 0 && h > 0 {
|
if w > 0 && h > 0 {
|
||||||
let x = event.x.clamp(0, w as i32) as f64;
|
self.refresh_geometry();
|
||||||
let y = event.y.clamp(0, h as i32) as f64;
|
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 {
|
if event.kind == InputKind::TouchDown {
|
||||||
self.fake.touch_down(event.code, x, y);
|
self.fake.touch_down(event.code, x, y);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -240,11 +240,13 @@ impl Drop for SteamDeckPad {
|
|||||||
/// [`apply_rich`](Self::apply_rich); [`pump`](Self::pump) services the kernel handshake + routes
|
/// [`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).
|
/// 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
|
/// 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
|
/// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS)
|
||||||
/// it (it presents the controller on interface 2). Selected per-pad in [`SteamControllerManager::ensure`].
|
/// 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 {
|
enum DeckTransport {
|
||||||
Uhid(SteamDeckPad),
|
Uhid(SteamDeckPad),
|
||||||
Gadget(crate::inject::steam_gadget::SteamDeckGadget),
|
Gadget(crate::inject::steam_gadget::SteamDeckGadget),
|
||||||
|
Usbip(crate::inject::steam_usbip::SteamDeckUsbip),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeckTransport {
|
impl DeckTransport {
|
||||||
@@ -254,22 +256,67 @@ impl DeckTransport {
|
|||||||
let _ = p.write_state(st);
|
let _ = p.write_state(st);
|
||||||
}
|
}
|
||||||
DeckTransport::Gadget(g) => g.write_state(st),
|
DeckTransport::Gadget(g) => g.write_state(st),
|
||||||
|
DeckTransport::Usbip(u) => u.write_state(st),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn service(&mut self) -> Option<(u16, u16)> {
|
fn service(&mut self) -> Option<(u16, u16)> {
|
||||||
match self {
|
match self {
|
||||||
DeckTransport::Uhid(p) => p.service(),
|
DeckTransport::Uhid(p) => p.service(),
|
||||||
DeckTransport::Gadget(g) => g.service().rumble,
|
DeckTransport::Gadget(g) => g.service().rumble,
|
||||||
|
DeckTransport::Usbip(u) => u.service().rumble,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn in_mode_entry(&self) -> bool {
|
fn in_mode_entry(&self) -> bool {
|
||||||
match self {
|
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::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 {
|
pub struct SteamControllerManager {
|
||||||
pads: Vec<Option<DeckTransport>>,
|
pads: Vec<Option<DeckTransport>>,
|
||||||
state: Vec<SteamState>,
|
state: Vec<SteamState>,
|
||||||
@@ -384,31 +431,8 @@ impl SteamControllerManager {
|
|||||||
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Prefer the USB gadget on SteamOS (default there — the only transport Steam Input promotes);
|
match open_transport(idx as u8) {
|
||||||
// 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 {
|
|
||||||
Ok(t) => {
|
Ok(t) => {
|
||||||
if matches!(t, DeckTransport::Uhid(_)) {
|
|
||||||
tracing::info!(index = idx, "virtual Steam Deck created (UHID hid-steam)");
|
|
||||||
}
|
|
||||||
self.pads[idx] = Some(t);
|
self.pads[idx] = Some(t);
|
||||||
self.state[idx] = SteamState::neutral();
|
self.state[idx] = SteamState::neutral();
|
||||||
self.last_rumble[idx] = (0, 0);
|
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_RAW_EVENT_CONTROL: u32 = 2;
|
||||||
const USB_SPEED_HIGH: u8 = 3;
|
const USB_SPEED_HIGH: u8 = 3;
|
||||||
|
|
||||||
// ---- captured-from-hardware descriptors (a real Steam Deck, 28DE:1205) ----
|
// Captured-from-hardware Deck descriptors + the `0x83`/`0xAE` feature contract live in the shared
|
||||||
#[rustfmt::skip]
|
// [`super::steam_proto`] module (single source of truth, also used by the usbip transport).
|
||||||
const RDESC_MOUSE: &[u8] = &[
|
use super::steam_proto::{
|
||||||
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02,
|
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, RDESC_DECK_CTRL as RDESC_CTRL,
|
||||||
0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,
|
RDESC_DECK_KBD as RDESC_KBD, RDESC_DECK_MOUSE as RDESC_MOUSE,
|
||||||
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];
|
|
||||||
|
|
||||||
const DEV_DESC: [u8; 18] = [
|
const DEV_DESC: [u8; 18] = [
|
||||||
18, 1, 0x00, 0x02, // bLength, DEVICE, bcdUSB 2.00
|
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());
|
bail!("raw_gadget RUN: {}", std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
let serial = format!("PFDECK{index:04}");
|
let serial = deck_serial(index);
|
||||||
let unit_id = 0x5046_0000u32 | index as u32; // "PF" + index — a synthetic per-instance device id
|
let unit_id = deck_unit_id(index); // "PF" + index — a synthetic per-instance device id
|
||||||
let report = Arc::new(Mutex::new(neutral_report()));
|
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
||||||
let feedback = Arc::new(Mutex::new(Default::default()));
|
let feedback = Arc::new(Mutex::new(Default::default()));
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
|
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) {
|
fn copy_cstr(dst: &mut [u8], s: &str) {
|
||||||
let n = s.len().min(dst.len() - 1);
|
let n = s.len().min(dst.len() - 1);
|
||||||
dst[..n].copy_from_slice(&s.as_bytes()[..n]);
|
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> {
|
fn hid_desc_for(cfg: &[u8], idx: u8) -> Vec<u8> {
|
||||||
// The HID descriptors live right after each interface descriptor in the config blob.
|
// 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)
|
// 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
|
let r = report
|
||||||
.lock()
|
.lock()
|
||||||
.map(|g| *g)
|
.map(|g| *g)
|
||||||
.unwrap_or_else(|_| neutral_report());
|
.unwrap_or_else(|_| neutral_deck_report());
|
||||||
let mut buf = [0u8; EPIO_HDR + 64];
|
let mut buf = [0u8; EPIO_HDR + 64];
|
||||||
buf[0..2].copy_from_slice(&(ep as u16).to_ne_bytes());
|
buf[0..2].copy_from_slice(&(ep as u16).to_ne_bytes());
|
||||||
buf[4..8].copy_from_slice(&(64u32).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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -532,4 +643,42 @@ mod tests {
|
|||||||
d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble
|
d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble
|
||||||
assert_eq!(parse_steam_output(&d).rumble, None);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Vendored + trimmed copy of the `usbip` crate (jiegec/usbip v0.8.0, MIT), reduced to the
|
||||||
|
# USB/IP *server simulation* path only: we present a virtual Steam Deck and let the local
|
||||||
|
# `vhci_hcd` attach it. The upstream crate hard-depends on `rusb`→`libusb1-sys` (for its USB
|
||||||
|
# *host* mode, which we do not use and which would add a libusb runtime dep + break `musl`),
|
||||||
|
# so the host modules (`host.rs`, the `rusb`/`nusb` device constructors) and the helper
|
||||||
|
# interface handlers (`cdc.rs`/`hid.rs`) are removed. What remains — the device model, the
|
||||||
|
# USB/IP protocol framing, and the `UsbInterfaceHandler` trait — is pure `std` + `tokio` and
|
||||||
|
# carries no libusb dependency. See `NOTICE` for upstream attribution.
|
||||||
|
[package]
|
||||||
|
name = "usbip-sim"
|
||||||
|
version = "0.8.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Trimmed usbip server-simulation core (no libusb) — vendored for the virtual Steam Deck"
|
||||||
|
license = "MIT"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "usbip_sim"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
log = "0.4"
|
||||||
|
num-derive = "0.4"
|
||||||
|
num-traits = "0.2"
|
||||||
|
# `time` is for the interrupt-IN pacing added in device.rs (punktfunk modification — see NOTICE).
|
||||||
|
tokio = { version = "1", features = ["rt", "net", "io-util", "sync", "time"] }
|
||||||
|
# Upstream gated its struct derives behind a `serde` feature; kept (off by default) so the
|
||||||
|
# `#[cfg(feature = "serde")]` attributes stay valid and the vendored diff stays minimal.
|
||||||
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
serde = ["dep:serde"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020-2025 Jiajie Chen
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
This crate (`usbip-sim`) is a vendored, trimmed copy of:
|
||||||
|
|
||||||
|
usbip v0.8.0
|
||||||
|
Copyright (c) Jiajie Chen <c@jia.je> and contributors
|
||||||
|
https://github.com/jiegec/usbip
|
||||||
|
Licensed under the MIT License.
|
||||||
|
|
||||||
|
Modifications by the punktfunk project:
|
||||||
|
- Removed the USB host modules (`src/host.rs`) and the `rusb`/`nusb` device
|
||||||
|
constructors in `src/lib.rs` (`with_rusb_*`, `with_nusb_*`, `new_from_host*`),
|
||||||
|
eliminating the libusb runtime dependency (which also broke `musl`).
|
||||||
|
- Removed the example helper interface handlers `src/cdc.rs` and `src/hid.rs`.
|
||||||
|
- Replaced the `rusb::Direction` re-export and `rusb::Version` conversions with
|
||||||
|
local definitions.
|
||||||
|
- Dropped the in-crate test modules (kept the library surface only).
|
||||||
|
- Paced interrupt/bulk IN endpoint transfers by bInterval in `device.rs`
|
||||||
|
`handle_urb` (so a simulated interrupt-IN mimics a real device's
|
||||||
|
NAK-until-bInterval behaviour rather than free-running over the loopback
|
||||||
|
link); added the tokio `time` feature for it.
|
||||||
|
|
||||||
|
Only the USB/IP server *simulation* path is retained: the device model, the
|
||||||
|
USB/IP wire protocol, and the `UsbInterfaceHandler` trait. The original MIT
|
||||||
|
license text is reproduced in LICENSE-MIT.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A list of known USB speeds
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub enum UsbSpeed {
|
||||||
|
Unknown = 0x0,
|
||||||
|
Low,
|
||||||
|
Full,
|
||||||
|
High,
|
||||||
|
Wireless,
|
||||||
|
Super,
|
||||||
|
SuperPlus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list of defined USB class codes
|
||||||
|
// https://www.usb.org/defined-class-codes
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub enum ClassCode {
|
||||||
|
SeeInterface = 0,
|
||||||
|
Audio,
|
||||||
|
CDC,
|
||||||
|
HID,
|
||||||
|
Physical = 0x05,
|
||||||
|
Image,
|
||||||
|
Printer,
|
||||||
|
MassStorage,
|
||||||
|
Hub,
|
||||||
|
CDCData,
|
||||||
|
SmartCard,
|
||||||
|
ContentSecurity = 0x0D,
|
||||||
|
Video,
|
||||||
|
PersonalHealthcare,
|
||||||
|
AudioVideo,
|
||||||
|
Billboard,
|
||||||
|
TypeCBridge,
|
||||||
|
Diagnostic = 0xDC,
|
||||||
|
WirelessController = 0xE0,
|
||||||
|
Misc = 0xEF,
|
||||||
|
ApplicationSpecific = 0xFE,
|
||||||
|
VendorSpecific = 0xFF,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list of defined USB endpoint attributes
|
||||||
|
#[derive(Copy, Clone, Debug, FromPrimitive)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub enum EndpointAttributes {
|
||||||
|
Control = 0,
|
||||||
|
Isochronous,
|
||||||
|
Bulk,
|
||||||
|
Interrupt,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// USB endpoint direction: IN or OUT.
|
||||||
|
///
|
||||||
|
/// Upstream re-exported `rusb::Direction`; vendored locally so this crate carries no libusb
|
||||||
|
/// dependency. `UsbEndpoint::direction()` returns this, and `device.rs` matches on the variants.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub enum Direction {
|
||||||
|
/// Host → device (`bEndpointAddress` bit 7 clear).
|
||||||
|
Out,
|
||||||
|
/// Device → host (`bEndpointAddress` bit 7 set).
|
||||||
|
In,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emulated max packet size of EP0
|
||||||
|
pub const EP0_MAX_PACKET_SIZE: u16 = 64;
|
||||||
|
|
||||||
|
/// A list of defined USB standard requests
|
||||||
|
/// from USB 2.0 standard Table 9.4. Standard Request Codes
|
||||||
|
#[derive(Copy, Clone, Debug, FromPrimitive)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub enum StandardRequest {
|
||||||
|
GetStatus = 0,
|
||||||
|
ClearFeature = 1,
|
||||||
|
SetFeature = 3,
|
||||||
|
SetAddress = 5,
|
||||||
|
GetDescriptor = 6,
|
||||||
|
SetDescriptor = 7,
|
||||||
|
GetConfiguration = 8,
|
||||||
|
SetConfiguration = 9,
|
||||||
|
GetInterface = 10,
|
||||||
|
SetInterface = 11,
|
||||||
|
SynchFrame = 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list of defined USB descriptor types
|
||||||
|
/// from USB 2.0 standard Table 9.5. Descriptor Types
|
||||||
|
#[derive(Copy, Clone, Debug, FromPrimitive)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub enum DescriptorType {
|
||||||
|
/// DEVICE
|
||||||
|
Device = 1,
|
||||||
|
/// CONFIGURATION
|
||||||
|
Configuration = 2,
|
||||||
|
/// STRING
|
||||||
|
String = 3,
|
||||||
|
/// INTERFACE
|
||||||
|
Interface = 4,
|
||||||
|
/// ENDPOINT
|
||||||
|
Endpoint = 5,
|
||||||
|
/// DEVICE_QUALIFIER
|
||||||
|
DeviceQualifier = 6,
|
||||||
|
/// OTHER_SPEED_CONFIGURATION
|
||||||
|
OtherSpeedConfiguration = 7,
|
||||||
|
/// INTERFACE_POINTER
|
||||||
|
InterfacePower = 8,
|
||||||
|
/// OTG
|
||||||
|
OTG = 9,
|
||||||
|
/// DEBUG
|
||||||
|
Debug = 0xA,
|
||||||
|
/// INTERFACE_ASSOCIATION
|
||||||
|
InterfaceAssociation = 0xB,
|
||||||
|
/// BOS
|
||||||
|
BOS = 0xF,
|
||||||
|
// DEVICE CAPABILITY
|
||||||
|
DeviceCapability = 0x10,
|
||||||
|
/// SUPERSPEED_USB_ENDPOINT_COMPANION
|
||||||
|
SuperspeedUsbEndpointCompanion = 0x30,
|
||||||
|
}
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Debug)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub struct Version {
|
||||||
|
pub major: u8,
|
||||||
|
pub minor: u8,
|
||||||
|
pub patch: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Upstream's `From<rusb::Version>` conversions removed — this crate has no libusb dependency.)
|
||||||
|
|
||||||
|
/// bcdDevice
|
||||||
|
impl From<u16> for Version {
|
||||||
|
fn from(value: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
major: (value >> 8) as u8,
|
||||||
|
minor: ((value >> 4) & 0xF) as u8,
|
||||||
|
patch: (value & 0xF) as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represent a USB device
|
||||||
|
#[derive(Clone, Default, Debug)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
|
pub struct UsbDevice {
|
||||||
|
pub path: String,
|
||||||
|
pub bus_id: String,
|
||||||
|
pub bus_num: u32,
|
||||||
|
pub dev_num: u32,
|
||||||
|
pub speed: u32,
|
||||||
|
pub vendor_id: u16,
|
||||||
|
pub product_id: u16,
|
||||||
|
pub device_bcd: Version,
|
||||||
|
pub device_class: u8,
|
||||||
|
pub device_subclass: u8,
|
||||||
|
pub device_protocol: u8,
|
||||||
|
pub configuration_value: u8,
|
||||||
|
pub num_configurations: u8,
|
||||||
|
pub interfaces: Vec<UsbInterface>,
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "serde", serde(skip))]
|
||||||
|
pub device_handler: Option<Arc<Mutex<Box<dyn UsbDeviceHandler + Send>>>>,
|
||||||
|
|
||||||
|
pub usb_version: Version,
|
||||||
|
|
||||||
|
pub(crate) ep0_in: UsbEndpoint,
|
||||||
|
pub(crate) ep0_out: UsbEndpoint,
|
||||||
|
// strings
|
||||||
|
pub(crate) string_pool: HashMap<u8, String>,
|
||||||
|
pub(crate) string_configuration: u8,
|
||||||
|
pub(crate) string_manufacturer: u8,
|
||||||
|
pub(crate) string_product: u8,
|
||||||
|
pub(crate) string_serial: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsbDevice {
|
||||||
|
pub fn new(index: u32) -> Self {
|
||||||
|
let mut res = Self {
|
||||||
|
path: "/sys/bus/0/0/0".to_string(),
|
||||||
|
bus_id: "0-0-0".to_string(),
|
||||||
|
dev_num: index,
|
||||||
|
speed: UsbSpeed::High as u32,
|
||||||
|
ep0_in: UsbEndpoint {
|
||||||
|
address: 0x80,
|
||||||
|
attributes: EndpointAttributes::Control as u8,
|
||||||
|
max_packet_size: EP0_MAX_PACKET_SIZE,
|
||||||
|
interval: 0,
|
||||||
|
},
|
||||||
|
ep0_out: UsbEndpoint {
|
||||||
|
address: 0x00,
|
||||||
|
attributes: EndpointAttributes::Control as u8,
|
||||||
|
max_packet_size: EP0_MAX_PACKET_SIZE,
|
||||||
|
interval: 0,
|
||||||
|
},
|
||||||
|
// configured by default
|
||||||
|
configuration_value: 1,
|
||||||
|
num_configurations: 1,
|
||||||
|
..Self::default()
|
||||||
|
};
|
||||||
|
res.string_configuration = res.new_string("Default Configuration");
|
||||||
|
res.string_manufacturer = res.new_string("Manufacturer");
|
||||||
|
res.string_product = res.new_string("Product");
|
||||||
|
res.string_serial = res.new_string("Serial");
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the old value, if present.
|
||||||
|
pub fn set_configuration_name(&mut self, name: &str) -> Option<String> {
|
||||||
|
let old = (self.string_configuration != 0)
|
||||||
|
.then(|| self.string_pool.remove(&self.string_configuration))
|
||||||
|
.flatten();
|
||||||
|
self.string_configuration = self.new_string(name);
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unset configuration name and returns the old value, if present.
|
||||||
|
pub fn unset_configuration_name(&mut self) -> Option<String> {
|
||||||
|
let old = (self.string_configuration != 0)
|
||||||
|
.then(|| self.string_pool.remove(&self.string_configuration))
|
||||||
|
.flatten();
|
||||||
|
self.string_configuration = 0;
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the old value, if present.
|
||||||
|
pub fn set_serial_number(&mut self, name: &str) -> Option<String> {
|
||||||
|
let old = (self.string_serial != 0)
|
||||||
|
.then(|| self.string_pool.remove(&self.string_serial))
|
||||||
|
.flatten();
|
||||||
|
self.string_serial = self.new_string(name);
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unset serial number and returns the old value, if present.
|
||||||
|
pub fn unset_serial_number(&mut self) -> Option<String> {
|
||||||
|
let old = (self.string_serial != 0)
|
||||||
|
.then(|| self.string_pool.remove(&self.string_serial))
|
||||||
|
.flatten();
|
||||||
|
self.string_serial = 0;
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the old value, if present.
|
||||||
|
pub fn set_product_name(&mut self, name: &str) -> Option<String> {
|
||||||
|
let old = (self.string_product != 0)
|
||||||
|
.then(|| self.string_pool.remove(&self.string_product))
|
||||||
|
.flatten();
|
||||||
|
self.string_product = self.new_string(name);
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unset product name and returns the old value, if present.
|
||||||
|
pub fn unset_product_name(&mut self) -> Option<String> {
|
||||||
|
let old = (self.string_product != 0)
|
||||||
|
.then(|| self.string_pool.remove(&self.string_product))
|
||||||
|
.flatten();
|
||||||
|
self.string_product = 0;
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the old value, if present.
|
||||||
|
pub fn set_manufacturer_name(&mut self, name: &str) -> Option<String> {
|
||||||
|
let old = (self.string_manufacturer != 0)
|
||||||
|
.then(|| self.string_pool.remove(&self.string_manufacturer))
|
||||||
|
.flatten();
|
||||||
|
self.string_manufacturer = self.new_string(name);
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unset manufacturer name and returns the old value, if present.
|
||||||
|
pub fn unset_manufacturer_name(&mut self) -> Option<String> {
|
||||||
|
let old = (self.string_manufacturer != 0)
|
||||||
|
.then(|| self.string_pool.remove(&self.string_manufacturer))
|
||||||
|
.flatten();
|
||||||
|
self.string_manufacturer = 0;
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_interface(
|
||||||
|
mut self,
|
||||||
|
interface_class: u8,
|
||||||
|
interface_subclass: u8,
|
||||||
|
interface_protocol: u8,
|
||||||
|
name: Option<&str>,
|
||||||
|
endpoints: Vec<UsbEndpoint>,
|
||||||
|
handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
|
||||||
|
) -> Self {
|
||||||
|
let string_interface = name.map(|name| self.new_string(name)).unwrap_or(0);
|
||||||
|
let class_specific_descriptor = handler.lock().unwrap().get_class_specific_descriptor();
|
||||||
|
self.interfaces.push(UsbInterface {
|
||||||
|
interface_class,
|
||||||
|
interface_subclass,
|
||||||
|
interface_protocol,
|
||||||
|
endpoints,
|
||||||
|
string_interface,
|
||||||
|
class_specific_descriptor,
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_device_handler(
|
||||||
|
mut self,
|
||||||
|
handler: Arc<Mutex<Box<dyn UsbDeviceHandler + Send>>>,
|
||||||
|
) -> Self {
|
||||||
|
self.device_handler = Some(handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_string(&mut self, s: &str) -> u8 {
|
||||||
|
for i in 1.. {
|
||||||
|
if let std::collections::hash_map::Entry::Vacant(e) = self.string_pool.entry(i) {
|
||||||
|
e.insert(s.to_string());
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("string poll exhausted")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_ep(&self, ep: u8) -> Option<(UsbEndpoint, Option<&UsbInterface>)> {
|
||||||
|
if ep == self.ep0_in.address {
|
||||||
|
Some((self.ep0_in, None))
|
||||||
|
} else if ep == self.ep0_out.address {
|
||||||
|
Some((self.ep0_out, None))
|
||||||
|
} else {
|
||||||
|
for intf in &self.interfaces {
|
||||||
|
for endpoint in &intf.endpoints {
|
||||||
|
if endpoint.address == ep {
|
||||||
|
return Some((*endpoint, Some(intf)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut result = Vec::with_capacity(312);
|
||||||
|
|
||||||
|
let mut path = self.path.as_bytes().to_vec();
|
||||||
|
debug_assert!(path.len() <= 256);
|
||||||
|
path.resize(256, 0);
|
||||||
|
result.extend_from_slice(path.as_slice());
|
||||||
|
|
||||||
|
let mut bus_id = self.bus_id.as_bytes().to_vec();
|
||||||
|
debug_assert!(bus_id.len() <= 32);
|
||||||
|
bus_id.resize(32, 0);
|
||||||
|
result.extend_from_slice(bus_id.as_slice());
|
||||||
|
|
||||||
|
result.extend_from_slice(&self.bus_num.to_be_bytes());
|
||||||
|
result.extend_from_slice(&self.dev_num.to_be_bytes());
|
||||||
|
result.extend_from_slice(&self.speed.to_be_bytes());
|
||||||
|
result.extend_from_slice(&self.vendor_id.to_be_bytes());
|
||||||
|
result.extend_from_slice(&self.product_id.to_be_bytes());
|
||||||
|
result.push(self.device_bcd.major);
|
||||||
|
result.push(self.device_bcd.minor);
|
||||||
|
result.push(self.device_class);
|
||||||
|
result.push(self.device_subclass);
|
||||||
|
result.push(self.device_protocol);
|
||||||
|
result.push(self.configuration_value);
|
||||||
|
result.push(self.num_configurations);
|
||||||
|
result.push(self.interfaces.len() as u8);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_bytes_with_interfaces(&self) -> Vec<u8> {
|
||||||
|
let mut result = self.to_bytes();
|
||||||
|
result.reserve(4 * self.interfaces.len());
|
||||||
|
|
||||||
|
for intf in &self.interfaces {
|
||||||
|
result.push(intf.interface_class);
|
||||||
|
result.push(intf.interface_subclass);
|
||||||
|
result.push(intf.interface_protocol);
|
||||||
|
result.push(0); // padding
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn handle_urb(
|
||||||
|
&self,
|
||||||
|
ep: UsbEndpoint,
|
||||||
|
intf: Option<&UsbInterface>,
|
||||||
|
transfer_buffer_length: u32,
|
||||||
|
setup_packet: SetupPacket,
|
||||||
|
out_data: &[u8],
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
use DescriptorType::*;
|
||||||
|
use Direction::*;
|
||||||
|
use EndpointAttributes::*;
|
||||||
|
use StandardRequest::*;
|
||||||
|
|
||||||
|
match (FromPrimitive::from_u8(ep.attributes), ep.direction()) {
|
||||||
|
(Some(Control), In) => {
|
||||||
|
// control in
|
||||||
|
debug!("Control IN setup={setup_packet:x?}");
|
||||||
|
match (
|
||||||
|
setup_packet.request_type,
|
||||||
|
FromPrimitive::from_u8(setup_packet.request),
|
||||||
|
) {
|
||||||
|
(0b10000000, Some(GetDescriptor)) => {
|
||||||
|
// high byte: type
|
||||||
|
match FromPrimitive::from_u16(setup_packet.value >> 8) {
|
||||||
|
Some(Device) => {
|
||||||
|
debug!("Get device descriptor");
|
||||||
|
// Standard Device Descriptor
|
||||||
|
let mut desc = vec![
|
||||||
|
0x12, // bLength
|
||||||
|
Device as u8, // bDescriptorType: Device
|
||||||
|
self.usb_version.minor,
|
||||||
|
self.usb_version.major, // bcdUSB: USB 2.0
|
||||||
|
self.device_class, // bDeviceClass
|
||||||
|
self.device_subclass, // bDeviceSubClass
|
||||||
|
self.device_protocol, // bDeviceProtocol
|
||||||
|
self.ep0_in.max_packet_size as u8, // bMaxPacketSize0
|
||||||
|
self.vendor_id as u8, // idVendor
|
||||||
|
(self.vendor_id >> 8) as u8,
|
||||||
|
self.product_id as u8, // idProduct
|
||||||
|
(self.product_id >> 8) as u8,
|
||||||
|
self.device_bcd.minor, // bcdDevice
|
||||||
|
self.device_bcd.major,
|
||||||
|
self.string_manufacturer, // iManufacturer
|
||||||
|
self.string_product, // iProduct
|
||||||
|
self.string_serial, // iSerial
|
||||||
|
self.num_configurations, // bNumConfigurations
|
||||||
|
];
|
||||||
|
|
||||||
|
// requested len too short: wLength < real length
|
||||||
|
if setup_packet.length < desc.len() as u16 {
|
||||||
|
desc.resize(setup_packet.length as usize, 0);
|
||||||
|
}
|
||||||
|
Ok(desc)
|
||||||
|
}
|
||||||
|
Some(BOS) => {
|
||||||
|
debug!("Get BOS descriptor");
|
||||||
|
let mut desc = vec![
|
||||||
|
0x05, // bLength
|
||||||
|
BOS as u8, // bDescriptorType: BOS
|
||||||
|
0x05, 0x00, // wTotalLength
|
||||||
|
0x00, // bNumCapabilities
|
||||||
|
];
|
||||||
|
|
||||||
|
// requested len too short: wLength < real length
|
||||||
|
if setup_packet.length < desc.len() as u16 {
|
||||||
|
desc.resize(setup_packet.length as usize, 0);
|
||||||
|
}
|
||||||
|
Ok(desc)
|
||||||
|
}
|
||||||
|
Some(Configuration) => {
|
||||||
|
debug!("Get configuration descriptor");
|
||||||
|
// Standard Configuration Descriptor
|
||||||
|
let mut desc = vec![
|
||||||
|
0x09, // bLength
|
||||||
|
Configuration as u8, // bDescriptorType: Configuration
|
||||||
|
0x00,
|
||||||
|
0x00, // wTotalLength: to be filled below
|
||||||
|
self.interfaces.len() as u8, // bNumInterfaces
|
||||||
|
self.configuration_value, // bConfigurationValue
|
||||||
|
self.string_configuration, // iConfiguration
|
||||||
|
0x80, // bmAttributes: Bus Powered
|
||||||
|
0x32, // bMaxPower: 100mA
|
||||||
|
];
|
||||||
|
for (i, intf) in self.interfaces.iter().enumerate() {
|
||||||
|
let mut intf_desc = vec![
|
||||||
|
0x09, // bLength
|
||||||
|
Interface as u8, // bDescriptorType: Interface
|
||||||
|
i as u8, // bInterfaceNum
|
||||||
|
0x00, // bAlternateSettings
|
||||||
|
intf.endpoints.len() as u8, // bNumEndpoints
|
||||||
|
intf.interface_class, // bInterfaceClass
|
||||||
|
intf.interface_subclass, // bInterfaceSubClass
|
||||||
|
intf.interface_protocol, // bInterfaceProtocol
|
||||||
|
intf.string_interface, //iInterface
|
||||||
|
];
|
||||||
|
// class specific endpoint
|
||||||
|
let mut specific = intf.class_specific_descriptor.clone();
|
||||||
|
intf_desc.append(&mut specific);
|
||||||
|
// endpoint descriptors
|
||||||
|
for endpoint in &intf.endpoints {
|
||||||
|
let mut ep_desc = vec![
|
||||||
|
0x07, // bLength
|
||||||
|
Endpoint as u8, // bDescriptorType: Endpoint
|
||||||
|
endpoint.address, // bEndpointAddress
|
||||||
|
endpoint.attributes, // bmAttributes
|
||||||
|
endpoint.max_packet_size as u8,
|
||||||
|
(endpoint.max_packet_size >> 8) as u8, // wMaxPacketSize
|
||||||
|
endpoint.interval, // bInterval
|
||||||
|
];
|
||||||
|
intf_desc.append(&mut ep_desc);
|
||||||
|
}
|
||||||
|
desc.append(&mut intf_desc);
|
||||||
|
}
|
||||||
|
// length
|
||||||
|
let len = desc.len() as u16;
|
||||||
|
desc[2] = len as u8;
|
||||||
|
desc[3] = (len >> 8) as u8;
|
||||||
|
|
||||||
|
// requested len too short: wLength < real length
|
||||||
|
if setup_packet.length < desc.len() as u16 {
|
||||||
|
desc.resize(setup_packet.length as usize, 0);
|
||||||
|
}
|
||||||
|
Ok(desc)
|
||||||
|
}
|
||||||
|
Some(String) => {
|
||||||
|
debug!("Get string descriptor");
|
||||||
|
let index = setup_packet.value as u8;
|
||||||
|
if index == 0 {
|
||||||
|
// String Descriptor Zero, Specifying Languages Supported by the Device
|
||||||
|
// language ids
|
||||||
|
let mut desc = vec![
|
||||||
|
4, // bLength
|
||||||
|
DescriptorType::String as u8, // bDescriptorType
|
||||||
|
0x09,
|
||||||
|
0x04, // wLANGID[0], en-US
|
||||||
|
];
|
||||||
|
// requested len too short: wLength < real length
|
||||||
|
if setup_packet.length < desc.len() as u16 {
|
||||||
|
desc.resize(setup_packet.length as usize, 0);
|
||||||
|
}
|
||||||
|
Ok(desc)
|
||||||
|
} else if let Some(s) = &self.string_pool.get(&index) {
|
||||||
|
// UNICODE String Descriptor
|
||||||
|
let bytes: Vec<u16> = s.encode_utf16().collect();
|
||||||
|
let mut desc = vec![
|
||||||
|
2 + bytes.len() as u8 * 2, // bLength
|
||||||
|
DescriptorType::String as u8, // bDescriptorType
|
||||||
|
];
|
||||||
|
for byte in bytes {
|
||||||
|
desc.push(byte as u8);
|
||||||
|
desc.push((byte >> 8) as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// requested len too short: wLength < real length
|
||||||
|
if setup_packet.length < desc.len() as u16 {
|
||||||
|
desc.resize(setup_packet.length as usize, 0);
|
||||||
|
}
|
||||||
|
Ok(desc)
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
format!("Invalid string index: {index}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(DeviceQualifier) => {
|
||||||
|
debug!("Get device qualifier descriptor");
|
||||||
|
// Device_Qualifier Descriptor
|
||||||
|
let mut desc = vec![
|
||||||
|
0x0A, // bLength
|
||||||
|
DeviceQualifier as u8, // bDescriptorType: Device Qualifier
|
||||||
|
self.usb_version.minor,
|
||||||
|
self.usb_version.major, // bcdUSB
|
||||||
|
self.device_class, // bDeviceClass
|
||||||
|
self.device_subclass, // bDeviceSUbClass
|
||||||
|
self.device_protocol, // bDeviceProtocol
|
||||||
|
self.ep0_in.max_packet_size as u8, // bMaxPacketSize0
|
||||||
|
self.num_configurations, // bNumConfigurations
|
||||||
|
0x00, // bReserved
|
||||||
|
];
|
||||||
|
|
||||||
|
// requested len too short: wLength < real length
|
||||||
|
if setup_packet.length < desc.len() as u16 {
|
||||||
|
desc.resize(setup_packet.length as usize, 0);
|
||||||
|
}
|
||||||
|
Ok(desc)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("unknown desc type: {setup_packet:x?}");
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if setup_packet.request_type & 0xF == 1 => {
|
||||||
|
// to interface
|
||||||
|
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
|
||||||
|
// only low 8 bits are valid
|
||||||
|
let intf = &self.interfaces[setup_packet.index as usize & 0xFF];
|
||||||
|
let mut handler = intf.handler.lock().unwrap();
|
||||||
|
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
|
||||||
|
}
|
||||||
|
_ if setup_packet.request_type & 0xF == 0 && self.device_handler.is_some() => {
|
||||||
|
// to device
|
||||||
|
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
|
||||||
|
let lock = self.device_handler.as_ref().unwrap();
|
||||||
|
let mut handler = lock.lock().unwrap();
|
||||||
|
handler.handle_urb(transfer_buffer_length, setup_packet, out_data)
|
||||||
|
}
|
||||||
|
_ => unimplemented!("control in"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(Control), Out) => {
|
||||||
|
// control out
|
||||||
|
debug!("Control OUT setup={setup_packet:x?}");
|
||||||
|
match (
|
||||||
|
setup_packet.request_type,
|
||||||
|
FromPrimitive::from_u8(setup_packet.request),
|
||||||
|
) {
|
||||||
|
(0b00000000, Some(SetConfiguration)) => {
|
||||||
|
let mut desc = vec![
|
||||||
|
self.configuration_value, // bConfigurationValue
|
||||||
|
];
|
||||||
|
|
||||||
|
// requested len too short: wLength < real length
|
||||||
|
if setup_packet.length < desc.len() as u16 {
|
||||||
|
desc.resize(setup_packet.length as usize, 0);
|
||||||
|
}
|
||||||
|
Ok(desc)
|
||||||
|
}
|
||||||
|
_ if setup_packet.request_type & 0xF == 1 => {
|
||||||
|
// to interface
|
||||||
|
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
|
||||||
|
// only low 8 bits are valid
|
||||||
|
let intf = &self.interfaces[setup_packet.index as usize & 0xFF];
|
||||||
|
let mut handler = intf.handler.lock().unwrap();
|
||||||
|
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
|
||||||
|
}
|
||||||
|
_ if setup_packet.request_type & 0xF == 0 && self.device_handler.is_some() => {
|
||||||
|
// to device
|
||||||
|
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
|
||||||
|
let lock = self.device_handler.as_ref().unwrap();
|
||||||
|
let mut handler = lock.lock().unwrap();
|
||||||
|
handler.handle_urb(transfer_buffer_length, setup_packet, out_data)
|
||||||
|
}
|
||||||
|
_ => unimplemented!("control out"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(_), _) => {
|
||||||
|
// others (interrupt / bulk / iso transfers to an endpoint)
|
||||||
|
// punktfunk modification: pace IN transfers by bInterval so a virtual interrupt-IN
|
||||||
|
// endpoint mimics a real device's NAK-until-bInterval behaviour instead of
|
||||||
|
// free-running as fast as the transport allows (vhci_hcd does not throttle the
|
||||||
|
// server side, so an unpaced sim would spin the loopback link). HS bInterval N →
|
||||||
|
// 2^(N-1) microframes × 125µs.
|
||||||
|
if let In = ep.direction() {
|
||||||
|
let n = ep.interval.clamp(1, 16) as u32;
|
||||||
|
let period_us = (1u32 << (n - 1)) * 125;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_micros(period_us as u64)).await;
|
||||||
|
}
|
||||||
|
let intf = intf.unwrap();
|
||||||
|
let mut handler = intf.handler.lock().unwrap();
|
||||||
|
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
|
||||||
|
}
|
||||||
|
_ => unimplemented!("transfer to {:?}", ep),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handler for URB targeting the device
|
||||||
|
pub trait UsbDeviceHandler: std::fmt::Debug {
|
||||||
|
/// Handle a URB(USB Request Block) targeting at this device
|
||||||
|
///
|
||||||
|
/// When the lower 4 bits of `bmRequestType` is zero and the URB is not handled by the library, this function is called.
|
||||||
|
/// The resulting data should not exceed `transfer_buffer_length`
|
||||||
|
fn handle_urb(
|
||||||
|
&mut self,
|
||||||
|
transfer_buffer_length: u32,
|
||||||
|
setup: SetupPacket,
|
||||||
|
req: &[u8],
|
||||||
|
) -> Result<Vec<u8>>;
|
||||||
|
|
||||||
|
/// Helper to downcast to actual struct
|
||||||
|
///
|
||||||
|
/// Please implement it as:
|
||||||
|
/// ```ignore
|
||||||
|
/// fn as_any(&mut self) -> &mut dyn Any {
|
||||||
|
/// self
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
fn as_any(&mut self) -> &mut dyn Any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (In-crate test module removed in the vendored copy — see NOTICE.)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Represent a USB endpoint
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub struct UsbEndpoint {
|
||||||
|
/// bEndpointAddress
|
||||||
|
pub address: u8,
|
||||||
|
/// bmAttributes
|
||||||
|
pub attributes: u8,
|
||||||
|
/// wMaxPacketSize
|
||||||
|
pub max_packet_size: u16,
|
||||||
|
/// bInterval
|
||||||
|
pub interval: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsbEndpoint {
|
||||||
|
/// Get direction from MSB of address
|
||||||
|
pub fn direction(&self) -> Direction {
|
||||||
|
if self.address & 0x80 != 0 {
|
||||||
|
Direction::In
|
||||||
|
} else {
|
||||||
|
Direction::Out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this is endpoint zero
|
||||||
|
pub fn is_ep0(&self) -> bool {
|
||||||
|
self.address & 0x7F == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Represent a USB interface
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
|
pub struct UsbInterface {
|
||||||
|
pub interface_class: u8,
|
||||||
|
pub interface_subclass: u8,
|
||||||
|
pub interface_protocol: u8,
|
||||||
|
pub endpoints: Vec<UsbEndpoint>,
|
||||||
|
pub string_interface: u8,
|
||||||
|
pub class_specific_descriptor: Vec<u8>,
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "serde", serde(skip))]
|
||||||
|
pub handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handler of a custom usb interface
|
||||||
|
pub trait UsbInterfaceHandler: std::fmt::Debug {
|
||||||
|
/// Return the class specific descriptor which is inserted between interface descriptor and endpoint descriptor
|
||||||
|
fn get_class_specific_descriptor(&self) -> Vec<u8>;
|
||||||
|
|
||||||
|
/// Handle a URB(USB Request Block) targeting at this interface
|
||||||
|
///
|
||||||
|
/// Can be one of: control transfer to ep0 or other types of transfer to its endpoint.
|
||||||
|
/// The resulting data should not exceed `transfer_buffer_length`.
|
||||||
|
fn handle_urb(
|
||||||
|
&mut self,
|
||||||
|
interface: &UsbInterface,
|
||||||
|
ep: UsbEndpoint,
|
||||||
|
transfer_buffer_length: u32,
|
||||||
|
setup: SetupPacket,
|
||||||
|
req: &[u8],
|
||||||
|
) -> Result<Vec<u8>>;
|
||||||
|
|
||||||
|
/// Helper to downcast to actual struct
|
||||||
|
///
|
||||||
|
/// Please implement it as:
|
||||||
|
/// ```ignore
|
||||||
|
/// fn as_any(&mut self) -> &mut dyn Any {
|
||||||
|
/// self
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
fn as_any(&mut self) -> &mut dyn Any;
|
||||||
|
}
|
||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
//! A USB/IP server (simulation path only).
|
||||||
|
//!
|
||||||
|
//! Vendored + trimmed from `usbip` v0.8.0 (jiegec/usbip, MIT); the USB *host* modules and the
|
||||||
|
//! `rusb`/`nusb` device constructors are removed so this carries no libusb dependency. See `NOTICE`.
|
||||||
|
|
||||||
|
use log::*;
|
||||||
|
use num_derive::FromPrimitive;
|
||||||
|
use num_traits::FromPrimitive;
|
||||||
|
use std::any::Any;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{ErrorKind, Result};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use usbip_protocol::UsbIpCommand;
|
||||||
|
|
||||||
|
#[cfg(feature = "serde")]
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
mod consts;
|
||||||
|
mod device;
|
||||||
|
mod endpoint;
|
||||||
|
mod interface;
|
||||||
|
mod setup;
|
||||||
|
pub mod usbip_protocol;
|
||||||
|
mod util;
|
||||||
|
pub use consts::*;
|
||||||
|
pub use device::*;
|
||||||
|
pub use endpoint::*;
|
||||||
|
pub use interface::*;
|
||||||
|
pub use setup::*;
|
||||||
|
pub use util::*;
|
||||||
|
|
||||||
|
use crate::usbip_protocol::{UsbIpResponse, USBIP_RET_SUBMIT, USBIP_RET_UNLINK};
|
||||||
|
|
||||||
|
/// Main struct of a USB/IP server
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct UsbIpServer {
|
||||||
|
available_devices: RwLock<Vec<UsbDevice>>,
|
||||||
|
used_devices: RwLock<HashMap<String, UsbDevice>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsbIpServer {
|
||||||
|
/// Create a [UsbIpServer] with simulated devices
|
||||||
|
pub fn new_simulated(devices: Vec<UsbDevice>) -> Self {
|
||||||
|
Self {
|
||||||
|
available_devices: RwLock::new(devices),
|
||||||
|
used_devices: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_device(&self, device: UsbDevice) {
|
||||||
|
self.available_devices.write().await.push(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_device(&self, bus_id: &str) -> Result<()> {
|
||||||
|
let mut available_devices = self.available_devices.write().await;
|
||||||
|
|
||||||
|
if let Some(device) = available_devices.iter().position(|d| d.bus_id == bus_id) {
|
||||||
|
available_devices.remove(device);
|
||||||
|
Ok(())
|
||||||
|
} else if let Some(device) = self
|
||||||
|
.used_devices
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.find(|d| d.bus_id == bus_id)
|
||||||
|
{
|
||||||
|
Err(std::io::Error::other(format!(
|
||||||
|
"Device {} is in use",
|
||||||
|
device.bus_id
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
ErrorKind::NotFound,
|
||||||
|
format!("Device {bus_id} not found"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||||
|
mut socket: &mut T,
|
||||||
|
server: Arc<UsbIpServer>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut current_import_device_id: Option<String> = None;
|
||||||
|
loop {
|
||||||
|
let command = UsbIpCommand::read_from_socket(&mut socket).await;
|
||||||
|
if let Err(err) = command {
|
||||||
|
if let Some(dev_id) = current_import_device_id {
|
||||||
|
let mut used_devices = server.used_devices.write().await;
|
||||||
|
let mut available_devices = server.available_devices.write().await;
|
||||||
|
match used_devices.remove(&dev_id) {
|
||||||
|
Some(dev) => available_devices.push(dev),
|
||||||
|
None => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.kind() == ErrorKind::UnexpectedEof {
|
||||||
|
info!("Remote closed the connection");
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let used_devices = server.used_devices.read().await;
|
||||||
|
let mut current_import_device = current_import_device_id
|
||||||
|
.clone()
|
||||||
|
.and_then(|ref id| used_devices.get(id));
|
||||||
|
|
||||||
|
match command.unwrap() {
|
||||||
|
UsbIpCommand::OpReqDevlist { .. } => {
|
||||||
|
trace!("Got OP_REQ_DEVLIST");
|
||||||
|
let devices = server.available_devices.read().await;
|
||||||
|
|
||||||
|
// OP_REP_DEVLIST
|
||||||
|
UsbIpResponse::op_rep_devlist(&devices)
|
||||||
|
.write_to_socket(socket)
|
||||||
|
.await?;
|
||||||
|
trace!("Sent OP_REP_DEVLIST");
|
||||||
|
}
|
||||||
|
UsbIpCommand::OpReqImport { busid, .. } => {
|
||||||
|
trace!("Got OP_REQ_IMPORT");
|
||||||
|
|
||||||
|
current_import_device_id = None;
|
||||||
|
current_import_device = None;
|
||||||
|
std::mem::drop(used_devices);
|
||||||
|
|
||||||
|
let mut used_devices = server.used_devices.write().await;
|
||||||
|
let mut available_devices = server.available_devices.write().await;
|
||||||
|
let busid_compare =
|
||||||
|
&busid[..busid.iter().position(|&x| x == 0).unwrap_or(busid.len())];
|
||||||
|
for (i, dev) in available_devices.iter().enumerate() {
|
||||||
|
if busid_compare == dev.bus_id.as_bytes() {
|
||||||
|
let dev = available_devices.remove(i);
|
||||||
|
let dev_id = dev.bus_id.clone();
|
||||||
|
used_devices.insert(dev.bus_id.clone(), dev);
|
||||||
|
current_import_device_id = dev_id.clone().into();
|
||||||
|
current_import_device = Some(used_devices.get(&dev_id).unwrap());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = if let Some(dev) = current_import_device {
|
||||||
|
UsbIpResponse::op_rep_import_success(dev)
|
||||||
|
} else {
|
||||||
|
UsbIpResponse::op_rep_import_fail()
|
||||||
|
};
|
||||||
|
res.write_to_socket(socket).await?;
|
||||||
|
trace!("Sent OP_REP_IMPORT");
|
||||||
|
}
|
||||||
|
UsbIpCommand::UsbIpCmdSubmit {
|
||||||
|
mut header,
|
||||||
|
transfer_buffer_length,
|
||||||
|
setup,
|
||||||
|
data,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
trace!("Got USBIP_CMD_SUBMIT");
|
||||||
|
let device = current_import_device.unwrap();
|
||||||
|
|
||||||
|
let out = header.direction == 0;
|
||||||
|
let real_ep = if out { header.ep } else { header.ep | 0x80 };
|
||||||
|
|
||||||
|
header.command = USBIP_RET_SUBMIT.into();
|
||||||
|
|
||||||
|
let res = match device.find_ep(real_ep as u8) {
|
||||||
|
None => {
|
||||||
|
warn!("Endpoint {real_ep:02x?} not found");
|
||||||
|
UsbIpResponse::usbip_ret_submit_fail(&header)
|
||||||
|
}
|
||||||
|
Some((ep, intf)) => {
|
||||||
|
trace!("->Endpoint {ep:02x?}");
|
||||||
|
trace!("->Setup {setup:02x?}");
|
||||||
|
trace!("->Request {data:02x?}");
|
||||||
|
let resp = device
|
||||||
|
.handle_urb(
|
||||||
|
ep,
|
||||||
|
intf,
|
||||||
|
transfer_buffer_length,
|
||||||
|
SetupPacket::parse(&setup),
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match resp {
|
||||||
|
Ok(resp) => {
|
||||||
|
if out {
|
||||||
|
trace!("<-Wrote {}", data.len());
|
||||||
|
} else {
|
||||||
|
trace!("<-Resp {resp:02x?}");
|
||||||
|
}
|
||||||
|
UsbIpResponse::usbip_ret_submit_success(&header, 0, 0, resp, vec![])
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Error handling URB: {err}");
|
||||||
|
UsbIpResponse::usbip_ret_submit_fail(&header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
res.write_to_socket(socket).await?;
|
||||||
|
trace!("Sent USBIP_RET_SUBMIT");
|
||||||
|
}
|
||||||
|
UsbIpCommand::UsbIpCmdUnlink {
|
||||||
|
mut header,
|
||||||
|
unlink_seqnum,
|
||||||
|
} => {
|
||||||
|
trace!("Got USBIP_CMD_UNLINK for {unlink_seqnum:10x?}");
|
||||||
|
|
||||||
|
header.command = USBIP_RET_UNLINK.into();
|
||||||
|
|
||||||
|
let res = UsbIpResponse::usbip_ret_unlink_success(&header);
|
||||||
|
res.write_to_socket(socket).await?;
|
||||||
|
trace!("Sent USBIP_RET_UNLINK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a USB/IP server at `addr` using [TcpListener]
|
||||||
|
pub async fn server(addr: SocketAddr, server: Arc<UsbIpServer>) {
|
||||||
|
let listener = TcpListener::bind(addr).await.expect("bind to addr");
|
||||||
|
|
||||||
|
let server = async move {
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((mut socket, _addr)) => {
|
||||||
|
info!("Got connection from {:?}", socket.peer_addr());
|
||||||
|
let new_server = server.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = handler(&mut socket, new_server).await;
|
||||||
|
info!("Handler ended with {res:?}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Got error {err:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
server.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Host-mode constructors and in-crate tests removed in the vendored copy — see NOTICE.)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#[cfg(feature = "serde")]
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Parse the SETUP packet of control transfers
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub struct SetupPacket {
|
||||||
|
/// bmRequestType
|
||||||
|
pub request_type: u8,
|
||||||
|
/// bRequest
|
||||||
|
pub request: u8,
|
||||||
|
/// wValue
|
||||||
|
pub value: u16,
|
||||||
|
/// wIndex
|
||||||
|
pub index: u16,
|
||||||
|
/// wLength
|
||||||
|
pub length: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SetupPacket {
|
||||||
|
/// Parse a [SetupPacket] from raw setup packet
|
||||||
|
pub fn parse(setup: &[u8; 8]) -> SetupPacket {
|
||||||
|
SetupPacket {
|
||||||
|
request_type: setup[0],
|
||||||
|
request: setup[1],
|
||||||
|
value: ((setup[3] as u16) << 8) | (setup[2] as u16),
|
||||||
|
index: ((setup[5] as u16) << 8) | (setup[4] as u16),
|
||||||
|
length: ((setup[7] as u16) << 8) | (setup[6] as u16),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
//! USB/IP protocol structs
|
||||||
|
//!
|
||||||
|
//! This module contains declarations of all structs used in the USB/IP protocol,
|
||||||
|
//! as well as functions to serialize and deserialize them to/from byte arrays,
|
||||||
|
//! and functions to send and receive them over a socket.
|
||||||
|
//!
|
||||||
|
//! They are based on the [Linux kernel documentation](https://docs.kernel.org/usb/usbip_protocol.html).
|
||||||
|
|
||||||
|
use log::trace;
|
||||||
|
use std::io::Result;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
#[cfg(feature = "serde")]
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::UsbDevice;
|
||||||
|
|
||||||
|
/// USB/IP protocol version
|
||||||
|
///
|
||||||
|
/// This is currently the only supported version of USB/IP
|
||||||
|
/// for this library.
|
||||||
|
pub const USBIP_VERSION: u16 = 0x0111;
|
||||||
|
|
||||||
|
/// Command code: Retrieve the list of exported USB devices
|
||||||
|
pub const OP_REQ_DEVLIST: u16 = 0x8005;
|
||||||
|
/// Command code: import a remote USB device
|
||||||
|
pub const OP_REQ_IMPORT: u16 = 0x8003;
|
||||||
|
/// Reply code: The list of exported USB devices
|
||||||
|
pub const OP_REP_DEVLIST: u16 = 0x0005;
|
||||||
|
/// Reply code: Reply to import
|
||||||
|
pub const OP_REP_IMPORT: u16 = 0x0003;
|
||||||
|
|
||||||
|
/// Command code: Submit an URB
|
||||||
|
pub const USBIP_CMD_SUBMIT: u16 = 0x0001;
|
||||||
|
/// Command code: Unlink an URB
|
||||||
|
pub const USBIP_CMD_UNLINK: u16 = 0x0002;
|
||||||
|
/// Reply code: Reply for submitting an URB
|
||||||
|
pub const USBIP_RET_SUBMIT: u16 = 0x0003;
|
||||||
|
/// Reply code: Reply for URB unlink
|
||||||
|
pub const USBIP_RET_UNLINK: u16 = 0x0004;
|
||||||
|
|
||||||
|
/// USB/IP direction
|
||||||
|
///
|
||||||
|
/// NOTE: Must not be confused with rusb::Direction,
|
||||||
|
/// which has the opposite enum values. This is only for
|
||||||
|
/// internal use in the USB/IP protocol.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Direction {
|
||||||
|
Out = 0,
|
||||||
|
In = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common header for all context sensitive packets
|
||||||
|
///
|
||||||
|
/// All commands/responses which rely on a device being attached
|
||||||
|
/// to a client use this header.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub struct UsbIpHeaderBasic {
|
||||||
|
pub command: u32,
|
||||||
|
pub seqnum: u32,
|
||||||
|
pub devid: u32,
|
||||||
|
pub direction: u32,
|
||||||
|
pub ep: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsbIpHeaderBasic {
|
||||||
|
/// Converts a byte array into a [UsbIpHeaderBasic].
|
||||||
|
pub fn from_bytes(bytes: &[u8; 20]) -> Self {
|
||||||
|
let result = UsbIpHeaderBasic {
|
||||||
|
command: u32::from_be_bytes(bytes[0..4].try_into().unwrap()),
|
||||||
|
seqnum: u32::from_be_bytes(bytes[4..8].try_into().unwrap()),
|
||||||
|
devid: u32::from_be_bytes(bytes[8..12].try_into().unwrap()),
|
||||||
|
direction: u32::from_be_bytes(bytes[12..16].try_into().unwrap()),
|
||||||
|
ep: u32::from_be_bytes(bytes[16..20].try_into().unwrap()),
|
||||||
|
};
|
||||||
|
// The direction should be 0 or 1
|
||||||
|
debug_assert!(result.direction & 1 == result.direction);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the [UsbIpHeaderBasic] into a byte array.
|
||||||
|
pub fn to_bytes(&self) -> [u8; 20] {
|
||||||
|
let mut result = [0u8; 20];
|
||||||
|
result[0..4].copy_from_slice(&self.command.to_be_bytes());
|
||||||
|
result[4..8].copy_from_slice(&self.seqnum.to_be_bytes());
|
||||||
|
result[8..12].copy_from_slice(&self.devid.to_be_bytes());
|
||||||
|
result[12..16].copy_from_slice(&self.direction.to_be_bytes());
|
||||||
|
result[16..20].copy_from_slice(&self.ep.to_be_bytes());
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_from_socket_with_command<T: AsyncReadExt + Unpin>(
|
||||||
|
socket: &mut T,
|
||||||
|
command: u16,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let seqnum = socket.read_u32().await?;
|
||||||
|
let devid = socket.read_u32().await?;
|
||||||
|
let direction = socket.read_u32().await?;
|
||||||
|
// The direction should be 0 or 1
|
||||||
|
debug_assert!(direction & 1 == direction);
|
||||||
|
let ep = socket.read_u32().await?;
|
||||||
|
|
||||||
|
Ok(UsbIpHeaderBasic {
|
||||||
|
command: command.into(),
|
||||||
|
seqnum,
|
||||||
|
devid,
|
||||||
|
direction,
|
||||||
|
ep,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client side commands from the Virtual Host Controller
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
pub enum UsbIpCommand {
|
||||||
|
OpReqDevlist {
|
||||||
|
status: u32,
|
||||||
|
},
|
||||||
|
OpReqImport {
|
||||||
|
status: u32,
|
||||||
|
busid: [u8; 32],
|
||||||
|
},
|
||||||
|
UsbIpCmdSubmit {
|
||||||
|
header: UsbIpHeaderBasic,
|
||||||
|
transfer_flags: u32,
|
||||||
|
transfer_buffer_length: u32,
|
||||||
|
start_frame: u32,
|
||||||
|
number_of_packets: u32,
|
||||||
|
interval: u32,
|
||||||
|
setup: [u8; 8],
|
||||||
|
data: Vec<u8>,
|
||||||
|
iso_packet_descriptor: Vec<u8>,
|
||||||
|
},
|
||||||
|
UsbIpCmdUnlink {
|
||||||
|
header: UsbIpHeaderBasic,
|
||||||
|
unlink_seqnum: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsbIpCommand {
|
||||||
|
/// Constructs a [UsbIpCommand] from a socket
|
||||||
|
///
|
||||||
|
/// This will consume a variable amount of bytes from the socket.
|
||||||
|
/// It might fail if the bytes does not follow the USB/IP protocol properly.
|
||||||
|
pub async fn read_from_socket<T: AsyncReadExt + Unpin>(socket: &mut T) -> Result<UsbIpCommand> {
|
||||||
|
let version: u16 = socket.read_u16().await?;
|
||||||
|
|
||||||
|
if version != 0 && version != USBIP_VERSION {
|
||||||
|
return Err(std::io::Error::other(format!(
|
||||||
|
"Unknown version: {version:#04X}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let command: u16 = socket.read_u16().await?;
|
||||||
|
|
||||||
|
trace!(
|
||||||
|
"Received command: {:#04X} ({}), parsing...",
|
||||||
|
command,
|
||||||
|
match command {
|
||||||
|
OP_REQ_DEVLIST => "OP_REQ_DEVLIST",
|
||||||
|
OP_REQ_IMPORT => "OP_REQ_IMPORT",
|
||||||
|
USBIP_CMD_SUBMIT => "USBIP_CMD_SUBMIT",
|
||||||
|
USBIP_CMD_UNLINK => "USBIP_CMD_UNLINK",
|
||||||
|
_ => "Unknown",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
match command {
|
||||||
|
OP_REQ_DEVLIST => {
|
||||||
|
let status = socket.read_u32().await?;
|
||||||
|
debug_assert!(status == 0);
|
||||||
|
|
||||||
|
Ok(UsbIpCommand::OpReqDevlist { status })
|
||||||
|
}
|
||||||
|
OP_REQ_IMPORT => {
|
||||||
|
let status = socket.read_u32().await?;
|
||||||
|
debug_assert!(status == 0);
|
||||||
|
let mut busid = [0; 32];
|
||||||
|
socket.read_exact(&mut busid).await?;
|
||||||
|
|
||||||
|
Ok(UsbIpCommand::OpReqImport { status, busid })
|
||||||
|
}
|
||||||
|
USBIP_CMD_SUBMIT => {
|
||||||
|
let header =
|
||||||
|
UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_SUBMIT)
|
||||||
|
.await?;
|
||||||
|
let transfer_flags = socket.read_u32().await?;
|
||||||
|
let transfer_buffer_length = socket.read_u32().await?;
|
||||||
|
let start_frame = socket.read_u32().await?;
|
||||||
|
let number_of_packets = socket.read_u32().await?;
|
||||||
|
let interval = socket.read_u32().await?;
|
||||||
|
|
||||||
|
let mut setup = [0; 8];
|
||||||
|
socket.read_exact(&mut setup).await?;
|
||||||
|
|
||||||
|
let data = if header.direction == Direction::In as u32 {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
let mut data = vec![0; transfer_buffer_length as usize];
|
||||||
|
socket.read_exact(&mut data).await?;
|
||||||
|
data
|
||||||
|
};
|
||||||
|
|
||||||
|
// The kernel docs specifies that this should be set to 0xFFFFFFFF for all
|
||||||
|
// non-ISO packets, however the actual implementation resorts to 0x00000000
|
||||||
|
// https://stackoverflow.com/questions/76899798/usb-ip-what-is-the-size-of-the-iso-packet-descriptor
|
||||||
|
let iso_packet_descriptor =
|
||||||
|
if number_of_packets != 0 && number_of_packets != 0xFFFFFFFF {
|
||||||
|
let mut result = vec![0; 16 * number_of_packets as usize];
|
||||||
|
socket.read_exact(&mut result).await?;
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(UsbIpCommand::UsbIpCmdSubmit {
|
||||||
|
header,
|
||||||
|
transfer_flags,
|
||||||
|
transfer_buffer_length,
|
||||||
|
start_frame,
|
||||||
|
number_of_packets,
|
||||||
|
interval,
|
||||||
|
setup,
|
||||||
|
data,
|
||||||
|
iso_packet_descriptor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
USBIP_CMD_UNLINK => {
|
||||||
|
let header =
|
||||||
|
UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_UNLINK)
|
||||||
|
.await?;
|
||||||
|
let unlink_seqnum = socket.read_u32().await?;
|
||||||
|
|
||||||
|
let mut _padding = [0; 24];
|
||||||
|
socket.read_exact(&mut _padding).await?;
|
||||||
|
|
||||||
|
Ok(UsbIpCommand::UsbIpCmdUnlink {
|
||||||
|
header,
|
||||||
|
unlink_seqnum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Err(std::io::Error::other(format!(
|
||||||
|
"Unknown command: {command:#04X}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the [UsbIpCommand] into a byte vector
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
match *self {
|
||||||
|
UsbIpCommand::OpReqDevlist { status } => {
|
||||||
|
let mut result = Vec::with_capacity(8);
|
||||||
|
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||||
|
result.extend_from_slice(&OP_REQ_DEVLIST.to_be_bytes());
|
||||||
|
result.extend_from_slice(&status.to_be_bytes());
|
||||||
|
result
|
||||||
|
}
|
||||||
|
UsbIpCommand::OpReqImport { status, busid } => {
|
||||||
|
let mut result = Vec::with_capacity(40);
|
||||||
|
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||||
|
result.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes());
|
||||||
|
result.extend_from_slice(&status.to_be_bytes());
|
||||||
|
result.extend_from_slice(&busid);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
UsbIpCommand::UsbIpCmdSubmit {
|
||||||
|
ref header,
|
||||||
|
transfer_flags,
|
||||||
|
transfer_buffer_length,
|
||||||
|
start_frame,
|
||||||
|
number_of_packets,
|
||||||
|
interval,
|
||||||
|
setup,
|
||||||
|
ref data,
|
||||||
|
ref iso_packet_descriptor,
|
||||||
|
} => {
|
||||||
|
debug_assert!(
|
||||||
|
header.direction != Direction::Out as u32
|
||||||
|
|| transfer_buffer_length == data.len() as u32
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut result = Vec::with_capacity(48 + data.len() + iso_packet_descriptor.len());
|
||||||
|
result.extend_from_slice(&header.to_bytes());
|
||||||
|
result.extend_from_slice(&transfer_flags.to_be_bytes());
|
||||||
|
result.extend_from_slice(&transfer_buffer_length.to_be_bytes());
|
||||||
|
result.extend_from_slice(&start_frame.to_be_bytes());
|
||||||
|
result.extend_from_slice(&number_of_packets.to_be_bytes());
|
||||||
|
result.extend_from_slice(&interval.to_be_bytes());
|
||||||
|
result.extend_from_slice(&setup);
|
||||||
|
result.extend_from_slice(data);
|
||||||
|
result.extend_from_slice(iso_packet_descriptor);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
UsbIpCommand::UsbIpCmdUnlink {
|
||||||
|
ref header,
|
||||||
|
unlink_seqnum,
|
||||||
|
} => {
|
||||||
|
let mut result = Vec::with_capacity(48);
|
||||||
|
result.extend_from_slice(&header.to_bytes());
|
||||||
|
result.extend_from_slice(&unlink_seqnum.to_be_bytes());
|
||||||
|
result.extend_from_slice(&[0; 24]);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server side responses from the USB Host
|
||||||
|
#[derive(Clone)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
|
pub enum UsbIpResponse {
|
||||||
|
OpRepDevlist {
|
||||||
|
status: u32,
|
||||||
|
device_count: u32,
|
||||||
|
devices: Vec<UsbDevice>,
|
||||||
|
},
|
||||||
|
OpRepImport {
|
||||||
|
status: u32,
|
||||||
|
device: Option<UsbDevice>,
|
||||||
|
},
|
||||||
|
UsbIpRetSubmit {
|
||||||
|
header: UsbIpHeaderBasic,
|
||||||
|
status: u32,
|
||||||
|
actual_length: u32,
|
||||||
|
start_frame: u32,
|
||||||
|
number_of_packets: u32,
|
||||||
|
error_count: u32,
|
||||||
|
transfer_buffer: Vec<u8>,
|
||||||
|
iso_packet_descriptor: Vec<u8>,
|
||||||
|
},
|
||||||
|
UsbIpRetUnlink {
|
||||||
|
header: UsbIpHeaderBasic,
|
||||||
|
status: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsbIpResponse {
|
||||||
|
/// Converts the [UsbIpResponse] into a byte vector
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
match *self {
|
||||||
|
Self::OpRepDevlist {
|
||||||
|
status,
|
||||||
|
device_count,
|
||||||
|
ref devices,
|
||||||
|
} => {
|
||||||
|
let mut result = Vec::with_capacity(
|
||||||
|
12 + devices.len() * 312
|
||||||
|
+ devices
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.interfaces.len() * 4)
|
||||||
|
.sum::<usize>(),
|
||||||
|
);
|
||||||
|
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||||
|
result.extend_from_slice(&OP_REP_DEVLIST.to_be_bytes());
|
||||||
|
result.extend_from_slice(&status.to_be_bytes());
|
||||||
|
result.extend_from_slice(&device_count.to_be_bytes());
|
||||||
|
for dev in devices {
|
||||||
|
result.extend_from_slice(&dev.to_bytes_with_interfaces());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
Self::OpRepImport { status, ref device } => {
|
||||||
|
let mut result = Vec::with_capacity(320);
|
||||||
|
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||||
|
result.extend_from_slice(&OP_REP_IMPORT.to_be_bytes());
|
||||||
|
result.extend_from_slice(&status.to_be_bytes());
|
||||||
|
if let Some(device) = device {
|
||||||
|
result.extend_from_slice(&device.to_bytes());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
Self::UsbIpRetSubmit {
|
||||||
|
ref header,
|
||||||
|
status,
|
||||||
|
actual_length,
|
||||||
|
start_frame,
|
||||||
|
number_of_packets,
|
||||||
|
error_count,
|
||||||
|
ref transfer_buffer,
|
||||||
|
ref iso_packet_descriptor,
|
||||||
|
} => {
|
||||||
|
let mut result =
|
||||||
|
Vec::with_capacity(48 + transfer_buffer.len() + iso_packet_descriptor.len());
|
||||||
|
|
||||||
|
debug_assert!(header.command == USBIP_RET_SUBMIT.into());
|
||||||
|
debug_assert!(if header.direction == Direction::In as u32 {
|
||||||
|
actual_length == transfer_buffer.len() as u32
|
||||||
|
} else {
|
||||||
|
actual_length == 0
|
||||||
|
});
|
||||||
|
|
||||||
|
result.extend_from_slice(&header.to_bytes());
|
||||||
|
result.extend_from_slice(&status.to_be_bytes());
|
||||||
|
result.extend_from_slice(&actual_length.to_be_bytes());
|
||||||
|
result.extend_from_slice(&start_frame.to_be_bytes());
|
||||||
|
result.extend_from_slice(&number_of_packets.to_be_bytes());
|
||||||
|
result.extend_from_slice(&error_count.to_be_bytes());
|
||||||
|
result.extend_from_slice(&[0; 8]);
|
||||||
|
result.extend_from_slice(transfer_buffer);
|
||||||
|
result.extend_from_slice(iso_packet_descriptor);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
Self::UsbIpRetUnlink { ref header, status } => {
|
||||||
|
let mut result = Vec::with_capacity(48);
|
||||||
|
|
||||||
|
debug_assert!(header.command == USBIP_RET_UNLINK.into());
|
||||||
|
|
||||||
|
result.extend_from_slice(&header.to_bytes());
|
||||||
|
result.extend_from_slice(&status.to_be_bytes());
|
||||||
|
result.extend_from_slice(&[0; 24]);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_to_socket<T: AsyncWriteExt + Unpin>(&self, socket: &mut T) -> Result<()> {
|
||||||
|
socket.write_all(&self.to_bytes()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a OP_REP_DEVLIST response
|
||||||
|
pub fn op_rep_devlist(devices: &[UsbDevice]) -> Self {
|
||||||
|
Self::OpRepDevlist {
|
||||||
|
status: 0,
|
||||||
|
device_count: devices.len() as u32,
|
||||||
|
devices: devices.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a successful OP_REP_IMPORT response
|
||||||
|
pub fn op_rep_import_success(device: &UsbDevice) -> Self {
|
||||||
|
Self::OpRepImport {
|
||||||
|
status: 0,
|
||||||
|
device: Some(device.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a failed OP_REP_IMPORT response
|
||||||
|
pub fn op_rep_import_fail() -> Self {
|
||||||
|
Self::OpRepImport {
|
||||||
|
status: 1,
|
||||||
|
device: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a successful OP_REP_IMPORT response
|
||||||
|
pub fn usbip_ret_submit_success(
|
||||||
|
header: &UsbIpHeaderBasic,
|
||||||
|
start_frame: u32,
|
||||||
|
number_of_packets: u32,
|
||||||
|
transfer_buffer: Vec<u8>,
|
||||||
|
iso_packet_descriptor: Vec<u8>,
|
||||||
|
) -> Self {
|
||||||
|
Self::UsbIpRetSubmit {
|
||||||
|
header: header.clone(),
|
||||||
|
status: 0,
|
||||||
|
actual_length: transfer_buffer.len() as u32,
|
||||||
|
start_frame,
|
||||||
|
number_of_packets,
|
||||||
|
error_count: 0,
|
||||||
|
transfer_buffer,
|
||||||
|
iso_packet_descriptor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a failed OP_REP_IMPORT response
|
||||||
|
pub fn usbip_ret_submit_fail(header: &UsbIpHeaderBasic) -> Self {
|
||||||
|
Self::UsbIpRetSubmit {
|
||||||
|
header: header.clone(),
|
||||||
|
status: 1,
|
||||||
|
actual_length: 0,
|
||||||
|
start_frame: 0,
|
||||||
|
number_of_packets: 0,
|
||||||
|
error_count: 0,
|
||||||
|
transfer_buffer: vec![],
|
||||||
|
iso_packet_descriptor: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a successful OP_REP_IMPORT response
|
||||||
|
pub fn usbip_ret_unlink_success(header: &UsbIpHeaderBasic) -> Self {
|
||||||
|
Self::UsbIpRetUnlink {
|
||||||
|
header: header.clone(),
|
||||||
|
status: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a failed OP_REP_IMPORT response.
|
||||||
|
pub fn usbip_ret_unlink_fail(header: &UsbIpHeaderBasic) -> Self {
|
||||||
|
Self::UsbIpRetUnlink {
|
||||||
|
header: header.clone(),
|
||||||
|
status: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (In-crate test module removed in the vendored copy — see NOTICE.)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// Check validity of a USB descriptor
|
||||||
|
pub fn verify_descriptor(desc: &[u8]) {
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset < desc.len() {
|
||||||
|
offset += desc[offset] as usize; // length
|
||||||
|
}
|
||||||
|
assert_eq!(offset, desc.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// (In-crate test module removed in the vendored copy — see NOTICE.)
|
||||||
@@ -34,6 +34,8 @@ holds the full originals.
|
|||||||
| [`apple-stage2-presenter.md`](apple-stage2-presenter.md) | Apple stage-2 (VTDecompressionSession + CAMetalLayer) presenter | **Shipped (opt-in)** — make-default + iOS open |
|
| [`apple-stage2-presenter.md`](apple-stage2-presenter.md) | Apple stage-2 (VTDecompressionSession + CAMetalLayer) presenter | **Shipped (opt-in)** — make-default + iOS open |
|
||||||
| [`game-library-stores.md`](game-library-stores.md) | Multi-store game library | **Phases 1–4 shipped** — 6 providers + 8 Qs open |
|
| [`game-library-stores.md`](game-library-stores.md) | Multi-store game library | **Phases 1–4 shipped** — 6 providers + 8 Qs open |
|
||||||
| [`dualsense-haptics.md`](dualsense-haptics.md) | DualSense advanced-haptics feasibility | **HID shipped**; audio haptics deferred (3 walls) |
|
| [`dualsense-haptics.md`](dualsense-haptics.md) | DualSense advanced-haptics feasibility | **HID shipped**; audio haptics deferred (3 walls) |
|
||||||
|
| [`steam-controller-deck-support.md`](steam-controller-deck-support.md) | Rich Steam Controller / Steam Deck **input fidelity** (paddles · trackpads · gyro → virtual `hid-steam`) | **Design + M0 GREEN** (Linux bind proven); M1+ open |
|
||||||
|
| [`controller-only-mode.md`](controller-only-mode.md) | Controller-only **session shape** — Deck/desktop as a remote gamepad, no video/audio (complements ↑) | **Design** — not yet implemented |
|
||||||
| [`archive/windows-secure-desktop.md`](archive/windows-secure-desktop.md) | Two-process WGC secure-desktop design | **Archived** — shipped but now a fallback (IDD-push primary) |
|
| [`archive/windows-secure-desktop.md`](archive/windows-secure-desktop.md) | Two-process WGC secure-desktop design | **Archived** — shipped but now a fallback (IDD-push primary) |
|
||||||
|
|
||||||
Plus `research/gamestream-protocol-research.json` — raw Moonlight/GameStream wire reference (data, not prose).
|
Plus `research/gamestream-protocol-research.json` — raw Moonlight/GameStream wire reference (data, not prose).
|
||||||
@@ -74,6 +76,10 @@ owning doc.)
|
|||||||
**Game library**
|
**Game library**
|
||||||
- 6 remaining providers (Desktop/Flatpak, itch.io, Ubisoft Connect, Amazon Games, Battle.net, EA app); the `/library/art/<entryId>/<slot>` mgmt endpoint; refactor `library.rs` into a `library/` dir; 8 open design questions; optional SteamGridDB v2 enrichment. → `game-library-stores`
|
- 6 remaining providers (Desktop/Flatpak, itch.io, Ubisoft Connect, Amazon Games, Battle.net, EA app); the `/library/art/<entryId>/<slot>` mgmt endpoint; refactor `library.rs` into a `library/` dir; 8 open design questions; optional SteamGridDB v2 enrichment. → `game-library-stores`
|
||||||
|
|
||||||
|
**Controllers / input**
|
||||||
|
- Rich Steam Controller / Steam Deck capture + virtual `hid-steam` inject (M1+ — Linux UHID, then clients, then deferred Windows UMDF). → `steam-controller-deck-support`
|
||||||
|
- Controller-only session shape (Deck/desktop as a remote gamepad, no video/audio) — `session_flags`/`SESSION_INPUT_ONLY` protocol bit + host skip-data-plane branch + client controller-only path. → `controller-only-mode`
|
||||||
|
|
||||||
**Multi-user / sessions**
|
**Multi-user / sessions**
|
||||||
- gamescope per-session input/audio isolation (independent desktops) — the 4 plumbing items, deferred. → `gamescope-multiuser`, `implementation-plan`
|
- gamescope per-session input/audio isolation (independent desktops) — the 4 plumbing items, deferred. → `gamescope-multiuser`, `implementation-plan`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# Controller-only mode (Deck / desktop as a remote gamepad)
|
||||||
|
|
||||||
|
> **Status:** **DESIGN — not yet implemented.** Locked decisions (2026-06-29): build the
|
||||||
|
> **full-fidelity** path directly (no plain-Xbox interim), capture this as a doc before code.
|
||||||
|
> This is the **session-shape** complement to `design/steam-controller-deck-support.md` (which is
|
||||||
|
> the **input-fidelity** work: capturing the Deck's paddles/trackpads/gyro and injecting a virtual
|
||||||
|
> `hid-steam`/DualSense device). Controller-only mode reuses that capture + inject pipeline
|
||||||
|
> verbatim; it only adds a negotiated "no video / no audio" session shape so the Deck can be a
|
||||||
|
> wireless gamepad for a PC **without** the wasteful return video stream.
|
||||||
|
|
||||||
|
## 1. Goal + the use case
|
||||||
|
|
||||||
|
Let a punktfunk client (a Steam Deck, but also any desktop with a controller) connect to a
|
||||||
|
punktfunk host and forward **only controller input** — no video stream, no audio stream. The host
|
||||||
|
user watches their **own** monitor; the Deck is just a wireless, full-fidelity gamepad. Rumble /
|
||||||
|
lightbar / adaptive-trigger / HID feedback still flows back on the existing side planes.
|
||||||
|
|
||||||
|
Concretely this turns the Deck into:
|
||||||
|
|
||||||
|
- a **couch controller** for a PC wired to a TV — gyro aim, both trackpads, the 4 back grips, with
|
||||||
|
**lower** latency than streaming (no encode/decode round-trip at all), and no bandwidth wasted on
|
||||||
|
a video feed you aren't looking at;
|
||||||
|
- one of **several** Decks/pads driving one shared-screen PC for local co-op (rides the multi-pad
|
||||||
|
work already in flight);
|
||||||
|
- a way to use the Deck's superior input surface (trackpads + gyro) on a game running on a
|
||||||
|
beefier host.
|
||||||
|
|
||||||
|
**Non-goal (for v1):** forwarding a real keyboard/mouse. The Deck's trackpads/gyro are carried as
|
||||||
|
*gamepad* fidelity (DualSense/Steam touchpad + motion planes), which is display-independent (§5).
|
||||||
|
A genuine keyboard/mouse forward rides the libei/portal pointer path, which is *not*
|
||||||
|
display-independent — deferred to a follow-on (§9).
|
||||||
|
|
||||||
|
## 2. Does SteamOS already do this? — No, and that's the opening
|
||||||
|
|
||||||
|
Researched 2026-06-29 (web). Summary of why this is worth building:
|
||||||
|
|
||||||
|
- **Officially**, Valve's only endorsed "Deck as a controller for another PC" path is **Steam
|
||||||
|
Remote Play / Steam Link in reverse** — but it **always streams the game's video to the Deck**
|
||||||
|
even though you're looking at the PC's monitor. A dedicated controller-only mode has been
|
||||||
|
requested since **July 2022** (Steam community thread) and the matching `ValveSoftware/SteamOS`
|
||||||
|
issue **#1623 is "Closed as not planned."** SteamOS 3.8 (Jun 2026) and the new standalone Steam
|
||||||
|
Controller (2026 hardware) did **not** add it.
|
||||||
|
- **Community/DIY** splits three ways, all low-popularity (25–129★), several stale or "currently
|
||||||
|
broken": USB-C HID gadget (**GadgetDeck** — wired-only, BIOS Dual-Role toggle, no gyro/trackpad,
|
||||||
|
unmaintained), Bluetooth HID (**steamdeck-bt-controller-emulator** — pairing/recognition pain + BT
|
||||||
|
latency), and network (**Deckpad** → commercial *paid* VirtualHere USB-over-IP, "currently
|
||||||
|
broken"; **swicd-remote-gamepad** → ViGEm, Windows-only, experimental).
|
||||||
|
- **The ceiling every existing tool hits:** none cleanly carries the Deck's **gyro + dual trackpads
|
||||||
|
+ the 4 back buttons (L4/L5/R4/R5)** to the remote host, because Steam's emulated Xbox pad
|
||||||
|
(`28DE:11FF`) hides them.
|
||||||
|
|
||||||
|
punktfunk is uniquely positioned: it already has a low-latency QUIC input back-channel, host-side
|
||||||
|
virtual Xbox-360 / DualSense / DS4 (and, in flight, `hid-steam`) pad injection with rumble/lightbar/
|
||||||
|
adaptive-trigger feedback, and SDL3 capture. Controller-only mode + the
|
||||||
|
`steam-controller-deck-support.md` capture work together deliver exactly the gap nobody else fills.
|
||||||
|
|
||||||
|
Sources (load-bearing): SteamOS issue #1623 (closed not-planned)
|
||||||
|
<https://github.com/ValveSoftware/SteamOS/issues/1623>; Steam community controller-only-mode request
|
||||||
|
<https://steamcommunity.com/app/1675200/discussions/2/3466100515592011642/>; Valve FAQ
|
||||||
|
<https://help.steampowered.com/en/faqs/view/0689-74B8-92AC-10F2>; GadgetDeck
|
||||||
|
<https://github.com/Frederic98/GadgetDeck>; Deckpad <https://github.com/HelloThisIsFlo/Deckpad>;
|
||||||
|
Steam Deck HID deep-dive <https://blogs.gnome.org/alicem/2024/10/24/steam-deck-hid-and-libmanette-adventures/>.
|
||||||
|
|
||||||
|
## 3. The key synergy: controller-only mode *dissolves* the Game-Mode capture wall
|
||||||
|
|
||||||
|
`steam-controller-deck-support.md` §6 / Wall A: on the Deck, SDL3's HIDAPI driver can open the raw
|
||||||
|
`28DE:1205` and expose paddles + both trackpads + gyro as a first-class SDL gamepad — **but in Deck
|
||||||
|
Game Mode, Steam Input grabs the device exclusively** and re-presents it as the gutted `28DE:11FF`
|
||||||
|
virtual XInput pad, so the rich controls silently vanish. The only escape there is the
|
||||||
|
disable-Steam-Input-per-title UX.
|
||||||
|
|
||||||
|
**Controller-only mode's natural launch context avoids that wall entirely.** The use case is "the
|
||||||
|
Deck is a controller, no game runs on the Deck" → it runs as a **desktop-mode / standalone app**,
|
||||||
|
where Steam Input is **not** managing the internal pad, so SDL3 binds `28DE:1205` and gets full
|
||||||
|
fidelity with no UX gymnastics. So the two features are mutually reinforcing: controller-only mode
|
||||||
|
is the very scenario in which full-fidelity Deck capture "just works."
|
||||||
|
|
||||||
|
The capture-side rule is therefore the same one §6 documents, and the client **must verify at
|
||||||
|
runtime it opened `28DE:1205` (HIDAPI GUID ending `6800`), not `28DE:11FF`** — if it only sees
|
||||||
|
`11FF`, Steam owns the pad and gyro/trackpad/grips are unavailable; surface that to the user.
|
||||||
|
|
||||||
|
## 4. Architecture — input plane is already decoupled from video
|
||||||
|
|
||||||
|
A punktfunk/1 native session already runs **two independent transports** (verified in-tree):
|
||||||
|
|
||||||
|
- a **QUIC** control connection that carries the `Hello`/`Welcome`/`Start` handshake **and every
|
||||||
|
side plane** as datagrams demuxed by first byte: input `0xC8`, rich input `0xCC` (DualSense/Deck
|
||||||
|
touchpad + motion), mic uplink `0xCB`, audio `0xC9`, rumble `0xCA`, HID-out `0xCD`;
|
||||||
|
- a **raw-UDP data plane** (`Session`, FEC + AES-GCM) that carries **only** the video AUs.
|
||||||
|
|
||||||
|
**Input never touches the UDP data plane** — it rides QUIC datagrams. So an input-only session is
|
||||||
|
"run the QUIC handshake + side planes, never bind/open the UDP data plane." The honest work is
|
||||||
|
making **both** ends *skip the data plane* and, on the host, *not spin up a virtual display +
|
||||||
|
encoder* (a desktop PC the operator is watching has no reason to allocate a headless virtual output
|
||||||
|
or burn an NVENC slot).
|
||||||
|
|
||||||
|
Critically: **gamepads are system-global kernel devices, not tied to any virtual output.** The
|
||||||
|
per-session `PadBackend` (`punktfunk1.rs:1396`) creates an Xbox-360 pad on `/dev/uinput`, or a
|
||||||
|
DualSense/DS4/Steam pad on `/dev/uhid` — all visible to Steam/Proton/every game on the host's real
|
||||||
|
seat with **zero** display involvement. Rumble/HID feedback (`0xCA`/`0xCD`) and DualSense/Deck
|
||||||
|
touchpad+motion (`0xCC` rich input, `apply_rich` at `:1467`/`:1606`) flow on the same per-session
|
||||||
|
input thread, also display-independent. So a controller-only session needs **no virtual display, no
|
||||||
|
compositor, no portal grant, no encoder** — just the input thread + the pad backend.
|
||||||
|
|
||||||
|
`clients/probe --input-test` already proves the shape: it connects, streams scripted gamepad
|
||||||
|
datagrams the host injects into a real pad, and never decodes video.
|
||||||
|
|
||||||
|
## 5. Protocol / ABI change — one `session_flags` byte (additive, fwd-compatible)
|
||||||
|
|
||||||
|
Reuse the **exact** trailing-byte back-compat discipline `Hello`/`Welcome` already apply to
|
||||||
|
`compositor`/`gamepad`/`video_caps`/`audio_channels` (`quic.rs:655-882`). Add a **session-flags**
|
||||||
|
byte as the new last trailing field on both.
|
||||||
|
|
||||||
|
```
|
||||||
|
quic.rs (core):
|
||||||
|
pub const SESSION_INPUT_ONLY: u8 = 0x01; // bit 0 of session_flags
|
||||||
|
|
||||||
|
Hello { …, audio_channels: u8, session_flags: u8 } // new trailing byte after audio_channels
|
||||||
|
Welcome { …, audio_channels: u8, session_flags: u8 } // echoes the resolved shape (offset 66)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 Encode (placeholder discipline)
|
||||||
|
|
||||||
|
`Hello::encode` already emits `video_caps` / `audio_channels` only when non-default, emitting
|
||||||
|
upstream placeholders so each lands at a deterministic offset. Extend the same logic:
|
||||||
|
|
||||||
|
```
|
||||||
|
need_placeholders = video_caps != 0 || audio_channels != 2 || session_flags != 0;
|
||||||
|
// video_caps emitted when video_caps != 0 || audio_channels != 2 || session_flags != 0
|
||||||
|
// audio_channels emitted when audio_channels != 2 || session_flags != 0
|
||||||
|
// session_flags emitted when session_flags != 0 (one more trailing byte, after audio_channels)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Hello::decode` reads it one past `audio_channels` (i.e. `video_caps_off + 2`), defaulting to `0`
|
||||||
|
(no flags) when absent → an older peer requests an ordinary video session, byte-identical wire.
|
||||||
|
`Welcome` is simpler (fixed-position trailing bytes): append `session_flags` at **offset 66**,
|
||||||
|
`b.get(66).copied().unwrap_or(0)`.
|
||||||
|
|
||||||
|
> **Why a flags byte, not an overloaded `Mode{0,0,0}` sentinel:** a flag is explicit and leaves
|
||||||
|
> room for future session-shape bits (e.g. audio-only, input+audio). `Mode` stays strictly a
|
||||||
|
> display mode. (Open question 7.1 — confirm with the user, but this is the recommendation.)
|
||||||
|
|
||||||
|
### 5.2 ABI
|
||||||
|
|
||||||
|
- New `connect_ex` rung in the existing ladder (the `connect_exN` precedent) that takes a
|
||||||
|
`session_flags` (or a bool `input_only`) and stores it on `NativeClient`; legacy `connect_ex*`
|
||||||
|
stay byte-for-byte. Regenerate `include/punktfunk_core.h` (CI fails on drift).
|
||||||
|
- New constant `PUNKTFUNK_SESSION_INPUT_ONLY = 0x01`.
|
||||||
|
- `next_au` / `next_frame` / `next_audio` simply return `NoFrame`/`Closed` in an input-only
|
||||||
|
connection; `send_input` / `send_rich_input` / `next_rumble` / `next_hidout` are unchanged — they
|
||||||
|
are already the full input-only surface.
|
||||||
|
|
||||||
|
## 6. Host changes — `serve_session` branch (`punktfunk1.rs:508`)
|
||||||
|
|
||||||
|
Branch on `hello.session_flags & SESSION_INPUT_ONLY`. When set:
|
||||||
|
|
||||||
|
| Step | Today (video session) | Input-only |
|
||||||
|
|---|---|---|
|
||||||
|
| `--max-concurrent` permit (`:640` `_permit`) | acquired (NVENC slot) | **skip** — input-only must not consume a GPU slot (§7.4) |
|
||||||
|
| `validate_dimensions` (`:654`) | required | **skip** |
|
||||||
|
| `resolve_compositor` (`:668`) | required (Virtual source) | **skip** — no virtual display |
|
||||||
|
| bit-depth / chroma / HDR / 444 probes, bitrate clamp | run | **skip** |
|
||||||
|
| `Welcome` (`:794`) | real mode + udp_port + caps | **sentinel** mode `{0,0,0}` + `udp_port=0` + `session_flags=INPUT_ONLY`; still carries the resolved `gamepad` backend |
|
||||||
|
| `Start::decode` (`:845`) | read client udp port | read + **ignore** (harmless no-op) |
|
||||||
|
| `input_thread` spawn (`:980`) | spawn | **spawn (unchanged)** — this is the whole point |
|
||||||
|
| client→host datagram demux (`:989`) | spawn | **spawn (unchanged)** — `0xC8`/`0xCC`/`0xCB` in, `0xCA`/`0xCD` out |
|
||||||
|
| `audio_thread` (`:1032`, already gated on `source==Virtual`) | spawn | **skip** (add `&& !input_only`) |
|
||||||
|
| `virtual_stream(SessionContext{…})` (`:1155`) — the only place a display+encoder open and the UDP socket binds | run | **replace** with `await stop / conn.closed()` — no UDP bind happens (the bind lives inside that block), no display, no encoder |
|
||||||
|
| teardown (`:1190+`) | releases input thread + pads + held keys | **unchanged** — already correct |
|
||||||
|
|
||||||
|
Net host change is mostly **guards and deletions in one function**; no hot-path, FEC, crypto, or
|
||||||
|
injector changes. `cfg`-clean on Windows (no compositor/`virtual_stream` there either; the XUSB/UMDF
|
||||||
|
pads are likewise system-global, SendInput is irrelevant in a gamepad-only mode).
|
||||||
|
|
||||||
|
### 6.1 Pad backend / fidelity (reuses `steam-controller-deck-support.md` verbatim)
|
||||||
|
|
||||||
|
The full-fidelity path is **entirely** the in-flight Deck/DualSense inject work — controller-only
|
||||||
|
mode adds nothing here, it just runs it without video:
|
||||||
|
|
||||||
|
- Deck → host resolves the **Steam `hid-steam`** backend (Linux UHID) when available so Steam Input
|
||||||
|
re-emits `28DE:11FF` with the user's bindings + correct glyphs; trackpads → `RichInput::TouchpadEx`
|
||||||
|
(`0xCC 0x03`), gyro/accel → `RichInput::Motion` (`0xCC 0x02`), back grips → `BTN_PADDLE1..4`
|
||||||
|
(`0xC8` bits). See that doc §4–§7.
|
||||||
|
- Where a real Steam pad is unavailable, the **DualSense remap fallback** (`steam_remap.rs`) folds
|
||||||
|
Steam-only inputs into a virtual DualSense (gyro→motion, right pad→touchpad, grips→configured
|
||||||
|
fallback) so nothing is silently dropped.
|
||||||
|
- `GamepadPref` resolution policy is that doc's §7 — **unchanged**; the Welcome echoes the real
|
||||||
|
resolved backend (honest downgrade).
|
||||||
|
|
||||||
|
## 7. Client changes
|
||||||
|
|
||||||
|
### 7.1 Core connector (`client.rs::worker_main:714`)
|
||||||
|
|
||||||
|
Thread an `input_only` flag in. When set: do the full `Hello`/`Welcome` handshake and spawn the
|
||||||
|
input/mic/rich/ctrl/datagram-demux tasks (so `send_input`/`send_rich_input` + `next_rumble`/
|
||||||
|
`next_hidout` keep working), but **skip** the UDP port reservation + `Start`-derived
|
||||||
|
`UdpTransport::connect` + `Session` + the data-plane pump (`:810-849,1041`). `next_frame`/
|
||||||
|
`next_audio` return `Closed`/`NoFrame`.
|
||||||
|
|
||||||
|
### 7.2 Deck / Linux client (`clients/linux`) — a "Use as controller" path
|
||||||
|
|
||||||
|
Add a connect path that opens the connection **input-only** and runs **only** the app-lifetime
|
||||||
|
`GamepadService` (`clients/linux/src/gamepad.rs`) — it already `attach`es the connector, forwards
|
||||||
|
pads via `send_input` (`:161`), DualSense/Deck touchpad+motion via `send_rich_input`, and drains
|
||||||
|
`next_rumble` (`:566`)/`next_hidout` (`:583`). **Do not** run `session.rs`'s video/audio pump
|
||||||
|
(`:135-200`) — no decoder, no video window, no PipeWire player. UI is a minimal "Connected as
|
||||||
|
controller — <backend> · <host>" status surface (battery, latency, a Disconnect button), no video
|
||||||
|
widget.
|
||||||
|
|
||||||
|
On the Deck specifically: set `SDL_JOYSTICK_HIDAPI_STEAMDECK`/`HIDAPI_STEAM` before `sdl3::init()`,
|
||||||
|
resolve `GamepadPref::SteamDeck` from VID `0x28DE`, and **assert the opened device is `28DE:1205`
|
||||||
|
(HIDAPI GUID `…6800`), not `11FF`** — if `11FF`, show "Steam is managing this controller; full
|
||||||
|
gyro/trackpad/grip fidelity needs desktop mode / Steam Input off." (Capture mechanics =
|
||||||
|
`steam-controller-deck-support.md` §6.)
|
||||||
|
|
||||||
|
### 7.3 Other clients
|
||||||
|
|
||||||
|
`clients/windows` gets the same input-only connect + SDL `GamepadService`-only path (XUSB/UMDF pads
|
||||||
|
are system-global; no SwapChainPanel/decode). Apple/Android: parity is optional and lower-priority —
|
||||||
|
the connector ABI already exposes everything; a "controller-only" UI mode is a small add once the
|
||||||
|
core flag lands. Scope per the user's roadmap, not blocking.
|
||||||
|
|
||||||
|
### 7.4 Session lifetime / accounting
|
||||||
|
|
||||||
|
An input-only session is long-lived (until disconnect), like a video session, but must **not**
|
||||||
|
count against `--max-concurrent` (it holds no NVENC) and should be **exempt** from any `--seconds`
|
||||||
|
duration cap. The mgmt API / web console should list it distinctly (it is **not** a "stream" — no
|
||||||
|
fps/bitrate/mode), and `--max-sessions` accounting should likely treat it as its own class
|
||||||
|
(open question 8.x). mDNS advertisement is unchanged (the host already advertises native service;
|
||||||
|
input-only is a per-session negotiation, not a service variant).
|
||||||
|
|
||||||
|
## 8. Security / trust — unchanged
|
||||||
|
|
||||||
|
A controller-only session is a **full punktfunk/1 session**: same SPAKE2 PIN pairing / TOFU /
|
||||||
|
`--require-pairing` gate, same QUIC client-auth + pinned-fingerprint trust. The *only* difference is
|
||||||
|
the absent data plane. No new attack surface — if anything less (no UDP socket, no FEC reassembler,
|
||||||
|
no decoder fed attacker bytes). The host still requires `/dev/uinput` (+ `/dev/uhid` for DualSense/
|
||||||
|
Steam) writable — the documented `input` group + `60-punktfunk.rules` setup.
|
||||||
|
|
||||||
|
## 9. Milestones
|
||||||
|
|
||||||
|
- **M1 — protocol + ABI:** `session_flags` byte + `SESSION_INPUT_ONLY` on `Hello`/`Welcome` with
|
||||||
|
round-trip + old-peer-default unit tests; new `connect_ex` rung; regenerate the C header.
|
||||||
|
- **M2 — host branch:** `serve_session` input-only path (skip display/encoder/audio/permit, keep
|
||||||
|
input thread + pads), `cfg`-clean on Windows. Loopback test: handshake completes, **no UDP data
|
||||||
|
plane binds**, gamepad events still inject into a real uinput pad.
|
||||||
|
- **M3 — Deck/Linux client:** "Use as controller" connect + `GamepadService`-only run; `28DE:1205`
|
||||||
|
vs `11FF` runtime check + user messaging.
|
||||||
|
- **M4 — full fidelity on-glass:** with `steam-controller-deck-support.md`'s Deck capture + inject,
|
||||||
|
validate Deck (desktop mode) → host: paddles + both trackpads + gyro reach the host pad, Steam
|
||||||
|
Input re-emits with bindings/glyphs, rumble returns. Glass-to-glass input latency vs a wired pad.
|
||||||
|
- **M5 — Windows client parity + mgmt/web "controller session" surfacing.**
|
||||||
|
- **M6 (deferred) — keyboard/mouse forward** over the libei/portal path (needs the active-session
|
||||||
|
RemoteDesktop grant; not display-independent).
|
||||||
|
|
||||||
|
## 10. Risks / open questions
|
||||||
|
|
||||||
|
**Open questions (decide with the user):**
|
||||||
|
|
||||||
|
1. **`session_flags` byte vs `Mode{0,0,0}` sentinel** for "no video" — recommend the explicit flags
|
||||||
|
byte (room for future shapes). *(Recommended; confirm.)*
|
||||||
|
2. Should an input-only session be **exempt** from `--max-concurrent` and `--seconds`? (Recommend
|
||||||
|
yes — it holds no GPU.)
|
||||||
|
3. Should mgmt/web track controller-only sessions as a distinct class (no fps/bitrate/mode), and
|
||||||
|
should `--max-sessions` count them?
|
||||||
|
4. Deck default backend: **Steam `hid-steam`** (best — Steam Input bindings/glyphs) vs **DualSense**
|
||||||
|
(works off-Steam too). Tie to `steam-controller-deck-support.md`'s resolution policy.
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
|
||||||
|
- **Capture wall (inherited):** full fidelity requires SDL to bind `28DE:1205`; if the Deck is in
|
||||||
|
Game Mode / Steam owns the pad, it degrades to `11FF` (sticks/buttons only). Mitigated by the
|
||||||
|
desktop-mode use case + runtime check + user messaging (§3, §7.2).
|
||||||
|
- **Host `/dev/uinput`(+`/dev/uhid`) perms** — a normal desktop PC operator must do the one-time
|
||||||
|
input-group/udev setup for the virtual pad to appear (already documented).
|
||||||
|
- **Handshake assumes a data plane:** `Start{client_udp_port}` + non-zero `udp_port` in `Welcome`
|
||||||
|
must become harmless no-ops (send 0 / ignore) without shifting the legacy wire — the trailing-byte
|
||||||
|
placeholder discipline is fragile; **add round-trip + old-peer tests** (M1).
|
||||||
|
- **Control-channel messages** (LossReport/Reconfigure/Probe/ClockProbe) assume a data plane; the
|
||||||
|
host (`:881`) + client (`:935`) control tasks must tolerate an input-only session with none —
|
||||||
|
mostly already fine since those are reactive.
|
||||||
|
- **Windows parity:** verify the input-only branch compiles `cfg`-clean where there is no
|
||||||
|
compositor/`virtual_stream`.
|
||||||
|
|
||||||
|
## 11. Validation plan
|
||||||
|
|
||||||
|
**Loopback (no hardware):**
|
||||||
|
- `quic.rs`: `session_flags` encode/decode round-trip; old-peer (no flags byte) → ordinary session;
|
||||||
|
flags coexist with non-default `video_caps`/`audio_channels` (placeholder offsets hold).
|
||||||
|
- Host: an input-only synthetic host+client asserting (a) handshake completes, (b) **no UDP socket
|
||||||
|
binds**, (c) no display/encoder opens, (d) scripted `0xC8`/`0xCC` events inject into a real pad,
|
||||||
|
(e) `0xCA`/`0xCD` feedback returns. Extend the `clients/probe` / `test-loopback.sh` harness.
|
||||||
|
|
||||||
|
**On-box / on-glass (with `steam-controller-deck-support.md` landed):**
|
||||||
|
- Deck (desktop mode) → this host, controller-only: confirm `28DE:1205` opens (not `11FF`); paddles
|
||||||
|
+ both trackpads + gyro reach the host pad; Steam Input on the host re-emits with bindings/glyphs;
|
||||||
|
rumble round-trips; measure input-only latency vs a wired pad and vs the streaming path.
|
||||||
|
- Confirm the host opens **no** virtual output / encoder (logs) and the session does **not** consume
|
||||||
|
a `--max-concurrent` slot (run alongside N video sessions).
|
||||||
@@ -1,12 +1,33 @@
|
|||||||
# Plan: production-ready Steam Deck pass-through (client) + shippable virtual Deck on any Linux host
|
# Plan: production-ready Steam Deck pass-through (client) + shippable virtual Deck on any Linux host
|
||||||
|
|
||||||
> **Status (2026-06-29):** architecture validated end-to-end on hardware; this is the build plan to
|
> **Status (2026-06-29): BUILT — code-complete, all CI checks green on Linux (build · `clippy
|
||||||
> ship it. Companion doc: [`steam-controller-deck-support.md`](steam-controller-deck-support.md)
|
> -D warnings` · `fmt` · ~270 tests), adversarially reviewed; NOT yet on-glass validated, NOT pushed.**
|
||||||
|
> Implemented in one pass:
|
||||||
|
> - **usbip/`vhci_hcd` transport** (`crates/punktfunk-host/src/inject/linux/steam_usbip.rs`) presenting
|
||||||
|
> a real interface-2 USB Deck, with **in-process vhci attach** (sysfs `OP_REQ_IMPORT` handshake) and a
|
||||||
|
> bounded `usbip`-CLI fallback. Backed by a **vendored, libusb-free trim** of the `usbip` crate
|
||||||
|
> (`crates/punktfunk-host/vendor/usbip-sim/`, MIT, see its NOTICE).
|
||||||
|
> - **Selection ladder** `raw_gadget` (SteamOS) → `usbip` (`vhci_hcd`, universal) → UHID
|
||||||
|
> (`steam_controller.rs::open_transport`); `PUNKTFUNK_STEAM_USBIP=0/1`, `PUNKTFUNK_USBIP_ATTACH=inproc|cli`.
|
||||||
|
> - **Shared Deck device contract** (captured descriptors + `0x83`/`0xAE` `feature_reply` + a
|
||||||
|
> Steam-accepted serial) consolidated into `steam_proto.rs`; the gadget now reuses it.
|
||||||
|
> - **Client leave-shortcuts**: keyboard **Ctrl+Alt+Shift+D** + controller **hold the escape chord
|
||||||
|
> (L1+R1+Start+Select) ≥1.5 s** → disconnect (short press still leaves fullscreen). Steam/QAM are NOT
|
||||||
|
> in the chord. Linux client only for now (windows/apple/android mirror is future work).
|
||||||
|
>
|
||||||
|
> **Decisions taken** (the plan's open questions): vendor-trim the crate (no libusb) ✓; in-process
|
||||||
|
> attach primary + CLI fallback ✓; escalate the existing escape chord (long-hold) ✓; keep BOTH
|
||||||
|
> `raw_gadget` (SteamOS fast-path) and usbip (universal) behind the transport ladder ✓.
|
||||||
|
>
|
||||||
|
> **Remaining = §6 on-glass validation** (Bazzite `192.168.1.41` + Deck `192.168.1.253`): confirm the
|
||||||
|
> in-process usbip attach promotes the Deck in game mode (Steam + QAM reach the game-mode UI), the
|
||||||
|
> raw_gadget path still works on SteamOS (regression), and the leave-shortcuts fire. The dev box has no
|
||||||
|
> Steam + no root, so this could not be run here.
|
||||||
|
>
|
||||||
|
> Companion doc: [`steam-controller-deck-support.md`](steam-controller-deck-support.md)
|
||||||
> (the virtual-Deck design + §11 the interface-2 / gadget story). The virtual Steam Deck that Steam
|
> (the virtual-Deck design + §11 the interface-2 / gadget story). The virtual Steam Deck that Steam
|
||||||
> Input promotes is **already built, hardware-validated, and default-on for SteamOS**
|
> Input promotes is **already built, hardware-validated, and default-on for SteamOS**
|
||||||
> (`raw_gadget`/`dummy_hcd`, glass-confirmed in-game on a real Deck). What remains is (a) exact
|
> (`raw_gadget`/`dummy_hcd`, glass-confirmed in-game on a real Deck).
|
||||||
> Deck pass-through from the Linux *client* incl. the Steam + QAM buttons, (b) a **shippable** virtual
|
|
||||||
> Deck on non-SteamOS Linux hosts (Bazzite etc.) via **usbip/`vhci_hcd`**, and (c) a leave-shortcut.
|
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user