refactor(inject/linux/steam_controller): convert to UhidManager<SteamProto> (3.3)

The most hook-laden conversion: SteamControllerManager becomes a pub
type alias of UhidManager<SteamProto>. The Steam-specific pieces map
cleanly onto the trait — open() delegates to open_transport (usbip →
gadget → UHID fallback, which keeps its own per-transport logging, so no
extra success line, matching the old ensure), merge_frame preserves the
trackpad coords/touch-bits/clicks + motion across button-only frames
(the G2 fix, verbatim), and the gamepad-mode-entry pulse rides the
force_heartbeat hook. DeckTransport goes pub (type Pad in a public-trait
impl). Also un-fuses a doc-comment glitch where the manager's doc had
been merged onto the DeckTransport enum.

Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.

Part of G12/3.3 (§3a.4 commit 8) — all five stateful managers now share
one skeleton.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 01:41:21 +02:00
parent 365d4bb8f1
commit 384fc30833
@@ -23,10 +23,9 @@ use super::steam_proto::{
btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT, btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT,
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
}; };
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use crate::inject::pad_gate::PadGate;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::RichInput;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
@@ -235,16 +234,12 @@ impl Drop for SteamDeckPad {
} }
} }
/// All virtual Steam Deck pads of a session — the Steam analogue of
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=steamdeck`.
/// 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 /// The transport a manager pad drives. UHID is universal but Steam Input won't promote it (a UHID
/// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS) /// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS)
/// and **usbip** (`vhci_hcd`, universal) both present the controller on USB interface 2, which Steam /// and **usbip** (`vhci_hcd`, universal) both present the controller on USB interface 2, which Steam
/// Input *does* promote. Selected per-pad by [`open_transport`]. /// Input *does* promote. Selected per-pad by [`open_transport`]. (`pub`: the type appears as
enum DeckTransport { /// `type Pad` in the `PadProto` impl, a public trait.)
pub enum DeckTransport {
Uhid(SteamDeckPad), Uhid(SteamDeckPad),
Gadget(crate::inject::steam_gadget::SteamDeckGadget), Gadget(crate::inject::steam_gadget::SteamDeckGadget),
Usbip(crate::inject::steam_usbip::SteamDeckUsbip), Usbip(crate::inject::steam_usbip::SteamDeckUsbip),
@@ -356,59 +351,36 @@ fn open_transport(idx: u8) -> Result<DeckTransport> {
Ok(DeckTransport::Uhid(p)) Ok(DeckTransport::Uhid(p))
} }
pub struct SteamControllerManager { /// The Steam-Deck-specific half of the shared stateful manager (see [`PadProto`]): the transport
pads: Vec<Option<DeckTransport>>, /// open (usbip → gadget → UHID fallback via [`open_transport`], which logs its own per-transport
state: Vec<SteamState>, /// outcome), the [`SteamState`] mappers, and the kernel-handshake service pass. Lifecycle (slot
last_rumble: Vec<(u16, u16)>, /// table, unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`]; the gamepad-mode-entry
last_write: Vec<Instant>, /// pulse rides the [`force_heartbeat`](PadProto::force_heartbeat) hook.
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of #[derive(Default)]
/// permanently disabling every pad for the session. pub struct SteamProto;
gate: PadGate,
}
impl Default for SteamControllerManager { impl PadProto for SteamProto {
fn default() -> SteamControllerManager { type Pad = DeckTransport;
SteamControllerManager::new() type State = SteamState;
} const LABEL: &'static str = "Steam Deck";
} const DEVICE: &'static str = "Steam Deck";
const CREATE_HINT: &'static str = "";
impl SteamControllerManager { fn open(&mut self, idx: u8) -> Result<DeckTransport> {
pub fn new() -> SteamControllerManager { open_transport(idx)
SteamControllerManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![SteamState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
gate: PadGate::new(),
}
} }
pub fn handle(&mut self, ev: &GamepadEvent) { fn neutral(&self) -> SteamState {
match ev { SteamState::neutral()
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (Steam Deck)");
self.ensure(*index as usize);
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; /// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion arrive
if idx >= MAX_PADS { /// separately and must survive a button-only frame).
return; fn merge_frame(
} &self,
for (i, slot) in self.pads.iter_mut().enumerate() { prev: &SteamState,
if slot.is_some() && f.active_mask & (1 << i) == 0 { f: &crate::gamestream::gamepad::GamepadFrame,
tracing::info!(index = i, "controller unplugged (Steam Deck)"); ) -> SteamState {
*slot = None;
self.state[i] = SteamState::neutral();
self.last_rumble[i] = (0, 0);
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion
// arrive separately and must survive a button-only frame).
let prev = self.state[idx];
let mut s = SteamState::from_gamepad( let mut s = SteamState::from_gamepad(
f.buttons, f.buttons,
f.ls_x, f.ls_x,
@@ -431,86 +403,41 @@ impl SteamControllerManager {
// wire-button's RPAD_CLICK — the two are OR'd only at serialize. // wire-button's RPAD_CLICK — the two are OR'd only at serialize.
s.lpad_click = prev.lpad_click; s.lpad_click = prev.lpad_click;
s.rpad_click = prev.rpad_click; s.rpad_click = prev.rpad_click;
self.state[idx] = s; s
self.write(idx);
} }
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
st.apply_rich(rich);
}
fn write_state(&self, pad: &mut DeckTransport, st: &SteamState) {
pad.write_state(st);
}
/// Answer the kernel handshake and forward rumble on the universal plane. The Steam Deck has
/// no rich host→client feedback plane (no lightbar / adaptive triggers), so `hidout` stays
/// empty.
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
PadFeedback {
rumble: pad.service(),
hidout: Vec::new(),
} }
} }
/// Apply a rich client→host event (right trackpad / motion) to an existing pad. /// Force a steady stream while a pad is still pulsing its gamepad-mode entry (so the `b9.6`
pub fn apply_rich(&mut self, rich: RichInput) { /// toggle completes even with no game input).
let idx = match rich { fn force_heartbeat(&self, pad: &DeckTransport) -> bool {
RichInput::Touchpad { pad, .. } pad.in_mode_entry()
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
self.state[idx].apply_rich(rich);
self.write(idx);
}
fn write(&mut self, idx: usize) {
let st = self.state[idx];
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(&st);
}
self.last_write[idx] = Instant::now();
}
/// Re-emit each live pad's current report when silent past `max_gap`, and force a steady stream
/// while a pad is still pulsing its gamepad-mode entry (so the `b9.6` toggle completes even with
/// no game input).
pub fn heartbeat(&mut self, max_gap: Duration) {
let now = Instant::now();
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_ref() else {
continue;
};
if pad.in_mode_entry() || now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match open_transport(idx as u8) {
Ok(t) => {
self.pads[idx] = Some(t);
self.state[idx] = SteamState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
/// Service every pad: answer the kernel handshake and forward rumble on the universal plane.
/// `rumble` fires `(index, low, high)` only on a level change. The Steam Deck has no rich
/// host→client feedback plane (no lightbar / adaptive triggers), so `hidout` goes unused.
pub fn pump(&mut self, mut rumble: impl FnMut(u16, u16, u16), _hidout: impl FnMut(HidOutput)) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
if let Some(r) = pad.service() {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
}
} }
} }
/// All virtual Steam Deck pads of a session — the Steam analogue of
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with
/// `PUNKTFUNK_GAMEPAD=steamdeck`. Button/stick frames arrive via `handle`; the trackpads + motion
/// via `apply_rich`; `pump` services the kernel handshake + routes rumble back; `heartbeat` keeps
/// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`].
pub type SteamControllerManager = UhidManager<SteamProto>;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;