From 89aa52bc5861ce5cabb3f42a2aad59e52ccb7d7b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:57:00 +0200 Subject: [PATCH] refactor(inject): uinput + XUSB managers onto PadSlots (3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/inject/linux/gamepad.rs | 60 ++++++++----------- .../src/inject/windows/gamepad_windows.rs | 55 +++++++---------- 2 files changed, 44 insertions(+), 71 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/gamepad.rs b/crates/punktfunk-host/src/inject/linux/gamepad.rs index 2e85ce93..0c21f66c 100644 --- a/crates/punktfunk-host/src/inject/linux/gamepad.rs +++ b/crates/punktfunk-host/src/inject/linux/gamepad.rs @@ -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>, + slots: PadSlots, /// 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,29 +609,18 @@ 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 { - if let Some((low, high)) = pad.pump_ff() { - send(i as u16, low, high); - } + for (i, pad) in self.slots.iter_mut() { + if let Some((low, high)) = pad.pump_ff() { + send(i as u16, low, high); } } } diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index 68e929b8..5297118f 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -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>, + slots: PadSlots, 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, - /// 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) => { - 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()); - } + if self.slots.ensure(idx, XusbWinPad::open) { + tracing::info!( + index = idx, + "virtual Xbox 360 created (Windows XUSB companion)" + ); + self.last_rumble[idx] = (0, 0); + self.last_active[idx] = 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