From 4f0b4aa68f01e458f157e51c74fe65a275943e8d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 29 Jun 2026 17:29:32 +0000 Subject: [PATCH] docs(steam): production plan for Deck client pass-through + shippable usbip host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write design/steam-deck-passthrough-plan.md — the build plan to ship exact Steam Deck pass-through from the Linux client (incl. the Steam + QAM buttons) plus a virtual Deck on any Linux host. Key validated facts captured so the next session doesn't re-investigate: - Client capture is ALREADY correct: SDL3 maps Steam->Guide, QAM->Misc1; the client forwards BTN_GUIDE/BTN_MISC1; the host maps them to btn::STEAM/btn::QAM. Only precondition: Steam Input disabled on the client (the Decky UX). - Shippable host transport = usbip + vhci_hcd (in-tree + signed everywhere, no module build, no MOK) — PROVEN on Bazzite: Steam promotes the usbip interface-2 Deck (XInput slot + X-Box pad), identical to raw_gadget on SteamOS. - Build steps: refactor steam_gadget.rs into shared Deck-logic + a transport trait; add the usbip transport (vendor-trim the usbip crate to drop rusb/libusb, in-process vhci attach); transport-select raw_gadget->usbip->UHID/DualSense; client leave-shortcut (controller chord + Ctrl+Alt+Shift+D); serial polish. Also checks in the working usbip Deck PoC (packaging/linux/steam-deck-gadget/ usbip-poc/) for the next session to build on. Not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) --- design/steam-deck-passthrough-plan.md | 184 ++++++++++++++++++ .../steam-deck-gadget/usbip-poc/.gitignore | 2 + .../steam-deck-gadget/usbip-poc/Cargo.toml | 16 ++ .../steam-deck-gadget/usbip-poc/README.md | 25 +++ .../steam-deck-gadget/usbip-poc/src/main.rs | 179 +++++++++++++++++ 5 files changed, 406 insertions(+) create mode 100644 design/steam-deck-passthrough-plan.md create mode 100644 packaging/linux/steam-deck-gadget/usbip-poc/.gitignore create mode 100644 packaging/linux/steam-deck-gadget/usbip-poc/Cargo.toml create mode 100644 packaging/linux/steam-deck-gadget/usbip-poc/README.md create mode 100644 packaging/linux/steam-deck-gadget/usbip-poc/src/main.rs diff --git a/design/steam-deck-passthrough-plan.md b/design/steam-deck-passthrough-plan.md new file mode 100644 index 0000000..c57c2d1 --- /dev/null +++ b/design/steam-deck-passthrough-plan.md @@ -0,0 +1,184 @@ +# 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 +> ship it. 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 +> 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 +> 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 + +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→`misc1` in `SDL_gamepad_db.h:729`; confirmed in `SDL_gamepad.h`: + *"Steam Controller QAM"* = `MISC1`). Paddles → `RightPaddle1/2`,`LeftPaddle1/2`; trackpad clicks → + `Touchpad`/`Misc2`. +- `clients/linux/src/gamepad.rs:173-201` `button_bit()` already maps `Guide → 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 individual + `InputEvent` (0xC8) events (`code`=bit, `x`=1/0); rich input (touchpad/gyro) on `0xCC`. +- Host `steam_proto::from_gamepad` (`crates/punktfunk-host/src/inject/proto/steam_proto.rs:179-233`) + already maps **`BTN_GUIDE → btn::STEAM`** (line 214) and **`BTN_MISC1 → btn::QAM`** (line 230). The + `btn` module: `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=1` is already set in `gamepad.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 + **drop `BTN_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_gadget` Deck on a real Steam Deck → Steam promotes it, glass-confirmed in-game. +- `usbip` Deck on **Bazzite** → `usbip attach -r 127.0.0.1 -b 0-0-0` → `vhci_hcd` enumerates the + 3-interface Deck, `hid-steam` binds it, reads the serial, makes the `Steam Deck`/`Motion Sensors` + evdevs, **stable (1 connect / 0 disconnect)**, and Steam logs `Interface: 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: + +```rust +/// 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` (a `Arc>` the handler reads), updated by `write_state`. +- **Dependency decision:** the `usbip` crate hard-depends on `rusb`→`libusb1-sys` (for its *host* + mode, which we don't use; it also breaks `musl`). For a clean shippable host, **vendor a trimmed + copy** of the crate (keep `lib.rs`, `device.rs`, `interface.rs`, `endpoint.rs`, `setup.rs`, + `usbip_protocol.rs`, `util.rs`, `consts.rs`; drop `host.rs`/`cdc.rs`/`hid.rs` + the `rusb`/`nusb` + deps) 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 `quic` feature). Keep it off the per-frame video path (input only — fine). +- **Local attach without the `usbip` CLI (preferred):** don't shell out to `usbip attach` + (avoids an external `usbip-utils` runtime dep). Implement the client side in-process: connect to our + own server (or better, a `socketpair`/unix socket to avoid a TCP port), do the `OP_REQ_IMPORT` + handshake, then write `" "` to + `/sys/devices/platform/vhci_hcd.0/attach`. (Acceptable fallback for v1: depend on the `usbip` CLI, + which is widely packaged, and `Command::new("usbip").args(["attach","-r","127.0.0.1","-b","0-0-0"])`.) +- **`ensure_modules`:** `modprobe vhci_hcd` (best-effort) the way the gadget does `dummy_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 the `Ctrl+Alt+Shift+Q` capture toggle) add + `Ctrl+Alt+Shift+D` → `stop.store(true, …)` (the `stop_h` is already in scope), `Propagation::Stop`. +- **Controller:** in `gamepad.rs` (model on `maybe_fire_escape` at `:354-362`, `ESCAPE_CHORD` at `: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 a + `disconnect_tx` consumed in `ui_stream.rs` (parallel to the escape future) → set the session `stop` + flag (`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 `PFDECK0000` as an "Invalid or missing unit serial number" and + substituted `28de-1205-` (benign, still promoted). Use a serial Steam accepts (a real Deck's + is alphanumeric like `FVZZ4200469B`); derive a per-instance valid-looking serial. The `0xAE` + attr-1 reply + the `0x83` unit-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`): confirm `raw_gadget` still 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. `musl` fails (libusb1-sys). The server runs + as an unprivileged **user** (TCP 3240); only `modprobe 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-length `EP0_READ`; no-arg ioctls must pass + an explicit `0` (musl); `libc::ioctl` request is `c_ulong`/`c_int` per libc → `as _`. +- **Feature contract** is what stops the gamepad-evdev churn (Steam re-probing): serve the captured + `0x83 GET_ATTRIBUTES` blob + `0xAE` serial (`packaging/linux/steam-deck-gadget/get_deck_attrs.c` + captures them from a physical Deck via hidraw `HIDIOCGFEATURE`; usbmon truncates to 32B). This is + already in `steam_gadget.rs::feature_reply` and 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 Page `0xFFFF`), endpoints `0x81/0x82/0x83`, + controller `bCountryCode 33`. + +## Hardware + recipes + +- **Deck (SteamOS)** `ssh deck@192.168.1.253` — has `dummy_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`). No `gcc`. +- **Bazzite** `ssh bazzite@192.168.1.41` — `vhci_hcd`+`usbip` (signed, in-tree), **no** dummy_hcd; + Secure Boot **on**; `gcc`+`kernel-devel` present; 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`. No `gcc` on 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-0` then watch `dmesg` + `~/.local/share/Steam/logs/controller.txt` for + `Interface: 2 … reserving XInput slot`. + +## Open decisions for the new session + +1. **Vendor-trim the `usbip` crate (no libusb) vs. accept the `rusb`/libusb dep.** Recommend trim. +2. **In-process vhci attach (write the sysfs) vs. shell out to the `usbip` CLI.** Recommend in-process + for v1-ship (no external CLI dep); CLI is the quick path to a working build first. +3. **Controller leave-chord**: escalate the escape chord (long-hold) vs. a dedicated combo. +4. Whether to **unify on usbip everywhere** (it works on SteamOS too) and retire `raw_gadget`, vs. + keep `raw_gadget` for 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. diff --git a/packaging/linux/steam-deck-gadget/usbip-poc/.gitignore b/packaging/linux/steam-deck-gadget/usbip-poc/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/packaging/linux/steam-deck-gadget/usbip-poc/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/packaging/linux/steam-deck-gadget/usbip-poc/Cargo.toml b/packaging/linux/steam-deck-gadget/usbip-poc/Cargo.toml new file mode 100644 index 0000000..fe92134 --- /dev/null +++ b/packaging/linux/steam-deck-gadget/usbip-poc/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "usbip-deck-poc" +version = "0.0.0" +edition = "2021" + +[[bin]] +name = "usbip-deck-poc" +path = "src/main.rs" + +[dependencies] +usbip = "*" +tokio = { version = "1", features = ["full"] } +env_logger = "0.11" + +[profile.release] +opt-level = 2 diff --git a/packaging/linux/steam-deck-gadget/usbip-poc/README.md b/packaging/linux/steam-deck-gadget/usbip-poc/README.md new file mode 100644 index 0000000..b86fdc5 --- /dev/null +++ b/packaging/linux/steam-deck-gadget/usbip-poc/README.md @@ -0,0 +1,25 @@ +# usbip Deck PoC — the shippable virtual Deck for non-SteamOS Linux hosts + +Presents a real 3-interface USB Steam Deck (`28DE:1205`, controller on **interface 2**) over the +usbip protocol via the `usbip` crate, so `vhci_hcd` can attach it **locally** — the Secure-Boot-clean, +universal alternative to `dummy_hcd`+`raw_gadget` (which Bazzite/Fedora don't ship). Reuses the exact +captured descriptors + feature contract from `crates/punktfunk-host/src/inject/linux/steam_gadget.rs`. + +**Validated live on Bazzite (2026-06-29):** `vhci_hcd` enumerates it, `hid-steam` binds it + reads the +serial + makes the `Steam Deck` evdevs, stable (1 connect / 0 disconnect), and **Steam promotes it** +(`Interface: 2 … reserving XInput slot 1`, X-Box pad emitted) — identical to the gadget on SteamOS. + +```sh +cargo build --release # glibc; needs GLIBC_2.34 (Bazzite has 2.42), libusb present/vendored +# on the host (root for the last two): +sudo modprobe vhci_hcd +./usbip-deck-poc pressa & # the usbip server (runs as a normal user, TCP 127.0.0.1:3240) +sudo usbip attach -r 127.0.0.1 -b 0-0-0 +``` + +See `design/steam-deck-passthrough-plan.md` for the production build plan (vendor-trim the crate to +drop the `rusb`/libusb dep; in-process `vhci_hcd` attach to avoid the `usbip` CLI; transport-select +`raw_gadget`→`usbip`→UHID). `usbip` crate API: a custom `UsbInterfaceHandler` — +`get_class_specific_descriptor()` = the 9-byte HID descriptor; `handle_urb()` dispatches EP0 +`GET_DESCRIPTOR`(report) / HID `GET_REPORT`(=`feature_reply`) / `SET_REPORT`, and returns the 64-byte +state report on the interrupt-IN endpoint. diff --git a/packaging/linux/steam-deck-gadget/usbip-poc/src/main.rs b/packaging/linux/steam-deck-gadget/usbip-poc/src/main.rs new file mode 100644 index 0000000..efe0d86 --- /dev/null +++ b/packaging/linux/steam-deck-gadget/usbip-poc/src/main.rs @@ -0,0 +1,179 @@ +// usbip Deck PoC: present a real 3-interface USB Steam Deck (28DE:1205, controller on interface 2) +// over the usbip protocol via the `usbip` crate, so vhci_hcd can attach it LOCALLY — the Secure-Boot- +// clean, universal alternative to dummy_hcd+raw_gadget. Validates that Steam recognizes a usbip- +// presented interface-2 Deck (on Bazzite, where dummy_hcd isn't available). +// +// Run as root with vhci_hcd loaded, then locally: usbip attach -r 127.0.0.1 -b 0-0-0 +use std::any::Any; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::{Arc, Mutex}; +use usbip::{Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbIpServer}; + +// ---- captured-from-hardware report descriptors (a real Steam Deck) ---- +const RDESC_MOUSE: &[u8] = &[ + 0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02, + 0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01, + 0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06, + 0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0]; +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]; +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]; + +fn hid_desc(report_len: usize, country: u8) -> Vec { + let l = report_len as u16; + vec![0x09, 0x21, 0x10, 0x01, country, 1, 0x22, (l & 0xff) as u8, (l >> 8) as u8] +} + +/// Captured-from-hardware feature replies (the contract Steam's GetControllerInfo reads). +fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> Vec { + let cmd = last_set.first().copied().unwrap_or(0xAE); + let mut r = vec![0u8; 64]; + match cmd { + 0x83 => { + r[0] = 0x83; + r[1] = 0x2d; + let attrs: [(u8, u32); 9] = [ + (0x01, 0x1205), (0x02, 0), (0x0a, unit_id), (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 => { + 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]); + } + _ => { + let n = last_set.len().min(64); + r[..n].copy_from_slice(&last_set[..n]); + } + } + r +} + +/// The Deck controller interface (vendor HID): answers feature reports + streams the 64-byte state. +#[derive(Debug)] +struct ControllerHandler { + report_desc: Vec, + last_set: Vec, + seq: u32, + press_a: bool, +} +impl UsbInterfaceHandler for ControllerHandler { + fn get_class_specific_descriptor(&self) -> Vec { + hid_desc(self.report_desc.len(), 33) + } + fn handle_urb( + &mut self, + _interface: &UsbInterface, + ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + req: &[u8], + ) -> std::io::Result> { + if ep.is_ep0() { + Ok(match (setup.request_type, setup.request) { + (0x81, 0x06) if (setup.value >> 8) == 0x22 => self.report_desc.clone(), // GET report descriptor + (0xA1, 0x01) => feature_reply(&self.last_set, "PFDECK0000", 0x5046_0000), // HID GET_REPORT (feature) + (0x21, 0x09) => { + self.last_set = req.to_vec(); + vec![] + } // HID SET_REPORT + (0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL + _ => vec![], + }) + } else if let Direction::In = ep.direction() { + self.seq = self.seq.wrapping_add(1); + let mut r = vec![0u8; 64]; + r[0] = 0x01; + r[2] = 0x09; + r[3] = 0x3c; + r[4..8].copy_from_slice(&self.seq.to_le_bytes()); + if self.press_a { + r[8] = 0x80; // btn::A + } + Ok(r) + } else { + Ok(vec![]) + } + } + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +/// A minimal HID interface (mouse/keyboard) — serves its report descriptor, sends nothing. +#[derive(Debug)] +struct IdleHidHandler { + report_desc: Vec, +} +impl UsbInterfaceHandler for IdleHidHandler { + fn get_class_specific_descriptor(&self) -> Vec { + 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> { + 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 handler(h: impl UsbInterfaceHandler + Send + 'static) -> Arc>> { + Arc::new(Mutex::new(Box::new(h))) +} +fn ep(addr: u8, mps: u16) -> UsbEndpoint { + UsbEndpoint { address: addr, attributes: 0x03, max_packet_size: mps, interval: 4 } +} + +#[tokio::main] +async fn main() { + env_logger::init(); + let press_a = std::env::args().any(|a| a == "pressa"); + + let mut dev = UsbDevice::new(0); + dev.vendor_id = 0x28DE; + dev.product_id = 0x1205; + dev.set_manufacturer_name("Valve Software"); + dev.set_product_name("Steam Deck Controller"); + dev.set_serial_number("PFDECK0000"); + + // interface 0: mouse, interface 1: keyboard, interface 2: the controller. + let dev = dev + .with_interface(0x03, 0x00, 0x02, Some("mouse"), vec![ep(0x81, 8)], + handler(IdleHidHandler { report_desc: RDESC_MOUSE.to_vec() })) + .with_interface(0x03, 0x01, 0x01, Some("keyboard"), vec![ep(0x82, 8)], + handler(IdleHidHandler { report_desc: RDESC_KBD.to_vec() })) + .with_interface(0x03, 0x00, 0x00, Some("controller"), vec![ep(0x83, 64)], + handler(ControllerHandler { report_desc: RDESC_CTRL.to_vec(), last_set: vec![], seq: 0, press_a })); + + let server = Arc::new(UsbIpServer::new_simulated(vec![dev])); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 3240); + println!("usbip Deck server on {addr} (press_a={press_a}); attach with: usbip attach -r 127.0.0.1 -b 0-0-0"); + usbip::server(addr, server).await; +}