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)]
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 std::collections::HashMap;
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.
#[derive(Default)]
/// All virtual pads of a session, driven from decoded controller events. Stateless per frame
/// (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 {
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 client asked for `XboxOne`). All pads in a session share one identity.
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 {
@@ -572,9 +576,8 @@ impl GamepadManager {
/// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]).
pub fn with_identity(identity: PadIdentity) -> GamepadManager {
GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
slots: PadSlots::new(identity.log, "gamepad", ""),
identity,
gate: PadGate::new(),
}
}
@@ -583,7 +586,7 @@ impl GamepadManager {
use crate::gamestream::gamepad::GamepadEvent;
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival");
tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
@@ -591,18 +594,14 @@ impl GamepadManager {
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged");
*slot = None;
}
}
// Unplugs: drop any allocated pad whose mask bit cleared (no per-index sibling
// state to reset — the pads mix rumble internally).
self.slots.sweep(f.active_mask);
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() {
if let Some(pad) = self.slots.get_mut(idx) {
pad.apply(f);
}
}
@@ -610,30 +609,19 @@ impl GamepadManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match VirtualPad::create(idx, self.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());
}
}
let identity = self.identity;
// `VirtualPad::create` logs its own success line (it knows the identity + transport).
self.slots
.ensure(idx, |i| VirtualPad::create(i as usize, identity));
}
/// 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).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for (i, slot) in self.pads.iter_mut().enumerate() {
if let Some(pad) = slot {
for (i, pad) in self.slots.iter_mut() {
if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high);
}
}
}
}
}
@@ -14,7 +14,7 @@
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use crate::inject::pad_slots::PadSlots;
use anyhow::{anyhow, Result};
use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering};
@@ -256,15 +256,12 @@ impl XusbWinPad {
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
pub struct GamepadManager {
pads: Vec<Option<XusbWinPad>>,
slots: PadSlots<XusbWinPad>,
last_rumble: Vec<(u8, u8)>,
/// 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
/// const's docs.
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 {
@@ -276,32 +273,24 @@ impl Default for GamepadManager {
impl GamepadManager {
pub fn new() -> 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_active: (0..MAX_PADS).map(|_| Instant::now()).collect(),
gate: PadGate::new(),
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match XusbWinPad::open(idx as u8) {
Ok(p) => {
if self.slots.ensure(idx, XusbWinPad::open) {
tracing::info!(
index = idx,
"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);
}
GamepadEvent::State(f) => {
let idx = f.index.max(0) as usize;
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)");
*slot = None;
let swept = self.slots.sweep(f.active_mask);
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
@@ -329,7 +317,7 @@ impl GamepadManager {
return;
}
self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() {
if let Some(pad) = self.slots.get_mut(idx) {
pad.write_state(
(f.buttons & 0xffff) as u16,
f.left_trigger,
@@ -348,10 +336,7 @@ impl GamepadManager {
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
for (i, pad) in self.slots.iter_mut() {
if let Some((large, small)) = pad.service() {
// 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