feat(host/steam): raw_gadget Deck host backend (Steam-Input path, opt-in)

Port the proven raw_gadget virtual Deck to a Rust host gamepad backend, the
SteamOS-only transport that gets Steam Input to actually promote the Deck.

- inject/linux/steam_gadget.rs (new): SteamDeckGadget — a userspace raw_gadget
  emulator of the real 3-interface USB Deck (mouse=0/keyboard=1/controller=2,
  28DE:1205) on a dummy_hcd loopback UDC, descriptors captured from a physical
  Deck, answering every control transfer incl. the HID feature reports. Driven by
  the same steam_proto::serialize_deck_state as the UHID pad; rumble feedback via
  parse_steam_output. The raw_gadget UAPI is funneled through 4 documented ioctl
  wrappers (the crate denies undocumented unsafe).
- inject/linux/steam_controller.rs: the manager pad is now a DeckTransport enum
  (Uhid | Gadget); ensure() prefers the gadget when PUNKTFUNK_STEAM_GADGET=1
  (best-effort modprobe dummy_hcd+raw_gadget), gracefully falling back to the
  universal UHID SteamDeckPad. write/pump/heartbeat dispatch through the enum.

Validated on a real Deck via a static musl harness that #[path]-includes the
module: enumerates, hid-steam binds + reads our serial + creates the Steam Deck +
Motion Sensors evdevs — identical to the C PoC. Caught a real portability bug:
raw_gadget's no-arg ioctls (RUN/CONFIGURE/EP0_STALL) reject a non-zero `value`
with EINVAL, and on musl an omitted ioctl vararg is a garbage register — so they
must pass an explicit 0.

Opt-in (default off) while the Steam GetControllerInfo feature contract is
hardened (to stop the gamepad-evdev churn). Workspace clippy/fmt/test green. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 15:39:35 +00:00
parent 78020cd66c
commit c8e19396e4
4 changed files with 660 additions and 11 deletions
+6
View File
@@ -495,6 +495,12 @@ mod gamepad_raii;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"] #[path = "inject/linux/steam_controller.rs"]
pub mod steam_controller; 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 /// 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`]). /// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -239,8 +239,39 @@ impl Drop for SteamDeckPad {
/// Button/stick frames arrive via [`handle`](Self::handle); the right trackpad + motion via /// 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 /// [`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). /// 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 { pub struct SteamControllerManager {
pads: Vec<Option<SteamDeckPad>>, pads: Vec<Option<DeckTransport>>,
state: Vec<SteamState>, state: Vec<SteamState>,
last_rumble: Vec<(u16, u16)>, last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>, last_write: Vec<Instant>,
@@ -329,7 +360,7 @@ impl SteamControllerManager {
fn write(&mut self, idx: usize) { fn write(&mut self, idx: usize) {
let st = self.state[idx]; let st = self.state[idx];
if let Some(pad) = self.pads[idx].as_mut() { if let Some(pad) = self.pads[idx].as_mut() {
let _ = pad.write_state(&st); pad.write_state(&st);
} }
self.last_write[idx] = Instant::now(); self.last_write[idx] = Instant::now();
} }
@@ -353,10 +384,32 @@ impl SteamControllerManager {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return; return;
} }
match SteamDeckPad::open(idx as u8) { // Prefer the USB gadget on SteamOS (the only transport Steam Input promotes); fall back to the
Ok(p) => { // universal UHID pad if the gadget is unavailable or not opted in.
tracing::info!(index = idx, "virtual Steam Deck created (UHID hid-steam)"); let opened = if crate::inject::steam_gadget::gadget_preferred() {
self.pads[idx] = Some(p); 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.state[idx] = SteamState::neutral();
self.last_rumble[idx] = (0, 0); self.last_rumble[idx] = (0, 0);
self.last_write[idx] = Instant::now(); self.last_write[idx] = Instant::now();
@@ -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::<UsbRawInit>());
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::<UsbEndpointDescriptor>());
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<u8> {
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<u8> {
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<T>(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<T>(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<u8>) {
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<Mutex<[u8; 64]>>,
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
running: Arc<AtomicBool>,
threads: Vec<JoinHandle<()>>,
_fd: Arc<GadgetFd>,
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<SteamDeckGadget> {
// 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<GadgetFd>,
running: Arc<AtomicBool>,
ctrl_ep: Arc<std::sync::atomic::AtomicI32>,
configured: Arc<AtomicBool>,
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
serial: String,
) {
let raw = fd.0;
let cfg = build_config();
let mut last_set: Vec<u8> = 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<u8>,
feedback: &Mutex<super::steam_proto::SteamFeedback>,
) {
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<u8> = 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<u8> {
// 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<u8> {
// 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<GadgetFd>,
running: Arc<AtomicBool>,
ctrl_ep: Arc<std::sync::atomic::AtomicI32>,
configured: Arc<AtomicBool>,
report: Arc<Mutex<[u8; 64]>>,
) {
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)
}
+20 -5
View File
@@ -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. `EP_WRITE` starves the control path.
- `dummy_hcd` + `raw_gadget` must both be loaded and `/dev/raw-gadget` present before launch. - `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 The C PoC's transport is ported to a Rust host gamepad backend:
produces correct Deck reports) through the interface-2 endpoint, and wrap this as a host gamepad `crates/punktfunk-host/src/inject/linux/steam_gadget.rs` (`SteamDeckGadget`), driven by the same
backend (a `raw_gadget` alternative to the UHID `SteamDeckPad`) — SteamOS-host only, since it needs `steam_proto` serializer as the UHID `SteamDeckPad`. The Steam-Deck manager
`dummy_hcd` + `raw_gadget`. (`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.