a53369bf21
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m11s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 7m0s
flatpak / build-publish (push) Successful in 6m25s
deb / build-publish (push) Successful in 9m53s
docker / deploy-docs (push) Successful in 28s
release / apple (push) Successful in 9m25s
deb / build-publish-host (push) Successful in 10m0s
windows-host / package (push) Successful in 15m34s
apple / screenshots (push) Successful in 6m30s
arch / build-publish (push) Successful in 18m15s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
android / android (push) Successful in 19m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m3s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m37s
ci / rust (push) Successful in 26m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m31s
`ci.yml`'s clippy gate has been failing on main since the pre-0.16.0 sweep landed, and because clippy stops at the first crate it can't compile, the visible error was only ever the first of four. `ci.yml` is not tag-triggered, so v0.16.0 cut and shipped over a red main; none of this reaches the release artifacts (the two E0308s are in a test binary and a dev tool, and the rest are lints) but the gate has been blind since. Fixed, in the order clippy surfaced them: - clients/probe failed to COMPILE (E0308, x2). `810d918d` moved `clock_sync` onto the resumable `io::MsgReader` but only updated the client pump, leaving the probe passing a bare `&mut RecvStream`. The probe now wraps the control stream in a `MsgReader` at `open_bi` and threads that everywhere — Welcome, the --remode and --bitrate watchers, and the speed-test result read — which is also what the refactor was for: those reads sit behind timeouts and `select!`, exactly where a straddling frame would desync the stream for the rest of the run. - pf-capture: `SPA_META_Cursor as u32` is a `u32 -> u32` no-op (`clippy::unnecessary_cast`). Line 1274 already passes the same constant uncast, so the type is not in question. - pf-inject: `noop as usize` on the SIGUSR1 wake handler added in `986402f7` trips `clippy::function_casts_as_integer`; goes via `*const ()` as the lint asks. Same value in the `usize`-typed `sa_sigaction` slot. - punktfunk-core: the `ctrl_framing` test module's `use super::*` is unused, which `-D warnings` promotes to an error. Verified with CI's own commands on a Linux box (this is all Linux-gated code, so a Mac cannot check it): `cargo clippy --workspace --all-targets --locked -- -D warnings` finishes clean, `cargo build --workspace --locked` succeeds, and `cargo test --workspace --locked` exits 0 with no failures — including punktfunk-core's 196-test suite, which covers the control-stream framing the probe change touches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
650 lines
26 KiB
Rust
650 lines
26 KiB
Rust
//! 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, AtomicU64, 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 Deck descriptors + the `0x83`/`0xAE` feature contract live in the shared
|
||
// [`super::steam_proto`] module (single source of truth, also used by the usbip transport).
|
||
use super::steam_proto::{
|
||
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, RDESC_DECK_CTRL as RDESC_CTRL,
|
||
RDESC_DECK_KBD as RDESC_KBD, RDESC_DECK_MOUSE as RDESC_MOUSE,
|
||
};
|
||
|
||
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) };
|
||
}
|
||
}
|
||
|
||
/// The signal used to break a worker thread out of a blocking raw_gadget ioctl at teardown.
|
||
/// `EVENT_FETCH`/`EP_WRITE` are `wait_event_interruptible` in the kernel with no timeout and no
|
||
/// `O_NONBLOCK` honouring, and closing the fd cannot wake a thread already inside the ioctl (the
|
||
/// in-flight syscall holds a reference to the struct file). A signal is the only reliable lever:
|
||
/// delivered with a no-op, non-`SA_RESTART` handler it forces the ioctl to return `EINTR`, after
|
||
/// which the loop's top-of-iteration `running` check exits. `SIGUSR1` is unused elsewhere in this
|
||
/// process; the handler is a no-op, so a stray `SIGUSR1` becomes harmless rather than fatal.
|
||
const WAKE_SIGNAL: libc::c_int = libc::SIGUSR1;
|
||
|
||
/// Install the no-op `WAKE_SIGNAL` handler exactly once. Crucially `sa_flags = 0` (no `SA_RESTART`)
|
||
/// so a delivered signal makes the interruptible ioctl return `EINTR` instead of auto-restarting.
|
||
fn install_wake_handler() {
|
||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||
ONCE.call_once(|| {
|
||
extern "C" fn noop(_: libc::c_int) {}
|
||
// SAFETY: installing a well-formed `sigaction` with an empty mask and a valid no-op handler
|
||
// for a single signal; touches only this process's disposition for `WAKE_SIGNAL`.
|
||
unsafe {
|
||
let mut sa: libc::sigaction = std::mem::zeroed();
|
||
// Via `*const ()`: casting a function item straight to an integer is what
|
||
// `clippy::function_casts_as_integer` rejects, and the pointer hop is the documented
|
||
// way to spell it. `sa_sigaction` is a `usize`-typed handler slot, so the value is
|
||
// unchanged.
|
||
sa.sa_sigaction = noop as *const () as usize;
|
||
libc::sigemptyset(&mut sa.sa_mask);
|
||
sa.sa_flags = 0;
|
||
libc::sigaction(WAKE_SIGNAL, &sa, std::ptr::null_mut());
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Lets `Drop` wake a specific worker thread parked in a blocking ioctl. `tid` is the thread's
|
||
/// `pthread_self()` (0 until it starts); `done` is set right before the thread returns, so `Drop`
|
||
/// stops signalling a thread that has already exited.
|
||
struct Waker {
|
||
tid: Arc<AtomicU64>,
|
||
done: Arc<AtomicBool>,
|
||
}
|
||
|
||
/// 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<()>>,
|
||
wakers: Vec<Waker>,
|
||
_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 = deck_serial(index);
|
||
let unit_id = deck_unit_id(index); // "PF" + index — a synthetic per-instance device id
|
||
let report = Arc::new(Mutex::new(neutral_deck_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));
|
||
|
||
// The teardown wake path (see `WAKE_SIGNAL`) needs the handler installed before any thread
|
||
// can park in a blocking ioctl.
|
||
install_wake_handler();
|
||
let ctrl_waker = Waker {
|
||
tid: Arc::new(AtomicU64::new(0)),
|
||
done: Arc::new(AtomicBool::new(false)),
|
||
};
|
||
let stream_waker = Waker {
|
||
tid: Arc::new(AtomicU64::new(0)),
|
||
done: 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();
|
||
let tid = ctrl_waker.tid.clone();
|
||
let done = ctrl_waker.done.clone();
|
||
std::thread::Builder::new()
|
||
.name("pf-deck-gadget-ctrl".into())
|
||
.spawn(move || {
|
||
// SAFETY: `pthread_self` is always valid on the calling thread.
|
||
tid.store(unsafe { libc::pthread_self() } as u64, Ordering::SeqCst);
|
||
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id);
|
||
done.store(true, Ordering::SeqCst);
|
||
})
|
||
.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();
|
||
let tid = stream_waker.tid.clone();
|
||
let done = stream_waker.done.clone();
|
||
std::thread::Builder::new()
|
||
.name("pf-deck-gadget-stream".into())
|
||
.spawn(move || {
|
||
// SAFETY: `pthread_self` is always valid on the calling thread.
|
||
tid.store(unsafe { libc::pthread_self() } as u64, Ordering::SeqCst);
|
||
stream_loop(fd, running, ctrl_ep, configured, report);
|
||
done.store(true, Ordering::SeqCst);
|
||
})
|
||
.context("spawn gadget stream thread")?
|
||
};
|
||
|
||
Ok(SteamDeckGadget {
|
||
report,
|
||
feedback,
|
||
running,
|
||
threads: vec![control, stream],
|
||
wakers: vec![ctrl_waker, stream_waker],
|
||
_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);
|
||
// The control thread spends steady state parked in a blocking `EVENT_FETCH` ioctl that only
|
||
// tests `running` at the top of its loop, so clearing the flag is not enough — it must be
|
||
// signalled out of the syscall (see `WAKE_SIGNAL`). Without this the join below can hang the
|
||
// caller (the session input thread, via `PadSlots::sweep`) indefinitely. Retry until each
|
||
// thread reports done, to cover the race where the signal lands just before the thread
|
||
// re-enters the ioctl; bounded (~1 s) so a genuinely stuck thread can't wedge teardown either.
|
||
for _ in 0..200 {
|
||
let mut all_done = true;
|
||
for w in &self.wakers {
|
||
if w.done.load(Ordering::SeqCst) {
|
||
continue;
|
||
}
|
||
all_done = false;
|
||
let tid = w.tid.load(Ordering::SeqCst);
|
||
if tid != 0 {
|
||
// SAFETY: the thread is joinable and not yet joined (join runs after this loop),
|
||
// so `tid` names a live pthread; `pthread_kill` on a finished-but-unjoined thread
|
||
// is defined (returns ESRCH), never UB.
|
||
unsafe { libc::pthread_kill(tid as libc::pthread_t, WAKE_SIGNAL) };
|
||
}
|
||
}
|
||
if all_done {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
for t in self.threads.drain(..) {
|
||
let _ = t.join();
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
unit_id: u32,
|
||
) {
|
||
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,
|
||
unit_id,
|
||
&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,
|
||
unit_id: u32,
|
||
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, unit_id);
|
||
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);
|
||
}
|
||
}
|
||
|
||
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_deck_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` — the only transport Steam Input
|
||
/// promotes (validated glass-to-glass on a Deck). Defaults **on for SteamOS** hosts (which ship the
|
||
/// gadget modules + run Steam Input); off elsewhere, where the universal UHID path stays the default.
|
||
/// `PUNKTFUNK_STEAM_GADGET=1`/`0` forces it on/off. A Deck-as-host with a *physical* Deck never reaches
|
||
/// here: `resolve_gamepad`'s conflict gate degrades `SteamDeck` → DualSense before the manager is built.
|
||
pub fn gadget_preferred() -> bool {
|
||
if let Ok(v) = std::env::var("PUNKTFUNK_STEAM_GADGET") {
|
||
return v == "1" || v.eq_ignore_ascii_case("true");
|
||
}
|
||
is_steamos()
|
||
}
|
||
|
||
/// True on SteamOS-class hosts (`/etc/os-release` `ID=steamos`, or `ID_LIKE` naming it).
|
||
fn is_steamos() -> bool {
|
||
std::fs::read_to_string("/etc/os-release")
|
||
.map(|s| {
|
||
s.lines()
|
||
.any(|l| l == "ID=steamos" || (l.starts_with("ID_LIKE=") && l.contains("steamos")))
|
||
})
|
||
.unwrap_or(false)
|
||
}
|