feat(gamepad): multi-controller support on the native plane

Host was already built for 16 pads; the blocker was every client
hard-coding pad 0. This lands the host-side + reference-client contract:

- input.rs: new wire kinds GamepadArrival=14 (declares a pad's type:
  code=GamepadPref byte, flags=pad) and GamepadRemove=13 (flags=seq<<24|pad,
  shares the snapshot seq space via encode/decode_gamepad_remove).
- pf-client-core/gamepad.rs: reworked from a single `open` pad to a
  slots: Vec<Slot> model — every forwarded controller gets a stable
  lowest-free wire index held for its lifetime, per-slot held/axis/touch/
  rumble state, GamepadArrival on open + GamepadRemove on close, and
  feedback routed back per wire index. Automatic forwards all real pads;
  a pin forces single-player.
- punktfunk1.rs: replaced the single-session PadBackend enum with a Pads
  router — per-pad kinds[]/owner[] arrays, lazily-created per-kind managers,
  pure route_decision keeping a live device in its manager across a kind
  change (no ghost/dup). Input thread seq-gates GamepadRemove (clears the
  pad_mask bit, resets rumble) and applies GamepadArrival kinds.
- inject linux/windows backends: add the two new no-op InputKind arms.

Native/session + default-Windows clients (both spawn punktfunk-session)
inherit this. 57 core + 33 client-core + 272 host tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:52:15 +02:00
parent 84329205eb
commit 76be4c3e12
9 changed files with 1082 additions and 462 deletions
+346 -130
View File
@@ -1740,166 +1740,315 @@ const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
/// virtual mic has its own tuning — see [`crate::audio::MicPump`].)
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
/// The session's virtual-gamepad backend, resolved once per session (sessions run serially).
/// Per-pad virtual-gamepad router: each pad index is served by a backend of that pad's declared
/// kind ([`InputKind::GamepadArrival`](punktfunk_core::input::InputKind::GamepadArrival)), so ONE
/// session can MIX controller types — pad 0 a DualSense, pad 1 an Xbox pad. A pad the client never
/// declares uses `default` (the session kind resolved from the Hello — the pre-existing single-kind
/// behaviour).
///
/// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)),
/// the in-tree XUSB companion driver (classic XInput) on Windows. Also the X-Box One/Series identity
/// (`PUNKTFUNK_GAMEPAD=xboxone`): the same
/// backend with the One/Series USB VID/PID so games show One/Series glyphs (XInput-identical
/// otherwise). The Linux pad carries it as a [`PadIdentity`](crate::inject::gamepad::PadIdentity).
/// - `DualSense` (`PUNKTFUNK_GAMEPAD=dualsense`) — virtual DualSense via UHID + `hid-playstation`,
/// so a game sees a *real* DualSense (adaptive triggers, lightbar, touchpad, motion); feedback
/// flows back over the rich HID-output plane.
/// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar,
/// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute).
/// Backends are created lazily per kind (an empty manager holds no device), and each owns only the
/// indices routed to it. A manager's `active_mask` unplug sweep stays correct across managers
/// because an index another manager owns is `None` in this one, so the sweep never touches it.
///
/// DualShock 4 + One/Series are Linux-only; DualSense has both a Linux (UHID) and a Windows (UMDF
/// minidriver) backend. The resolver folds any type a platform can't build into `Xbox360`, so a
/// build never constructs a variant it lacks.
enum PadBackend {
Xbox360(crate::inject::gamepad::GamepadManager),
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
/// two identities), the XUSB companion driver (classic XInput) on Windows.
/// - DualSense / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF minidriver.
/// - Steam Deck — Linux UHID `hid-steam`.
///
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
/// constructs a manager the build lacks.
struct Pads {
/// Declared (and host-resolved) kind per pad index; `default` until a `GamepadArrival` lands.
kinds: [GamepadPref; MAX_WIRE_PADS],
/// The kind of the manager that currently OWNS a built device at each index (`None` = no
/// device). A live device stays in its manager even if `kinds[idx]` later changes (the rare
/// arrival-after-first-frame reorder), so a pad is never duplicated across managers and its
/// removal always reaches the manager that actually holds it.
owner: [Option<GamepadPref>; MAX_WIRE_PADS],
xbox360: Option<crate::inject::gamepad::GamepadManager>,
#[cfg(target_os = "linux")]
DualSense(crate::inject::dualsense::DualSenseManager),
xboxone: Option<crate::inject::gamepad::GamepadManager>,
#[cfg(target_os = "linux")]
DualShock4(crate::inject::dualshock4::DualShock4Manager),
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
#[cfg(target_os = "linux")]
SteamDeck(crate::inject::steam_controller::SteamControllerManager),
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
#[cfg(target_os = "linux")]
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "windows")]
DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager),
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")]
DualShock4Windows(crate::inject::dualshock4_windows::DualShock4WindowsManager),
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
}
impl PadBackend {
/// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference,
/// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only ever
/// construct the X-Box backend, whatever the resolution said.
fn select(kind: GamepadPref) -> PadBackend {
#[cfg(target_os = "linux")]
match kind {
GamepadPref::DualSense => {
tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)");
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new());
}
GamepadPref::DualShock4 => {
tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)");
return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new());
}
GamepadPref::SteamDeck => {
tracing::info!("gamepad backend: virtual Steam Deck (UHID hid-steam)");
return PadBackend::SteamDeck(
crate::inject::steam_controller::SteamControllerManager::new(),
);
}
GamepadPref::XboxOne => {
tracing::info!("gamepad backend: uinput X-Box One/Series pad");
return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity(
crate::inject::gamepad::PadIdentity::xbox_one(),
));
}
_ => {}
impl Pads {
/// `default` is the session kind (see [`resolve_gamepad`]); every pad starts on it until the
/// client declares its own kind.
fn new(default: GamepadPref) -> Pads {
let default = resolve_pad_kind(default);
tracing::info!(
default = default.as_str(),
"gamepad backends: per-pad router (session default)"
);
Pads {
kinds: [default; MAX_WIRE_PADS],
owner: [None; MAX_WIRE_PADS],
xbox360: None,
#[cfg(target_os = "linux")]
xboxone: None,
#[cfg(target_os = "linux")]
dualsense: None,
#[cfg(target_os = "linux")]
dualshock4: None,
#[cfg(target_os = "linux")]
steamdeck: None,
#[cfg(target_os = "windows")]
dualsense_win: None,
#[cfg(target_os = "windows")]
dualshock4_win: None,
}
#[cfg(target_os = "windows")]
match kind {
GamepadPref::DualSense => {
tracing::info!("gamepad backend: virtual DualSense (Windows UMDF shm channel)");
return PadBackend::DualSenseWindows(
crate::inject::dualsense_windows::DualSenseWindowsManager::new(),
);
}
GamepadPref::DualShock4 => {
tracing::info!("gamepad backend: virtual DualShock 4 (Windows UMDF shm channel)");
return PadBackend::DualShock4Windows(
crate::inject::dualshock4_windows::DualShock4WindowsManager::new(),
);
}
_ => {}
}
/// Record a pad's client-declared kind (resolved to a buildable backend). Takes effect on the
/// pad's next frame; the arrival is sent before the pad's first input, so a device already
/// built under the wrong kind is only the rare arrival-after-first-frame reorder — it then
/// keeps the earlier kind until re-plug (no live device swap).
fn set_kind(&mut self, idx: usize, kind: GamepadPref) {
if idx >= MAX_WIRE_PADS {
return;
}
let _ = kind;
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new())
let resolved = resolve_pad_kind(kind);
if self.kinds[idx] != resolved {
tracing::info!(
pad = idx,
kind = resolved.as_str(),
"gamepad kind declared (per-pad)"
);
}
self.kinds[idx] = resolved;
}
fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
match self {
PadBackend::Xbox360(m) => m.handle(ev),
use crate::gamestream::gamepad::GamepadEvent;
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
// removal frame emitted by the native detach path (`GamepadRemove`).
let (idx, present) = match ev {
GamepadEvent::State(f) => {
let idx = f.index as usize;
(idx, f.active_mask & (1 << idx) != 0)
}
GamepadEvent::Arrival { index, .. } => (*index as usize, true),
};
if idx >= MAX_WIRE_PADS {
return;
}
let (kind, new_owner) = route_decision(self.owner[idx], self.kinds[idx], present);
self.owner[idx] = new_owner;
self.route_handle(kind, ev);
}
/// Dispatch a decoded event to the manager for `kind`, creating it lazily.
fn route_handle(&mut self, kind: GamepadPref, ev: &crate::gamestream::gamepad::GamepadEvent) {
match kind {
#[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.handle(ev),
GamepadPref::DualSense => self
.dualsense
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.handle(ev),
GamepadPref::DualShock4 => self
.dualshock4
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
.handle(ev),
#[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.handle(ev),
GamepadPref::SteamDeck => self
.steamdeck
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::XboxOne => self
.xboxone
.get_or_insert_with(|| {
crate::inject::gamepad::GamepadManager::with_identity(
crate::inject::gamepad::PadIdentity::xbox_one(),
)
})
.handle(ev),
#[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.handle(ev),
GamepadPref::DualSense => self
.dualsense_win
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
.handle(ev),
#[cfg(target_os = "windows")]
PadBackend::DualShock4Windows(m) => m.handle(ev),
GamepadPref::DualShock4 => self
.dualshock4_win
.get_or_insert_with(
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
)
.handle(ev),
_ => self
.xbox360
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
.handle(ev),
}
}
/// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no
/// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors.
fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) {
match self {
PadBackend::Xbox360(_) => {}
/// Apply a rich client→host event (touchpad / motion) to the pad's kind manager, if it exists
/// (rich before the first frame = no device yet = a no-op anyway). The X-Box pads have no rich
/// plane, so those indices ignore it.
fn apply_rich(&mut self, rich: punktfunk_core::quic::RichInput) {
use punktfunk_core::quic::RichInput;
let idx = match rich {
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
// Route to the manager that actually owns the device (falling back to the declared kind
// before the first frame builds it), so a pad's touchpad/motion never lands on the wrong
// backend after a kind change.
let kind = self
.owner
.get(idx)
.copied()
.flatten()
.or_else(|| self.kinds.get(idx).copied())
.unwrap_or(GamepadPref::Xbox360);
match kind {
#[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.apply_rich(_rich),
#[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.apply_rich(_rich),
#[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.apply_rich(_rich),
#[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.apply_rich(_rich),
#[cfg(target_os = "windows")]
PadBackend::DualShock4Windows(m) => m.apply_rich(_rich),
}
}
/// Service feedback every cycle. `rumble` carries motor force-feedback on the universal plane
/// (every backend); `hidout` carries rich feedback on the HID-output plane — lightbar (both
/// UHID pads), plus player LEDs / adaptive triggers (DualSense only). The X-Box pad has no
/// rich-feedback plane.
fn pump(
&mut self,
rumble: impl FnMut(u16, u16, u16),
hidout: impl FnMut(punktfunk_core::quic::HidOutput),
) {
match self {
PadBackend::Xbox360(m) => {
let _ = hidout; // the X-Box pad has no rich-feedback plane
m.pump_rumble(rumble)
GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.pump(rumble, hidout),
GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4 {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.pump(rumble, hidout),
#[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.pump(rumble, hidout),
GamepadPref::SteamDeck => {
if let Some(m) = &mut self.steamdeck {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout),
GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
PadBackend::DualShock4Windows(m) => m.pump(rumble, hidout),
GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4_win {
m.apply_rich(rich)
}
}
_ => {}
}
}
/// Keep a virtual UHID pad alive during input silence: re-emit its current HID report if it's
/// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as
/// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev
/// holds last-known state with no periodic-report requirement). Called every input-thread tick;
/// the per-pad gap timer (not the tick rate) governs the actual emit cadence.
fn heartbeat(&mut self) {
match self {
PadBackend::Xbox360(_) => {}
#[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "windows")]
PadBackend::DualShock4Windows(m) => m.heartbeat(std::time::Duration::from_millis(8)),
/// Service feedback for every instantiated backend each cycle. `rumble` carries motor
/// force-feedback on the universal plane (every backend, tagged with its own pad index);
/// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
/// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend.
fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(punktfunk_core::quic::HidOutput),
) {
if let Some(m) = &mut self.xbox360 {
m.pump_rumble(&mut rumble); // the X-Box pad has no rich-feedback plane
}
#[cfg(target_os = "linux")]
{
if let Some(m) = &mut self.xboxone {
m.pump_rumble(&mut rumble);
}
if let Some(m) = &mut self.dualsense {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4 {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamdeck {
m.pump(&mut rumble, &mut hidout);
}
}
#[cfg(target_os = "windows")]
{
if let Some(m) = &mut self.dualsense_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4_win {
m.pump(&mut rumble, &mut hidout);
}
}
}
/// Keep every instantiated virtual UHID/UMDF pad alive during input silence (re-emit its HID
/// report so the kernel driver / SDL don't drop a held-steady pad). The X-Box pads need no
/// heartbeat (evdev holds last-known state). Per-pad gap timers inside each manager govern the
/// actual emit cadence, not this per-tick call.
fn heartbeat(&mut self) {
#[cfg(target_os = "linux")]
{
let gap = std::time::Duration::from_millis(8);
if let Some(m) = &mut self.dualsense {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4 {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamdeck {
m.heartbeat(gap);
}
}
#[cfg(target_os = "windows")]
{
let gap = std::time::Duration::from_millis(8);
if let Some(m) = &mut self.dualsense_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4_win {
m.heartbeat(gap);
}
}
}
}
/// The per-pad routing decision for one frame ([`Pads::handle`]): given `owner` (the manager
/// holding a live device at this index, if any), the client-`declared` kind, and whether this is a
/// create/update frame (`present`) vs a removal, return `(kind to route to, new owner)`.
///
/// A live device stays in its owning manager even if the declared kind later changes (so a pad is
/// never duplicated across managers); the declared kind takes effect only when no device exists
/// yet; a removal routes to the owner's manager (so it tears the right device down) and clears the
/// owner.
fn route_decision(
owner: Option<GamepadPref>,
declared: GamepadPref,
present: bool,
) -> (GamepadPref, Option<GamepadPref>) {
match (owner, present) {
(Some(k), true) => (k, Some(k)), // keep the existing device in its manager
(Some(k), false) => (k, None), // removal → owner's manager, then clear
(None, true) => (declared, Some(declared)), // create in the declared kind's manager
(None, false) => (declared, None), // removal with no device — a harmless no-op
}
}
/// Resolve one client-declared per-pad kind to a backend this host can actually build (mixed
/// types): the platform map + the runtime UHID / Steam-conflict degrades that [`resolve_gamepad`]
/// applies to the session default, minus the Auto/env session logic (a per-pad declaration is
/// always a concrete kind).
fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref {
let chosen = pick_gamepad(
kind,
None,
cfg!(target_os = "linux"),
cfg!(target_os = "windows"),
);
degrade_steam_on_conflict(degrade_if_no_uhid(chosen))
}
/// One client→host input item, both planes on ONE channel so the input thread wakes the
@@ -1956,8 +2105,9 @@ fn send_rumble(
}
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the
/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich
/// service (`inj_tx`) and gamepad events to this session's [`Pads`] router (`gamepad` — the
/// resolved Hello preference is the per-pad default; clients declare each pad's kind so a session
/// can mix uinput X-Box pads and virtual DualSense pads), with rich
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
@@ -1975,7 +2125,7 @@ fn input_thread(
inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref,
) {
let mut pads = PadBackend::select(gamepad);
let mut pads = Pads::new(gamepad);
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
// is 5000 u32s.
@@ -2098,6 +2248,44 @@ fn input_thread(
}
}
}
InputKind::GamepadRemove => {
// Mid-session hot-unplug from a snapshot-capable client (the native plane's
// `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence
// space as snapshots, so a snapshot the network reordered past this removal
// is dropped (older seq) and can't resurrect the pad — while a later re-plug
// on the same index arrives with a still-newer seq and is accepted. Clearing
// the `active_mask` bit and re-emitting the frame fires every backend's
// unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device.
let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags);
let idx = pad as usize;
if idx < MAX_WIRE_PADS
&& punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx])
{
pad_seq[idx] = Some(seq);
if pad_mask & (1 << idx) != 0 {
pad_mask &= !(1 << idx);
pad_state[idx] = PadState::default();
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
}
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
rumble_state[idx] = (0, 0);
rumble_seen[idx] = false;
rumble_seq[idx] = 0;
rumble_stop_burst[idx] = 0;
}
}
InputKind::GamepadArrival => {
// Per-pad controller kind declaration (mixed types): route this pad's future
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
// `flags` = pad index. Applied before the pad's first frame (the client sends it
// on slot open), so the device is built as the right type from the start.
let idx = ev.flags as usize;
let kind = GamepadPref::from_u8(ev.code as u8);
pads.set_kind(idx, kind);
}
_ => {
// Track press/release so a mid-press disconnect can be undone below.
match ev.kind {
@@ -4766,6 +4954,34 @@ fn build_pipeline(
mod tests {
use super::*;
#[test]
fn per_pad_route_decision() {
use GamepadPref::{DualSense, Xbox360};
// First frame with no device: create in the declared kind's manager, record ownership.
assert_eq!(
route_decision(None, DualSense, true),
(DualSense, Some(DualSense))
);
// Subsequent frame: stays in the owning manager even if the declared kind now differs
// (the arrival-after-first-frame reorder) — never a second device in another manager.
assert_eq!(
route_decision(Some(DualSense), Xbox360, true),
(DualSense, Some(DualSense))
);
// Removal (cleared bit): routes to the owner so the RIGHT device is torn down, then clears.
assert_eq!(
route_decision(Some(DualSense), Xbox360, false),
(DualSense, None)
);
// Removal with no device is a harmless no-op route (owner stays cleared).
assert_eq!(route_decision(None, Xbox360, false), (Xbox360, None));
// A fresh device after a re-plug picks up the newly-declared kind (owner was cleared).
assert_eq!(
route_decision(None, Xbox360, true),
(Xbox360, Some(Xbox360))
);
}
#[test]
fn live_mode_pack_roundtrips_and_interval_recovers_hz() {
// The live-stats mode slot (H3): pack → unpack is exact for real modes.