feat(inject): shared PadSlots<P> slot table + lifecycle (3.3 layer 1)
The Vec<Option<Pad>> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -511,6 +511,12 @@ mod gamepad_raii;
|
|||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
#[path = "inject/pad_gate.rs"]
|
#[path = "inject/pad_gate.rs"]
|
||||||
pub mod pad_gate;
|
pub mod pad_gate;
|
||||||
|
/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the
|
||||||
|
/// `Vec<Option<Pad>>` 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.
|
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/steam_controller.rs"]
|
#[path = "inject/linux/steam_controller.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<Option<P>>` 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<P> {
|
||||||
|
pads: Vec<Option<P>>,
|
||||||
|
/// 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 `<device>` creation failed …").
|
||||||
|
device: &'static str,
|
||||||
|
/// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
|
||||||
|
hint: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P> PadSlots<P> {
|
||||||
|
/// 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<P> {
|
||||||
|
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<P>) -> 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<Item = (usize, &mut P)> {
|
||||||
|
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<u32> {
|
||||||
|
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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user