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 00000000..96ef6c0b --- /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 00000000..fe921344 --- /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 00000000..b86fdc5b --- /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 00000000..efe0d869 --- /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; +}