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>
15 KiB
Plan: production-ready Steam Deck pass-through (client) + shippable virtual Deck on any Linux host
Status (2026-06-29): BUILT — code-complete, all CI checks green on Linux (build ·
clippy -D warnings·fmt· ~270 tests), adversarially reviewed; NOT yet on-glass validated, NOT pushed. Implemented in one pass:
- usbip/
vhci_hcdtransport (crates/punktfunk-host/src/inject/linux/steam_usbip.rs) presenting a real interface-2 USB Deck, with in-process vhci attach (sysfsOP_REQ_IMPORThandshake) and a boundedusbip-CLI fallback. Backed by a vendored, libusb-free trim of theusbipcrate (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/0xAEfeature_reply+ a Steam-accepted serial) consolidated intosteam_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+ Deck192.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(the virtual-Deck design + §11 the interface-2 / gadget story). The virtual Steam Deck that Steam Input promotes is already built, hardware-validated, and default-on for SteamOS (raw_gadget/dummy_hcd, glass-confirmed in-game on a real Deck).
Goal
When a Steam Deck (or any Valve controller) is the client, streaming to a Linux host running Steam (SteamOS or Bazzite/generic), every Deck control — including the Steam logo button and the QAM "…" (quick-access) button — passes through and drives the host's game-mode UI, so it feels native. Plus a leave-shortcut (controller + keyboard) since Steam/QAM now pass through.
What's already true (do NOT rebuild — verified by investigation wf_f5e3528b-3ef)
The client capture is already correct, and the wire + host mapping already carry Steam + QAM:
- SDL3's HIDAPI Steam Deck driver exposes Steam →
Button::Guide(joystick b5) and QAM "…" →Button::Misc1(joystick b11→misc1inSDL_gamepad_db.h:729; confirmed inSDL_gamepad.h: "Steam Controller QAM" =MISC1). Paddles →RightPaddle1/2,LeftPaddle1/2; trackpad clicks →Touchpad/Misc2. clients/linux/src/gamepad.rs:173-201button_bit()already mapsGuide → wire::BTN_GUIDE,Misc1 → wire::BTN_MISC1, all four paddles, touchpad.- Wire buttons are
u32(crates/punktfunk-core/src/input.rs:54-86):BTN_GUIDE=0x0400(bit 10),BTN_MISC1=0x0020_0000(bit 21); free bits = 11, 22-31. Buttons ride as individualInputEvent(0xC8) events (code=bit,x=1/0); rich input (touchpad/gyro) on0xCC. - Host
steam_proto::from_gamepad(crates/punktfunk-host/src/inject/proto/steam_proto.rs:179-233) already mapsBTN_GUIDE → btn::STEAM(line 214) andBTN_MISC1 → btn::QAM(line 230). Thebtnmodule:STEAM = 1<<13,QAM = 1<<50. - Caveat that matters: SDL only surfaces Steam/QAM when Steam Input is NOT grabbing the
controller on the client (else Steam consumes them globally and hands the app its virtual Xbox
pad, which lacks Steam/QAM). The fix is disable Steam Input for the client — already the Decky
plugin's "Disable Steam Input" UX. SDL's HIDAPI Deck driver is on by default on Linux
(
SDL_HIDAPI_DEFAULT);SDL_JOYSTICK_HIDAPI_STEAMDECK=1is already set ingamepad.rs:446-456. - Only the Deck host backend carries QAM. The Xbox/xpad map
(
inject/linux/gamepad.rs:80-97) and DualSense (dualsense_proto.rs:214) map Guide→MODE/PS but dropBTN_MISC1(QAM) — they have no slot for it. So QAM-to-game-mode requires the virtual Deck backend (gadget or usbip). This is expected and correct.
Net: for SteamOS hosts the whole feature already works today (client capture → gadget Deck → Steam Input). The remaining work is the non-SteamOS host (usbip) + the leave-shortcut + polish.
Architecture decision: usbip/vhci_hcd is the shippable universal transport
Presenting a real interface-2 USB Deck on a generic Linux host is the only gap. Decision matrix:
| Mechanism | Ships? | Why |
|---|---|---|
dummy_hcd + raw_gadget |
SteamOS only | In-tree on SteamOS (used + validated). Not built on Bazzite/Fedora (CONFIG_USB_DUMMY_HCD/RAW_GADGET unset); building them needs kernel-devel and MOK-signing under Secure Boot → not shippable. |
usbip + vhci_hcd |
everywhere | In-tree + signed on SteamOS, Bazzite, and ~every distro (it's the standard usbip stack). Loads under Secure Boot, no module build, no MOK. A userspace usbip server emulates the Deck; vhci_hcd attaches it locally. |
Both validated on hardware (2026-06-29):
raw_gadgetDeck on a real Steam Deck → Steam promotes it, glass-confirmed in-game.usbipDeck on Bazzite →usbip attach -r 127.0.0.1 -b 0-0-0→vhci_hcdenumerates the 3-interface Deck,hid-steambinds it, reads the serial, makes theSteam Deck/Motion Sensorsevdevs, stable (1 connect / 0 disconnect), and Steam logsInterface: 2 … opened for index … reserving XInput slot 1+ emits an X-Box pad. Identical recognition to the gadget.
The working PoC is checked in at packaging/linux/steam-deck-gadget/usbip-poc/ — the new session
should build on it. It uses the usbip crate (jiegec/usbip v0.8.0): a custom UsbInterfaceHandler
(get_class_specific_descriptor = the 9-byte HID descriptor; handle_urb = GET report-descriptor /
HID GET_REPORT=feature_reply / SET_REPORT / interrupt-IN = the 64-byte Deck state), reusing the
exact captured descriptors + feature contract from steam_gadget.rs.
Build steps (ordered)
1. Refactor steam_gadget.rs into shared Deck-logic + a transport trait
The descriptor set (mouse/kbd/controller report descriptors, the device/config assembly), the
feature_reply (0x83 attributes + 0xAE serial), and serialize_deck_state are transport-agnostic
and already proven. Extract them into a shared module (e.g. inject/proto/steam_proto.rs already holds
serialize_deck_state/feature_reply-equivalents; consolidate the gadget's feature_reply +
descriptors there or a new steam_device.rs). Define:
/// A virtual Deck transport: feed it the current 64-byte state, drain feedback.
trait DeckTransport {
fn write_state(&mut self, st: &SteamState);
fn service(&mut self) -> Option<(u16, u16)>; // rumble
}
Make the existing raw_gadget SteamDeckGadget implement it (it already has write_state/service).
2. Add the usbip transport (SteamDeckUsbip)
- Reuse the PoC's device definition + handler. Drive the interrupt-IN report from the shared
SteamState(aArc<Mutex<[u8;64]>>the handler reads), updated bywrite_state. - Dependency decision: the
usbipcrate hard-depends onrusb→libusb1-sys(for its host mode, which we don't use; it also breaksmusl). For a clean shippable host, vendor a trimmed copy of the crate (keeplib.rs,device.rs,interface.rs,endpoint.rs,setup.rs,usbip_protocol.rs,util.rs,consts.rs; drophost.rs/cdc.rs/hid.rs+ therusb/nusbdeps) under e.g.crates/punktfunk-host/vendor/usbip-sim/, or accept the libusb dep if vendoring is too much churn. Recommendation: vendor-trim (no libusb at runtime). - Runtime: the usbip server is tokio-based. Run it on a dedicated runtime/thread (the host already
uses tokio behind the
quicfeature). Keep it off the per-frame video path (input only — fine). - Local attach without the
usbipCLI (preferred): don't shell out tousbip attach(avoids an externalusbip-utilsruntime dep). Implement the client side in-process: connect to our own server (or better, asocketpair/unix socket to avoid a TCP port), do theOP_REQ_IMPORThandshake, then write"<port> <sockfd> <devid> <speed>"to/sys/devices/platform/vhci_hcd.0/attach. (Acceptable fallback for v1: depend on theusbipCLI, which is widely packaged, andCommand::new("usbip").args(["attach","-r","127.0.0.1","-b","0-0-0"]).) ensure_modules:modprobe vhci_hcd(best-effort) the way the gadget doesdummy_hcd raw_gadget.
3. Transport selection (in inject/linux/steam_controller.rs ensure() + gadget_preferred)
Extend the existing DeckTransport enum (currently Uhid | Gadget) to Uhid | Gadget | Usbip and the
selection ladder to: raw_gadget if /dev/raw-gadget usable (SteamOS) → else usbip if vhci_hcd
loadable (Bazzite/generic) → else UHID/DualSense. gadget_preferred() currently keys on
ID=steamos; generalize to "a recognized-by-Steam transport is available" (raw_gadget OR usbip).
Keep the M6 conflict gate (degrade_steam_on_conflict in punktfunk1.rs) ahead of all this — a host
with a physical Deck still degrades SteamDeck→DualSense, so two-Decks never happens in production.
4. Client leave-shortcut (clients/linux/src/)
Steam/QAM now pass through, so add an explicit disconnect:
- Keyboard: in
ui_stream.rs:300-310(next to theCtrl+Alt+Shift+Qcapture toggle) addCtrl+Alt+Shift+D→stop.store(true, …)(thestop_his already in scope),Propagation::Stop. - Controller: in
gamepad.rs(model onmaybe_fire_escapeat:354-362,ESCAPE_CHORDat:36) add a disconnect chord. Recommended: hold Start+Select+L1+R1 ≥ ~1.5 s (escalate the existing escape chord — short press leaves fullscreen, long-hold disconnects) OR a dedicated combo. Fire adisconnect_txconsumed inui_stream.rs(parallel to the escape future) → set the sessionstopflag (session.rs:73,212-214). Do not use Steam/QAM in the chord (they're the marquee pass-through buttons). Mirror the same to the other clients (windows/apple/android) later.
5. Polish
- Serial format: Steam flagged
PFDECK0000as an "Invalid or missing unit serial number" and substituted28de-1205-<hash>(benign, still promoted). Use a serial Steam accepts (a real Deck's is alphanumeric likeFVZZ4200469B); derive a per-instance valid-looking serial. The0xAEattr-1 reply + the0x83unit-id attrs (0x0a/0x04) should be consistent. - Verify the Decky/client "Disable Steam Input" path actually frees the Deck controller for SDL on the client (so Steam/QAM reach SDL). This is the one runtime precondition for capture.
6. Validation (glass-to-glass)
- Bazzite host (
bazzite@192.168.1.41): run the host with the usbip transport, connect the Linux client (a Deck or a machine with a Valve controller, Steam Input disabled), and confirm in game mode that the Steam button opens the Steam menu and the QAM "…" button opens Quick Access. - SteamOS host (
deck@192.168.1.253): confirmraw_gadgetstill selected + works (regression). - Confirm the leave-shortcut works from both controller and keyboard while Steam/QAM pass through.
Key findings / gotchas (so they aren't rediscovered)
- usbip PoC portability: the glibc build needs
GLIBC_2.34(Bazzite has 2.42) + libusb (present or vendored) → a dev-box glibc binary runs on Bazzite.muslfails (libusb1-sys). The server runs as an unprivileged user (TCP 3240); onlymodprobe vhci_hcd+ the attach need root. A systemd system service can't exec from/home(perms) — run the server as the user. - raw_gadget gotchas (already solved, see
steam-controller-deck-support.md§11): 7-vs-9-byte endpoint descriptor; no-data OUT controls acked via zero-lengthEP0_READ; no-arg ioctls must pass an explicit0(musl);libc::ioctlrequest isc_ulong/c_intper libc →as _. - Feature contract is what stops the gamepad-evdev churn (Steam re-probing): serve the captured
0x83 GET_ATTRIBUTESblob +0xAEserial (packaging/linux/steam-deck-gadget/get_deck_attrs.ccaptures them from a physical Deck via hidrawHIDIOCGFEATURE; usbmon truncates to 32B). This is already insteam_gadget.rs::feature_replyand the usbip PoC. - Captured descriptors (verbatim from a physical Deck) live in
steam_gadget.rs+ the usbip PoC: mouse (65B), keyboard (39B), controller (38B, Usage Page0xFFFF), endpoints0x81/0x82/0x83, controllerbCountryCode 33.
Hardware + recipes
- Deck (SteamOS)
ssh deck@192.168.1.253— hasdummy_hcd+raw_gadget+vhci_hcd+usbip; a physical Deck controller (so it degrades to DualSense by the M6 gate — for raw_gadget testing there, de-authorize the physical Deck via/sys/bus/usb/devices/3-3/authorized). Nogcc. - Bazzite
ssh bazzite@192.168.1.41—vhci_hcd+usbip(signed, in-tree), no dummy_hcd; Secure Boot on;gcc+kernel-develpresent; Steam runs. This is the usbip test bed. - Both need passwordless sudo for driving (
/etc/sudoers.d/zz-punktfunk-poc— remove when done). SSH via-o BatchMode=yes. Nogccon the Deck → build static/glibc on the dev box +scp. - usbip quick test (Bazzite):
sudo modprobe vhci_hcd; ./usbip-deck-poc pressa & ; sudo usbip attach -r 127.0.0.1 -b 0-0-0then watchdmesg+~/.local/share/Steam/logs/controller.txtforInterface: 2 … reserving XInput slot.
Open decisions for the new session
- Vendor-trim the
usbipcrate (no libusb) vs. accept therusb/libusb dep. Recommend trim. - In-process vhci attach (write the sysfs) vs. shell out to the
usbipCLI. Recommend in-process for v1-ship (no external CLI dep); CLI is the quick path to a working build first. - Controller leave-chord: escalate the escape chord (long-hold) vs. a dedicated combo.
- Whether to unify on usbip everywhere (it works on SteamOS too) and retire
raw_gadget, vs. keepraw_gadgetfor SteamOS (already validated). Recommend keep both behind the trait — usbip is the universal fallback, raw_gadget the validated SteamOS fast-path.
Commit trail (this work, all on main, NOT pushed)
faea4f1…a33c7d3 (M0–M6) · b6b6f27 (raw_gadget Deck) · 9e5112b (feature contract) ·
b3bc313 (host backend) · 8c3188d (glass-confirmed + default-on SteamOS). The usbip PoC +
this plan are the next commits.