From 528a51d75cb133ad8b5e9fc43839b2322a3e05f1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:04:22 +0200 Subject: [PATCH] feat(inject): shared PadSlots

slot table + lifecycle (3.3 layer 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vec> slot table, active_mask unplug sweep, and PadGate- checked create that all seven backend managers copy-paste, extracted into one unit-tested inject/pad_slots.rs (cfg any(linux,windows), like pad_gate). sweep() returns the swept indices as a bitmask and ensure() returns fresh-create, so managers reset their per-index sibling state (state / last_rumble / dedup / clocks) without closure gymnastics. Lifecycle log lines are label/device/hint-parameterized to stay byte-identical per backend; open() keeps the success line (it knows the transport detail). Additive only — no manager converted yet; first unit coverage for the sweep/create lifecycle (5 tests: freshness, sweep-once semantics, gate integration, recreate, pump iteration). Verified on .21: clippy --all-targets -D warnings clean; suite 285 pass / 0 fail (280 prior + 5 new). Part of G12/3.3 (gamepad-review-cleanup.md §3a.3, commit 2 of the §3a.4 sequence). Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/inject.rs | 6 + crates/punktfunk-host/src/inject/pad_slots.rs | 184 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 crates/punktfunk-host/src/inject/pad_slots.rs diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index dccf62c1..9d8eb6b7 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -511,6 +511,12 @@ mod gamepad_raii; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/pad_gate.rs"] pub mod pad_gate; +/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the +/// `Vec>` table, `active_mask` unplug sweep, and gate-checked create every backend +/// manager used to copy-paste (G12). +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[path = "inject/pad_slots.rs"] +pub mod pad_slots; /// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck. #[cfg(target_os = "linux")] #[path = "inject/linux/steam_controller.rs"] diff --git a/crates/punktfunk-host/src/inject/pad_slots.rs b/crates/punktfunk-host/src/inject/pad_slots.rs new file mode 100644 index 00000000..4f2427d8 --- /dev/null +++ b/crates/punktfunk-host/src/inject/pad_slots.rs @@ -0,0 +1,184 @@ +//! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux +//! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`]. + +use crate::gamestream::gamepad::MAX_PADS; +use crate::inject::pad_gate::PadGate; +use anyhow::Result; +use std::time::Instant; + +// The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit. +const _: () = assert!(MAX_PADS <= 16); + +/// The slot table + lifecycle every virtual-pad manager repeats: `Vec>` keyed by wire pad +/// index, the `active_mask` unplug sweep, and the [`PadGate`]-guarded create. Extracted verbatim +/// from seven copy-pasted managers (G12) so a lifecycle fix lands once, not seven times. +/// +/// Division of labor: `PadSlots` owns the pads' *existence* (create / sweep / lookup) and logs the +/// shared lifecycle lines (unplug, create-failure); the backend keeps everything per-controller — +/// its state model, feedback pump, and the success log inside `open` (which knows the transport +/// detail worth printing). Per-index sibling state (`state` / `last_rumble` / dedup / clocks) stays +/// in the manager, which resets it on the indices [`sweep`](Self::sweep) returns and on a `true` +/// from [`ensure`](Self::ensure). +pub struct PadSlots

{ + pads: Vec>, + /// Create-retry gate: a transient backend failure backs off and retries instead of permanently + /// disabling every pad for the session. + gate: PadGate, + /// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"` — keeps every + /// existing per-backend line byte-identical (ops greps survive the extraction). + label: &'static str, + /// Device name in the create-failure line ("virtual `` creation failed …"). + device: &'static str, + /// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows. + hint: &'static str, +} + +impl

PadSlots

{ + /// An empty table of [`MAX_PADS`] slots whose lifecycle log lines carry `label` / `device` / + /// `hint` (see the field docs). + pub fn new(label: &'static str, device: &'static str, hint: &'static str) -> PadSlots

{ + PadSlots { + pads: (0..MAX_PADS).map(|_| None).collect(), + gate: PadGate::new(), + label, + device, + hint, + } + } + + /// The backend tag this table logs with (for the manager's own arrival line). + pub fn label(&self) -> &'static str { + self.label + } + + /// Drop every allocated pad whose `active_mask` bit cleared (the unplug sweep run on each state + /// frame), logging each. Returns the swept indices as a bitmask so the caller resets its + /// per-index sibling state; an index another manager owns is `None` here, so it is never swept. + pub fn sweep(&mut self, active_mask: u16) -> u16 { + let mut swept = 0u16; + for (i, slot) in self.pads.iter_mut().enumerate() { + if slot.is_some() && active_mask & (1 << i) == 0 { + tracing::info!(index = i, "controller unplugged ({})", self.label); + *slot = None; + swept |= 1 << i; + } + } + swept + } + + /// Create the pad at `idx` via `open` if the slot is empty and the create gate allows it. + /// Returns `true` only on a fresh create (the caller resets its per-index sibling state); + /// `open` logs its own success line (it knows the transport detail), failure is logged here. + pub fn ensure(&mut self, idx: usize, open: impl FnOnce(u8) -> Result

) -> bool { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { + return false; + } + match open(idx as u8) { + Ok(p) => { + self.pads[idx] = Some(p); + self.gate.on_success(); + true + } + Err(e) => { + tracing::error!( + error = %format!("{e:#}"), + "virtual {} creation failed — retrying with backoff{}", + self.device, + self.hint + ); + self.gate.on_failure(Instant::now()); + false + } + } + } + + /// The live pad at `idx`, if any (out-of-range → `None`). + pub fn get(&self, idx: usize) -> Option<&P> { + self.pads.get(idx).and_then(|s| s.as_ref()) + } + + /// The live pad at `idx`, mutably, if any (out-of-range → `None`). + pub fn get_mut(&mut self, idx: usize) -> Option<&mut P> { + self.pads.get_mut(idx).and_then(|s| s.as_mut()) + } + + /// Iterate the live pads as `(index, &mut pad)` (the feedback-pump shape). + pub fn iter_mut(&mut self) -> impl Iterator { + self.pads + .iter_mut() + .enumerate() + .filter_map(|(i, s)| s.as_mut().map(|p| (i, p))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::bail; + + fn slots() -> PadSlots { + PadSlots::new("Test", "test pad", "") + } + + #[test] + fn ensure_creates_once_and_reports_freshness() { + let mut s = slots(); + // Fresh create → true; the pad is live. + assert!(s.ensure(3, |i| Ok(i as u32 * 10))); + assert_eq!(s.get(3), Some(&30)); + // Occupied slot → no re-open (the closure must not run), no reset signal. + assert!(!s.ensure(3, |_| panic!("re-opened an occupied slot"))); + // Out of range → never opens. + assert!(!s.ensure(MAX_PADS, |_| panic!("opened out of range"))); + assert_eq!(s.get(MAX_PADS), None); + } + + #[test] + fn sweep_drops_only_cleared_bits_and_returns_them_once() { + let mut s = slots(); + assert!(s.ensure(0, |_| Ok(0))); + assert!(s.ensure(2, |_| Ok(2))); + assert!(s.ensure(5, |_| Ok(5))); + // Mask keeps 2, clears 0 and 5; empty slots (1, 3, …) are untouched non-events. + let swept = s.sweep(0b0000_0100); + assert_eq!(swept, 0b0010_0001); + assert_eq!(s.get(0), None); + assert_eq!(s.get(2), Some(&2)); + assert_eq!(s.get(5), None); + // A second identical sweep is a no-op: the indices were returned exactly once. + assert_eq!(s.sweep(0b0000_0100), 0); + } + + #[test] + fn create_failure_arms_the_gate_and_success_heals_it() { + let mut s = slots(); + assert!(!s.ensure(1, |_| bail!("transient"))); + // Backoff in effect: the next attempt is blocked without even calling `open`. + assert!(!s.ensure(1, |_| panic!("open during backoff"))); + // The gate is manager-wide (create failures are systemic), so other indices block too. + assert!(!s.ensure(2, |_| panic!("open during backoff"))); + // …and a sweep-then-recreate of a *different* live pad is equally gated, but the table + // itself is intact: nothing was allocated. + assert_eq!(s.get(1), None); + } + + #[test] + fn recreate_after_sweep_resets_freshness() { + let mut s = slots(); + assert!(s.ensure(4, |_| Ok(1))); + s.sweep(0); + assert_eq!(s.get(4), None); + // The slot is free again → a fresh create (true) with a new value. + assert!(s.ensure(4, |_| Ok(2))); + assert_eq!(s.get(4), Some(&2)); + } + + #[test] + fn iter_mut_yields_live_pads_with_indices() { + let mut s = slots(); + assert!(s.ensure(1, |_| Ok(10))); + assert!(s.ensure(6, |_| Ok(60))); + let seen: Vec<(usize, u32)> = s.iter_mut().map(|(i, p)| (i, *p)).collect(); + assert_eq!(seen, vec![(1, 10), (6, 60)]); + } +}