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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 19:17:00 +00:00
parent 831b37b4b7
commit 580b1ea7a7
26 changed files with 3292 additions and 145 deletions
@@ -349,6 +349,117 @@ pub fn parse_steam_output(data: &[u8]) -> SteamFeedback {
fb
}
// ===========================================================================================
// Real-USB Deck device contract (the gadget + usbip transports present a *real* 3-interface USB
// Deck so Steam Input promotes it; the UHID path above uses the minimal [`STEAMDECK_RDESC`]).
//
// These descriptors are captured verbatim from a physical Steam Deck (28DE:1205): mouse =
// interface 0, keyboard = interface 1, **controller = interface 2** (the interface number Steam's
// own driver filters on — the reason a UHID Deck, `Interface: -1`, is never promoted). The
// `0x83`/`0xAE` feature contract is what stops Steam re-probing (the gamepad-evdev churn). Shared
// by [`super::super::steam_gadget`] (raw_gadget) and [`super::super::steam_usbip`] (usbip/vhci).
// ===========================================================================================
/// Captured Deck **mouse** report descriptor (interface 0, EP 0x81).
#[rustfmt::skip]
pub const RDESC_DECK_MOUSE: &[u8] = &[
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02,
0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,
0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06,
0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0];
/// Captured Deck **keyboard** (boot) report descriptor (interface 1, EP 0x82).
#[rustfmt::skip]
pub const RDESC_DECK_KBD: &[u8] = &[
0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01,
0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65,
0x75,0x08,0x95,0x06,0x81,0x00,0xc0];
/// Captured Deck **controller** report descriptor (interface 2, EP 0x83; Usage Page `0xFFFF`,
/// `bCountryCode 33`). The vendor-defined report the `hid-steam` driver binds.
#[rustfmt::skip]
pub const RDESC_DECK_CTRL: &[u8] = &[
0x06,0xff,0xff,0x09,0x01,0xa1,0x01,0x09,0x02,0x09,0x03,0x15,0x00,0x26,0xff,0x00,
0x75,0x08,0x95,0x40,0x81,0x02,0x09,0x06,0x09,0x07,0x15,0x00,0x26,0xff,0x00,0x75,
0x08,0x95,0x40,0xb1,0x02,0xc0];
/// Per-instance Deck unit id stamped into the `0x83` GET_ATTRIBUTES device-id attrs (`0x0a`/`0x04`)
/// so a virtual Deck never collides with a real one or another instance. `"PF"` high word + index.
pub fn deck_unit_id(index: u8) -> u32 {
0x5046_0000 | index as u32
}
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`; Steam rejects
/// a too-short/oddly-formatted one as "Invalid or missing unit serial number" and substitutes its
/// own — benign, but we present a clean 12-char one). Derived from [`deck_unit_id`] so the `0xAE`
/// serial reply and the `0x83` unit-id attrs stay consistent.
pub fn deck_serial(index: u8) -> String {
format!("PFDK{:08X}", deck_unit_id(index))
}
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
/// real-USB transports stream until the first [`serialize_deck_state`] call updates it.
pub fn neutral_deck_report() -> [u8; STEAM_REPORT_LEN] {
let mut r = [0u8; STEAM_REPORT_LEN];
r[0] = 0x01;
r[2] = ID_CONTROLLER_DECK_STATE;
r[3] = 0x3C;
r
}
/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command, for the *real-USB*
/// Deck (gadget + usbip). Steam's `GetControllerInfo` reads the `0x83` attributes + the `0xAE`
/// serial; **serving the real `0x83` blob is what stops Steam re-probing** (the gamepad-evdev churn).
/// The 9-attribute `0x83` layout + the `0xAE` string format were captured from a physical Deck via
/// hidraw. `unit_id` (see [`deck_unit_id`]) stamps a per-instance value into the device-id attrs.
///
/// Note this is the raw 64-byte EP0 feature payload (command id first, no report-id prefix) — the USB
/// control path, distinct from [`serial_reply`] which carries the UHID report-id byte the kernel
/// strips.
pub fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; STEAM_REPORT_LEN] {
let cmd = last_set.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
let mut r = [0u8; STEAM_REPORT_LEN];
match cmd {
ID_GET_ATTRIBUTES_VALUES => {
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)].
r[0] = ID_GET_ATTRIBUTES_VALUES;
r[1] = 0x2d;
let attrs: [(u8, u32); 9] = [
(0x01, 0x1205), // product id
(0x02, 0),
(0x0a, unit_id), // unit serial number (per-instance)
(0x04, unit_id ^ 0x5555_5555),
(0x09, 0x2e),
(0x0b, 0x0fa0),
(0x0d, 0),
(0x0c, 0),
(0x0e, 0),
];
let mut o = 2;
for (id, val) in attrs {
r[o] = id;
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
o += 5;
}
}
ID_GET_STRING_ATTRIBUTE => {
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr
// 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id.
let attr = last_set.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
let b = serial.as_bytes();
let len = b.len().clamp(1, 20);
r[0] = ID_GET_STRING_ATTRIBUTE;
r[1] = len as u8;
r[2] = attr;
r[3..3 + len].copy_from_slice(&b[..len]);
}
_ => {
// Settings read-back (e.g. 0x87): echo the host's last command + data.
let n = last_set.len().min(STEAM_REPORT_LEN);
r[..n].copy_from_slice(&last_set[..n]);
}
}
r
}
#[cfg(test)]
mod tests {
use super::*;
@@ -532,4 +643,42 @@ mod tests {
d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble
assert_eq!(parse_steam_output(&d).rumble, None);
}
/// The shared real-USB Deck feature contract (gadget + usbip): the `0x83` GET_ATTRIBUTES reply
/// carries the 9-attribute blob with the per-instance unit id, and the `0xAE` reply carries the
/// Steam-accepted serial — both keyed off the host's last SET_REPORT command. A slip here is the
/// gamepad-evdev churn (Steam re-probing).
#[test]
fn deck_feature_reply_contract() {
let serial = deck_serial(0);
let unit_id = deck_unit_id(0);
assert_eq!(serial, "PFDK50460000"); // 12-char alphanumeric, derived from the unit id
assert_eq!(serial.len(), 12);
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
let r = feature_reply(&[ID_GET_ATTRIBUTES_VALUES], &serial, unit_id);
assert_eq!(r[0], ID_GET_ATTRIBUTES_VALUES);
assert_eq!(r[1], 0x2d);
assert_eq!(r[12], 0x0a); // 3rd attr id (slots at 2,7,12,…)
assert_eq!(
u32::from_le_bytes([r[13], r[14], r[15], r[16]]),
unit_id,
"unit serial attribute must carry the per-instance unit id"
);
// 0xAE GET_STRING_ATTRIBUTE: [0xAE, len, attr(0x01), ascii serial…].
let r = feature_reply(
&[ID_GET_STRING_ATTRIBUTE, 0, ATTRIB_STR_UNIT_SERIAL],
&serial,
unit_id,
);
assert_eq!(r[0], ID_GET_STRING_ATTRIBUTE);
assert_eq!(r[1] as usize, serial.len());
assert_eq!(r[2], ATTRIB_STR_UNIT_SERIAL);
assert_eq!(&r[3..3 + serial.len()], serial.as_bytes());
// Distinct pad indices get distinct unit ids + serials (no collision between virtual Decks).
assert_ne!(deck_unit_id(0), deck_unit_id(1));
assert_ne!(deck_serial(0), deck_serial(1));
}
}