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 27271d56d1
commit 3cc8f7dc65
4 changed files with 660 additions and 11 deletions
@@ -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<Option<SteamDeckPad>>,
pads: Vec<Option<DeckTransport>>,
state: Vec<SteamState>,
last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>,
@@ -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();