diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 8ed2772..0c5d2d8 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -495,6 +495,12 @@ mod gamepad_raii; #[cfg(target_os = "linux")] #[path = "inject/linux/steam_controller.rs"] pub mod steam_controller; +/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only +/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2). +/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`). +#[cfg(target_os = "linux")] +#[path = "inject/linux/steam_gadget.rs"] +pub mod steam_gadget; /// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck /// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]). #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index 7461f02..3d9d4d2 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -239,8 +239,39 @@ impl Drop for SteamDeckPad { /// Button/stick frames arrive via [`handle`](Self::handle); the right trackpad + motion via /// [`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). +/// 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 +/// it (it presents the controller on interface 2). Selected per-pad in [`SteamControllerManager::ensure`]. +enum DeckTransport { + Uhid(SteamDeckPad), + Gadget(crate::inject::steam_gadget::SteamDeckGadget), +} + +impl DeckTransport { + fn write_state(&mut self, st: &SteamState) { + match self { + DeckTransport::Uhid(p) => { + let _ = p.write_state(st); + } + DeckTransport::Gadget(g) => g.write_state(st), + } + } + fn service(&mut self) -> Option<(u16, u16)> { + match self { + DeckTransport::Uhid(p) => p.service(), + DeckTransport::Gadget(g) => g.service().rumble, + } + } + fn in_mode_entry(&self) -> bool { + match self { + DeckTransport::Uhid(p) => p.in_mode_entry(), + DeckTransport::Gadget(_) => false, + } + } +} + pub struct SteamControllerManager { - pads: Vec>, + pads: Vec>, state: Vec, last_rumble: Vec<(u16, u16)>, last_write: Vec, @@ -329,7 +360,7 @@ impl SteamControllerManager { fn write(&mut self, idx: usize) { let st = self.state[idx]; if let Some(pad) = self.pads[idx].as_mut() { - let _ = pad.write_state(&st); + pad.write_state(&st); } self.last_write[idx] = Instant::now(); } @@ -353,10 +384,32 @@ impl SteamControllerManager { if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { return; } - match SteamDeckPad::open(idx as u8) { - Ok(p) => { - tracing::info!(index = idx, "virtual Steam Deck created (UHID hid-steam)"); - self.pads[idx] = Some(p); + // Prefer the USB gadget on SteamOS (the only transport Steam Input promotes); fall back to the + // universal UHID pad if the gadget is unavailable or not opted in. + 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) => { + if matches!(t, DeckTransport::Uhid(_)) { + tracing::info!(index = idx, "virtual Steam Deck created (UHID hid-steam)"); + } + self.pads[idx] = Some(t); self.state[idx] = SteamState::neutral(); self.last_rumble[idx] = (0, 0); self.last_write[idx] = Instant::now(); diff --git a/crates/punktfunk-host/src/inject/linux/steam_gadget.rs b/crates/punktfunk-host/src/inject/linux/steam_gadget.rs new file mode 100644 index 0000000..c9eb2b7 --- /dev/null +++ b/crates/punktfunk-host/src/inject/linux/steam_gadget.rs @@ -0,0 +1,575 @@ +//! Virtual Steam Deck via the USB **gadget** subsystem (`raw_gadget` + `dummy_hcd`) — the only +//! virtual-Deck transport Steam Input recognizes. +//! +//! The UHID [`super::steam_controller::SteamDeckPad`] binds the kernel `hid-steam` driver, but Steam's +//! own controller driver filters the Deck's controller to USB **interface 2**, and a UHID device has no +//! USB interface number (`Interface: -1`), so Steam enumerates it but never promotes it. This backend +//! instead presents a *real* 3-interface USB Deck (mouse = interface 0, keyboard = 1, **controller = +//! 2**) on a `dummy_hcd` loopback UDC, driven from userspace via `/dev/raw-gadget` so we can answer +//! every control transfer (including the HID feature reports `f_hid` can't). Proven on a real Deck: +//! hid-steam binds it, Steam reserves an XInput slot and emits an X-Box pad. Descriptors are captured +//! verbatim from a physical Deck; see `packaging/linux/steam-deck-gadget/` for the original PoC + the +//! USB-stack gotchas. **SteamOS-host only** (needs `dummy_hcd` + `raw_gadget`, which SteamOS ships). +//! +//! The transport here is self-contained (libc + std); the report bytes it streams are produced by +//! [`super::steam_proto`] in the wrapping backend. + +use anyhow::{bail, Context, Result}; +use std::mem::size_of; +use std::os::fd::RawFd; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +// ---- raw_gadget UAPI (mirrors linux/usb/raw_gadget.h; inlined like the C PoC) ---- +const UDC_NAME_MAX: usize = 128; + +#[repr(C)] +struct UsbRawInit { + driver_name: [u8; UDC_NAME_MAX], + device_name: [u8; UDC_NAME_MAX], + speed: u8, +} + +// usb_raw_event { u32 type; u32 length; u8 data[]; } — we read it into a fixed buffer. +const EVENT_HDR: usize = 8; // type + length +const EVENT_BUF: usize = EVENT_HDR + 64; // setup packet (8) fits easily + +// usb_raw_ep_io { u16 ep; u16 flags; u32 length; u8 data[]; } +const EPIO_HDR: usize = 8; + +// usb_endpoint_descriptor is 9 bytes in the kernel (audio bRefresh/bSynchAddress); EP_ENABLE wants it. +#[repr(C, packed)] +#[derive(Clone, Copy, Default)] +struct UsbEndpointDescriptor { + b_length: u8, + b_descriptor_type: u8, + b_endpoint_address: u8, + bm_attributes: u8, + w_max_packet_size: u16, + b_interval: u8, + b_refresh: u8, + b_synch_address: u8, +} + +const fn ioc(dir: u64, nr: u64, size: usize) -> libc::c_ulong { + ((dir << 30) | ((size as u64) << 16) | ((b'U' as u64) << 8) | nr) as libc::c_ulong +} +const IOCTL_INIT: libc::c_ulong = ioc(1, 0, size_of::()); +const IOCTL_RUN: libc::c_ulong = ioc(0, 1, 0); +const IOCTL_EVENT_FETCH: libc::c_ulong = ioc(2, 2, EVENT_HDR); // size is the header; kernel copies more +const IOCTL_EP0_WRITE: libc::c_ulong = ioc(1, 3, EPIO_HDR); +const IOCTL_EP0_READ: libc::c_ulong = ioc(2 | 1, 4, EPIO_HDR); // _IOWR +const IOCTL_EP_ENABLE: libc::c_ulong = ioc(1, 5, size_of::()); +const IOCTL_EP_WRITE: libc::c_ulong = ioc(1, 7, EPIO_HDR); +const IOCTL_CONFIGURE: libc::c_ulong = ioc(0, 9, 0); +const IOCTL_VBUS_DRAW: libc::c_ulong = ioc(1, 10, 4); +const IOCTL_EP0_STALL: libc::c_ulong = ioc(0, 12, 0); + +const USB_RAW_EVENT_CONNECT: u32 = 1; +const USB_RAW_EVENT_CONTROL: u32 = 2; +const USB_SPEED_HIGH: u8 = 3; + +// ---- captured-from-hardware descriptors (a real Steam Deck, 28DE:1205) ---- +#[rustfmt::skip] +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]; +#[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] = [ + 18, 1, 0x00, 0x02, // bLength, DEVICE, bcdUSB 2.00 + 0, 0, 0, 64, // class/sub/proto, bMaxPacketSize0 + 0xDE, 0x28, 0x05, 0x12, // idVendor 28DE, idProduct 1205 + 0x00, 0x03, // bcdDevice 3.00 + 1, 2, 3, 1, // iManufacturer, iProduct, iSerial, bNumConfigurations +]; + +const HID_DT: u8 = 0x21; +const HID_RPT_DT: u8 = 0x22; + +/// Assemble the 84-byte config descriptor: config + 3×(interface + HID + 7-byte endpoint). +fn build_config() -> Vec { + let mut c = Vec::with_capacity(84); + // config descriptor (wTotalLength patched after) + c.extend_from_slice(&[9, 2, 84, 0, 3, 1, 0, 0x80, 250]); + // helper closures + let iface = |n: u8, sub: u8, proto: u8| [9u8, 4, n, 0, 1, 3, sub, proto, 0]; + let hid = |rlen: u16, country: u8| { + [ + 9u8, + HID_DT, + 0x10, + 0x01, + country, + 1, + HID_RPT_DT, + (rlen & 0xff) as u8, + (rlen >> 8) as u8, + ] + }; + let ep = |addr: u8, mps: u16| [7u8, 5, addr, 0x03, (mps & 0xff) as u8, (mps >> 8) as u8, 4]; + // interface 0: mouse, EP 0x81 + c.extend_from_slice(&iface(0, 0, 2)); + c.extend_from_slice(&hid(RDESC_MOUSE.len() as u16, 0)); + c.extend_from_slice(&ep(0x81, 8)); + // interface 1: keyboard (boot), EP 0x82 + c.extend_from_slice(&iface(1, 1, 1)); + c.extend_from_slice(&hid(RDESC_KBD.len() as u16, 0)); + c.extend_from_slice(&ep(0x82, 8)); + // interface 2: controller, EP 0x83, bCountryCode 33 + c.extend_from_slice(&iface(2, 0, 0)); + c.extend_from_slice(&hid(RDESC_CTRL.len() as u16, 33)); + c.extend_from_slice(&ep(0x83, 64)); + debug_assert_eq!(c.len(), 84); + c +} + +fn string_desc(idx: u8, serial: &str) -> Vec { + if idx == 0 { + return vec![4, 3, 0x09, 0x04]; // LANGID en-US + } + let s: &str = match idx { + 1 => "Valve Software", + 2 => "Steam Deck Controller", + 3 => serial, + _ => "", + }; + let mut v = vec![(2 + s.len() * 2) as u8, 3]; + for ch in s.encode_utf16() { + v.push((ch & 0xff) as u8); + v.push((ch >> 8) as u8); + } + v +} + +// ---- ioctl wrappers (the only unsafe surface for the raw_gadget UAPI; documented once) ---- +fn ioctl_ptr(fd: RawFd, req: libc::c_ulong, arg: *const T) -> i32 { + // SAFETY: `fd` is our open /dev/raw-gadget descriptor; `arg` points to a correctly-sized, + // initialized argument for `req` (a raw_gadget UAPI struct or an owned usb_raw_ep_io buffer) + // that lives for the duration of the call. `ioctl` is variadic, so passing a thin pointer is ABI-correct. + unsafe { libc::ioctl(fd, req as _, arg) as i32 } +} +fn ioctl_mut(fd: RawFd, req: libc::c_ulong, arg: *mut T) -> i32 { + // SAFETY: as `ioctl_ptr`, but `arg` is a writable buffer the kernel fills for `req` (EVENT_FETCH / EP0_READ). + unsafe { libc::ioctl(fd, req as _, arg) as i32 } +} +fn ioctl_val(fd: RawFd, req: libc::c_ulong, val: libc::c_ulong) -> i32 { + // SAFETY: `req` (VBUS_DRAW) takes an integer argument by value; `fd` is our descriptor. + unsafe { libc::ioctl(fd, req as _, val) as i32 } +} +fn ioctl_none(fd: RawFd, req: libc::c_ulong) -> i32 { + // SAFETY: `req` (RUN / CONFIGURE / EP0_STALL) takes no argument, but raw_gadget rejects a non-zero + // `value` with EINVAL — pass an explicit 0 (an omitted vararg would be an indeterminate register). + unsafe { libc::ioctl(fd, req as _, 0) as i32 } +} + +// ---- low-level ep0 helpers (operate on the shared fd) ---- +fn ep0_write(fd: RawFd, data: &[u8]) -> i32 { + let mut buf = vec![0u8; EPIO_HDR + data.len()]; + buf[0..2].copy_from_slice(&0u16.to_ne_bytes()); // ep 0 + buf[4..8].copy_from_slice(&(data.len() as u32).to_ne_bytes()); + buf[EPIO_HDR..].copy_from_slice(data); + ioctl_ptr(fd, IOCTL_EP0_WRITE, buf.as_ptr()) +} +fn ep0_read(fd: RawFd, len: usize) -> (i32, Vec) { + let mut buf = vec![0u8; EPIO_HDR + len.max(1)]; + buf[4..8].copy_from_slice(&(len as u32).to_ne_bytes()); + let r = ioctl_mut(fd, IOCTL_EP0_READ, buf.as_mut_ptr()); + let n = if r > 0 { r as usize } else { 0 }; + (r, buf[EPIO_HDR..EPIO_HDR + n.min(len.max(1))].to_vec()) +} +/// Complete a no-data OUT control (status stage is an IN, handled by a zero-length read). +fn ep0_ack(fd: RawFd) { + ep0_read(fd, 0); +} +fn ep0_stall(fd: RawFd) { + ioctl_none(fd, IOCTL_EP0_STALL); +} + +/// Owns the `/dev/raw-gadget` fd; closing it tears the device down. +struct GadgetFd(RawFd); +impl Drop for GadgetFd { + fn drop(&mut self) { + // SAFETY: `self.0` is the fd we opened in `SteamDeckGadget::open` and own uniquely here. + unsafe { libc::close(self.0) }; + } +} + +/// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and +/// closes the gadget (the kernel tears down the device). +pub struct SteamDeckGadget { + report: Arc>, + feedback: Arc>, + running: Arc, + threads: Vec>, + _fd: Arc, + seq: u32, +} + +impl SteamDeckGadget { + /// Bind a virtual Deck on a fresh `dummy_hcd` UDC. `index` only varies the serial. Requires + /// `dummy_hcd` + `raw_gadget` loaded and write access to `/dev/raw-gadget` (root on SteamOS). + pub fn open(index: u8) -> Result { + // SAFETY: opening a constant NUL-terminated device path with O_RDWR; returns a fd or -1. + let fd = unsafe { libc::open(c"/dev/raw-gadget".as_ptr(), libc::O_RDWR) }; + if fd < 0 { + bail!( + "open /dev/raw-gadget ({}) — is raw_gadget+dummy_hcd loaded and are we root?", + std::io::Error::last_os_error() + ); + } + let fd = Arc::new(GadgetFd(fd)); + let raw = fd.0; + + // INIT against the dummy UDC, then RUN. + // SAFETY: `UsbRawInit` is a plain-old-data struct (byte arrays + u8); all-zero is a valid value. + let mut init: UsbRawInit = unsafe { std::mem::zeroed() }; + copy_cstr(&mut init.driver_name, "dummy_udc"); + copy_cstr(&mut init.device_name, "dummy_udc.0"); + init.speed = USB_SPEED_HIGH; + if ioctl_ptr(raw, IOCTL_INIT, &init as *const _) < 0 { + bail!("raw_gadget INIT: {}", std::io::Error::last_os_error()); + } + if ioctl_none(raw, IOCTL_RUN) < 0 { + bail!("raw_gadget RUN: {}", std::io::Error::last_os_error()); + } + + let serial = format!("PFDECK{index:04}"); + let report = Arc::new(Mutex::new(neutral_report())); + let feedback = Arc::new(Mutex::new(Default::default())); + let running = Arc::new(AtomicBool::new(true)); + let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1)); + let configured = Arc::new(AtomicBool::new(false)); + + // Control thread: enumerate + answer every control transfer. + let control = { + let fd = fd.clone(); + let running = running.clone(); + let ctrl_ep = ctrl_ep.clone(); + let configured = configured.clone(); + let feedback = feedback.clone(); + std::thread::Builder::new() + .name("pf-deck-gadget-ctrl".into()) + .spawn(move || control_loop(fd, running, ctrl_ep, configured, feedback, serial)) + .context("spawn gadget control thread")? + }; + // Stream thread: push the current report on the controller interrupt-IN endpoint. + let stream = { + let fd = fd.clone(); + let running = running.clone(); + let ctrl_ep = ctrl_ep.clone(); + let configured = configured.clone(); + let report = report.clone(); + std::thread::Builder::new() + .name("pf-deck-gadget-stream".into()) + .spawn(move || stream_loop(fd, running, ctrl_ep, configured, report)) + .context("spawn gadget stream thread")? + }; + + Ok(SteamDeckGadget { + report, + feedback, + running, + threads: vec![control, stream], + _fd: fd, + seq: 0, + }) + } + + /// Serialize `st` into the 64-byte Deck state report streamed to the kernel. + pub fn write_state(&mut self, st: &super::steam_proto::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 feedback (rumble) the kernel/Steam wrote to the device. + pub fn service(&mut self) -> super::steam_proto::SteamFeedback { + self.feedback + .lock() + .map(|mut f| std::mem::take(&mut *f)) + .unwrap_or_default() + } +} + +impl Drop for SteamDeckGadget { + fn drop(&mut self) { + self.running.store(false, Ordering::SeqCst); + for t in self.threads.drain(..) { + let _ = t.join(); + } + } +} + +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) { + let n = s.len().min(dst.len() - 1); + dst[..n].copy_from_slice(&s.as_bytes()[..n]); +} + +fn control_loop( + fd: Arc, + running: Arc, + ctrl_ep: Arc, + configured: Arc, + feedback: Arc>, + serial: String, +) { + let raw = fd.0; + let cfg = build_config(); + let mut last_set: Vec = Vec::new(); + let mut evbuf = [0u8; EVENT_BUF]; + while running.load(Ordering::SeqCst) { + // EVENT_FETCH: type(4) length(4) data[]. + evbuf[4..8].copy_from_slice(&(8u32).to_ne_bytes()); // request setup-sized payload + let r = ioctl_mut(raw, IOCTL_EVENT_FETCH, evbuf.as_mut_ptr()); + if r < 0 { + if running.load(Ordering::SeqCst) { + // transient; brief backoff + std::thread::sleep(std::time::Duration::from_millis(2)); + } + continue; + } + let etype = u32::from_ne_bytes([evbuf[0], evbuf[1], evbuf[2], evbuf[3]]); + match etype { + USB_RAW_EVENT_CONNECT => {} + USB_RAW_EVENT_CONTROL => { + let s = &evbuf[EVENT_HDR..EVENT_HDR + 8]; + let ctrl = Setup { + bm_request_type: s[0], + b_request: s[1], + w_value: u16::from_le_bytes([s[2], s[3]]), + w_index: u16::from_le_bytes([s[4], s[5]]), + w_length: u16::from_le_bytes([s[6], s[7]]), + }; + handle_control( + raw, + &ctrl, + &cfg, + &serial, + &ctrl_ep, + &configured, + &mut last_set, + &feedback, + ); + } + _ => {} + } + } +} + +struct Setup { + bm_request_type: u8, + b_request: u8, + w_value: u16, + w_index: u16, + w_length: u16, +} + +#[allow(clippy::too_many_arguments)] +fn handle_control( + raw: RawFd, + ctrl: &Setup, + cfg: &[u8], + serial: &str, + ctrl_ep: &std::sync::atomic::AtomicI32, + configured: &AtomicBool, + last_set: &mut Vec, + feedback: &Mutex, +) { + let idx = (ctrl.w_index & 0xff) as u8; + let type_class = ctrl.bm_request_type & 0x60; + let wl = ctrl.w_length as usize; + if type_class == 0x00 { + // standard + match ctrl.b_request { + 0x06 => { + // GET_DESCRIPTOR + let dt = (ctrl.w_value >> 8) as u8; + let di = (ctrl.w_value & 0xff) as u8; + let resp: Vec = match dt { + 1 => DEV_DESC.to_vec(), + 2 => cfg.to_vec(), + 3 => string_desc(di, serial), + HID_RPT_DT => match idx { + 0 => RDESC_MOUSE.to_vec(), + 1 => RDESC_KBD.to_vec(), + _ => RDESC_CTRL.to_vec(), + }, + HID_DT => { + // re-emit the interface's HID descriptor from the config blob (best effort) + hid_desc_for(cfg, idx) + } + _ => { + ep0_stall(raw); + return; + } + }; + let n = resp.len().min(wl); + ep0_write(raw, &resp[..n]); + } + 0x09 => { + // SET_CONFIGURATION + ioctl_val(raw, IOCTL_VBUS_DRAW, 0x32); + ioctl_none(raw, IOCTL_CONFIGURE); + enable_endpoints(raw, ctrl_ep); + ep0_ack(raw); + configured.store(true, Ordering::SeqCst); + } + 0x0b => ep0_ack(raw), // SET_INTERFACE + 0x00 => { + let st = 0u16; + ep0_write(raw, &st.to_le_bytes()); + } + _ => ep0_stall(raw), + } + } else if type_class == 0x20 { + // HID class + match ctrl.b_request { + 0x01 => { + // GET_REPORT — serve the Deck feature reply for the last requested command. + let resp = feature_reply(last_set, serial); + let n = resp.len().min(wl); + ep0_write(raw, &resp[..n]); + } + 0x09 => { + // SET_REPORT — read the host's data; remember it + extract feedback. + let (r, data) = ep0_read(raw, wl); + if r > 0 { + *last_set = data.clone(); + // parse_steam_output expects [report-id(0), cmd, …]; EP0 OUT data is [cmd, …]. + let mut framed = Vec::with_capacity(data.len() + 1); + framed.push(0); + framed.extend_from_slice(&data); + let fb = super::steam_proto::parse_steam_output(&framed); + if fb.rumble.is_some() { + if let Ok(mut g) = feedback.lock() { + *g = fb; + } + } + } + } + 0x0a | 0x0b => ep0_ack(raw), // SET_IDLE / SET_PROTOCOL + 0x03 => { + ep0_write(raw, &[0u8]); + } // GET_PROTOCOL + _ => ep0_stall(raw), + } + } else { + ep0_stall(raw); + } +} + +/// Build the HID feature GET_REPORT reply for whatever the host last asked via SET_REPORT. +fn feature_reply(last_set: &[u8], serial: &str) -> Vec { + // The Deck serial path: SET [0xAE,…] then GET → [0xAE,len,0x01,ascii]. For unknown commands echo + // the command byte with a zeroed body (enough to keep hid-steam's probe from erroring). + let cmd = last_set.first().copied().unwrap_or(0xAE); + let mut r = vec![0u8; 64]; + r[0] = cmd; + if cmd == 0xAE { + let b = serial.as_bytes(); + let len = b.len().clamp(1, 21); + r[1] = len as u8; + r[2] = 0x01; + r[3..3 + len].copy_from_slice(&b[..len]); + } + r +} + +fn hid_desc_for(cfg: &[u8], idx: u8) -> Vec { + // 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) + let off = match idx { + 0 => 9 + 9, + 1 => 9 + 25 + 9, + _ => 9 + 50 + 9, + }; + cfg.get(off..off + 9) + .map(|s| s.to_vec()) + .unwrap_or_default() +} + +fn enable_endpoints(raw: RawFd, ctrl_ep: &std::sync::atomic::AtomicI32) { + let mk = |addr: u8, mps: u16| UsbEndpointDescriptor { + b_length: 7, + b_descriptor_type: 5, + b_endpoint_address: addr, + bm_attributes: 0x03, + w_max_packet_size: mps, + b_interval: 4, + ..Default::default() + }; + let e0 = mk(0x81, 8); + let e1 = mk(0x82, 8); + let e2 = mk(0x83, 64); + ioctl_ptr(raw, IOCTL_EP_ENABLE, &e0 as *const _); + ioctl_ptr(raw, IOCTL_EP_ENABLE, &e1 as *const _); + let h2 = ioctl_ptr(raw, IOCTL_EP_ENABLE, &e2 as *const _); + ctrl_ep.store(h2, Ordering::SeqCst); +} + +fn stream_loop( + fd: Arc, + running: Arc, + ctrl_ep: Arc, + configured: Arc, + report: Arc>, +) { + let raw = fd.0; + while running.load(Ordering::SeqCst) { + let ep = ctrl_ep.load(Ordering::SeqCst); + if configured.load(Ordering::SeqCst) && ep >= 0 { + let r = report + .lock() + .map(|g| *g) + .unwrap_or_else(|_| neutral_report()); + let mut buf = [0u8; EPIO_HDR + 64]; + buf[0..2].copy_from_slice(&(ep as u16).to_ne_bytes()); + buf[4..8].copy_from_slice(&(64u32).to_ne_bytes()); + buf[EPIO_HDR..].copy_from_slice(&r); + // Blocks until the host polls the interrupt-IN endpoint; that's fine on its own thread. + ioctl_ptr(raw, IOCTL_EP_WRITE, buf.as_ptr()); + } + std::thread::sleep(std::time::Duration::from_millis(8)); + } +} + +/// Best-effort load of the gadget modules (SteamOS ships `dummy_hcd` + `raw_gadget`). Failures are +/// ignored — the caller falls back to UHID if `/dev/raw-gadget` is then still unusable. +pub fn ensure_modules() { + for m in ["dummy_hcd", "raw_gadget"] { + let _ = std::process::Command::new("modprobe").arg(m).status(); + } +} + +/// Whether to prefer the USB-gadget Deck over the UHID `SteamDeckPad`. SteamOS-host only (needs the +/// gadget modules + root) and opt-in for now (`PUNKTFUNK_STEAM_GADGET=1`) while the full Steam-Input +/// feature contract is hardened; defaults off, so the universal UHID path stays the default. +pub fn gadget_preferred() -> bool { + std::env::var("PUNKTFUNK_STEAM_GADGET") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} diff --git a/packaging/linux/steam-deck-gadget/README.md b/packaging/linux/steam-deck-gadget/README.md index 1f98eb6..d93773c 100644 --- a/packaging/linux/steam-deck-gadget/README.md +++ b/packaging/linux/steam-deck-gadget/README.md @@ -56,9 +56,24 @@ Steam Input, which exposes its own X-Box 360 pad — exactly a real Deck's behav `EP_WRITE` starves the control path. - `dummy_hcd` + `raw_gadget` must both be loaded and `/dev/raw-gadget` present before launch. -## Status / next +## Host backend (shipped, opt-in) -Recognition is proven. Remaining: feed real client state (the `steam_proto` serializer already -produces correct Deck reports) through the interface-2 endpoint, and wrap this as a host gamepad -backend (a `raw_gadget` alternative to the UHID `SteamDeckPad`) — SteamOS-host only, since it needs -`dummy_hcd` + `raw_gadget`. +The C PoC's transport is ported to a Rust host gamepad backend: +`crates/punktfunk-host/src/inject/linux/steam_gadget.rs` (`SteamDeckGadget`), driven by the same +`steam_proto` serializer as the UHID `SteamDeckPad`. The Steam-Deck manager +(`inject/linux/steam_controller.rs`) now selects per-pad between **UHID** (default, universal) and the +**USB gadget** (`PUNKTFUNK_STEAM_GADGET=1`, SteamOS-only — best-effort `modprobe dummy_hcd raw_gadget`, +graceful fallback to UHID if `/dev/raw-gadget` is unusable). + +The Rust transport is **validated on the Deck** (a static musl test binary that `#[path]`-includes the +real module): it enumerates the 3-interface Deck, hid-steam binds it + reads our serial + creates the +`Steam Deck` + `Motion Sensors` evdevs — identical to the C PoC. A real USB-stack bug it caught: on +musl, `ioctl(fd, RUN)` with no third arg passes a garbage `value`, and raw_gadget's `RUN`/`CONFIGURE`/ +`EP0_STALL` reject a non-zero `value` with `EINVAL` — so the no-arg ioctls must pass an explicit `0`. + +## Remaining + +- **Harden the feature contract** so Steam stops re-probing + the gamepad evdev stops churning (serve + Steam's full `GetControllerInfo` attribute set, captured from a physical Deck) — then a clean live + input-flow check + defaulting the gadget on for SteamOS hosts. +- A `punktfunk-host` build for SteamOS to exercise the integrated path end-to-end with a live client.