refactor(inject): uinput + XUSB managers onto PadSlots (3.3)

The two stateless backends keep their structs and special pumps (uinput
FF-effect mixing via pump_ff/last_mix; the XUSB stale-residual
RUMBLE_IDLE_TIMEOUT force-off) but delegate slot lifecycle — table,
unplug sweep, gate-checked create — to the shared PadSlots. XUSB resets
last_rumble/last_active on the swept indices and on fresh create exactly
as before (the G10/G16-adjacent semantics untouched).

Two accepted deltas, both flagged in the plan (§3a): the uinput
arrival/unplug log lines gain the pad-identity label every other backend
already has ("controller arrival (X-Box 360 pad)"), and XUSB's
f.index.max(0) clamp is replaced by the bounds check every other manager
uses — a negative wire index is now dropped instead of being treated as
pad 0.

Verified: .21 clippy --all-targets -D warnings clean + full suite 290
pass / 0 fail (uinput); .133 clippy --all-targets -D warnings EXITCODE 0
(XUSB).

Part of G12/3.3 (§3a.4 commit 9) — all seven managers now share the
PadSlots lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 01:57:00 +02:00
parent 384fc30833
commit 89aa52bc58
2 changed files with 44 additions and 71 deletions
@@ -19,7 +19,7 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS}; use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
use crate::inject::pad_gate::PadGate; use crate::inject::pad_slots::PadSlots;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::collections::HashMap; use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd}; use std::os::fd::{AsRawFd, OwnedFd};
@@ -551,16 +551,20 @@ impl Drop for VirtualPad {
} }
} }
/// All virtual pads of a session, driven from decoded controller events. /// All virtual pads of a session, driven from decoded controller events. Stateless per frame
#[derive(Default)] /// (uinput/evdev holds last-known state kernel-side), so it rides [`PadSlots`] directly — no state
/// vec, heartbeat, or rich plane like the UHID managers.
pub struct GamepadManager { pub struct GamepadManager {
pads: Vec<Option<VirtualPad>>, slots: PadSlots<VirtualPad>,
/// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when /// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when
/// the client asked for `XboxOne`). All pads in a session share one identity. /// the client asked for `XboxOne`). All pads in a session share one identity.
identity: PadIdentity, identity: PadIdentity,
/// Create-retry gate: a transient `/dev/uinput` failure backs off and retries instead of }
/// permanently disabling every pad for the session.
gate: PadGate, impl Default for GamepadManager {
fn default() -> GamepadManager {
GamepadManager::new()
}
} }
impl GamepadManager { impl GamepadManager {
@@ -572,9 +576,8 @@ impl GamepadManager {
/// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]). /// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]).
pub fn with_identity(identity: PadIdentity) -> GamepadManager { pub fn with_identity(identity: PadIdentity) -> GamepadManager {
GamepadManager { GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(), slots: PadSlots::new(identity.log, "gamepad", ""),
identity, identity,
gate: PadGate::new(),
} }
} }
@@ -583,7 +586,7 @@ impl GamepadManager {
use crate::gamestream::gamepad::GamepadEvent; use crate::gamestream::gamepad::GamepadEvent;
match ev { match ev {
GamepadEvent::Arrival { index, kind, .. } => { GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival"); tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
self.ensure(*index as usize); self.ensure(*index as usize);
} }
GamepadEvent::State(f) => { GamepadEvent::State(f) => {
@@ -591,18 +594,14 @@ impl GamepadManager {
if idx >= MAX_PADS { if idx >= MAX_PADS {
return; return;
} }
// Unplugs: drop any allocated pad whose mask bit cleared. // Unplugs: drop any allocated pad whose mask bit cleared (no per-index sibling
for (i, slot) in self.pads.iter_mut().enumerate() { // state to reset — the pads mix rumble internally).
if slot.is_some() && f.active_mask & (1 << i) == 0 { self.slots.sweep(f.active_mask);
tracing::info!(index = i, "controller unplugged");
*slot = None;
}
}
if f.active_mask & (1 << idx) == 0 { if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug return; // this event WAS the unplug
} }
self.ensure(idx); self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() { if let Some(pad) = self.slots.get_mut(idx) {
pad.apply(f); pad.apply(f);
} }
} }
@@ -610,29 +609,18 @@ impl GamepadManager {
} }
fn ensure(&mut self, idx: usize) { fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { let identity = self.identity;
return; // `VirtualPad::create` logs its own success line (it knows the identity + transport).
} self.slots
match VirtualPad::create(idx, self.identity) { .ensure(idx, |i| VirtualPad::create(i as usize, identity));
Ok(p) => {
self.pads[idx] = Some(p);
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
} }
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose /// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered). /// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for (i, slot) in self.pads.iter_mut().enumerate() { for (i, pad) in self.slots.iter_mut() {
if let Some(pad) = slot { if let Some((low, high)) = pad.pump_ff() {
if let Some((low, high)) = pad.pump_ff() { send(i as u16, low, high);
send(i as u16, low, high);
}
} }
} }
} }
@@ -14,7 +14,7 @@
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate; use crate::inject::pad_slots::PadSlots;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering}; use std::sync::atomic::{fence, AtomicU32, Ordering};
@@ -256,15 +256,12 @@ impl XusbWinPad {
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500); const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
pub struct GamepadManager { pub struct GamepadManager {
pads: Vec<Option<XusbWinPad>>, slots: PadSlots<XusbWinPad>,
last_rumble: Vec<(u8, u8)>, last_rumble: Vec<(u8, u8)>,
/// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero /// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero
/// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the /// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the
/// const's docs. /// const's docs.
last_active: Vec<Instant>, last_active: Vec<Instant>,
/// Create-retry gate: a transient XUSB-companion failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
} }
impl Default for GamepadManager { impl Default for GamepadManager {
@@ -276,32 +273,24 @@ impl Default for GamepadManager {
impl GamepadManager { impl GamepadManager {
pub fn new() -> GamepadManager { pub fn new() -> GamepadManager {
GamepadManager { GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(), slots: PadSlots::new(
"Xbox 360/Windows",
"Xbox 360",
" (install/repair: punktfunk-host.exe driver install --gamepad)",
),
last_rumble: vec![(0, 0); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS],
last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(), last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(),
gate: PadGate::new(),
} }
} }
fn ensure(&mut self, idx: usize) { fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { if self.slots.ensure(idx, XusbWinPad::open) {
return; tracing::info!(
} index = idx,
match XusbWinPad::open(idx as u8) { "virtual Xbox 360 created (Windows XUSB companion)"
Ok(p) => { );
tracing::info!( self.last_rumble[idx] = (0, 0);
index = idx, self.last_active[idx] = Instant::now();
"virtual Xbox 360 created (Windows XUSB companion)"
);
self.pads[idx] = Some(p);
self.last_rumble[idx] = (0, 0);
self.last_active[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
self.gate.on_failure(Instant::now());
}
} }
} }
@@ -312,15 +301,14 @@ impl GamepadManager {
self.ensure(*index as usize); self.ensure(*index as usize);
} }
GamepadEvent::State(f) => { GamepadEvent::State(f) => {
let idx = f.index.max(0) as usize; let idx = f.index as usize;
if idx >= MAX_PADS { if idx >= MAX_PADS {
return; return;
} }
// Unplugs: drop any allocated pad whose mask bit cleared. // Unplugs: drop any allocated pad whose mask bit cleared.
for (i, slot) in self.pads.iter_mut().enumerate() { let swept = self.slots.sweep(f.active_mask);
if slot.is_some() && f.active_mask & (1 << i) == 0 { for i in 0..MAX_PADS {
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)"); if swept & (1 << i) != 0 {
*slot = None;
self.last_rumble[i] = (0, 0); self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now(); self.last_active[i] = Instant::now();
} }
@@ -329,7 +317,7 @@ impl GamepadManager {
return; return;
} }
self.ensure(idx); self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() { if let Some(pad) = self.slots.get_mut(idx) {
pad.write_state( pad.write_state(
(f.buttons & 0xffff) as u16, (f.buttons & 0xffff) as u16,
f.left_trigger, f.left_trigger,
@@ -348,10 +336,7 @@ impl GamepadManager {
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small` /// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends. /// (high-frequency) → `high` — matching the other backends.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for i in 0..self.pads.len() { for (i, pad) in self.slots.iter_mut() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
if let Some((large, small)) = pad.service() { if let Some((large, small)) = pad.service() {
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the // The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
// activity clock even when the level is unchanged, so a rumble it keeps asserting // activity clock even when the level is unchanged, so a rumble it keeps asserting