` 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)]);
+ }
+}
diff --git a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs
index f1a80f04..152e98e8 100644
--- a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs
+++ b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs
@@ -1,10 +1,6 @@
-//! Transport-independent DualShock 4 HID contract — the pure report codec used by the Windows
-//! UMDF-driver backend ([`super::dualshock4_windows`]).
-//!
-//! FIXME(ds4-dedup): the Linux UHID backend ([`super::dualshock4`]) still carries its own byte-
-//! identical copy of this codec (`serialize_state` / `parse_ds4_output` / `Ds4Feedback` / the touch
-//! dims). Fold it onto this module once the Linux build can be re-validated (it is `cfg(linux)`, so
-//! it can't be compile-checked from a Windows host). Keep the two in sync until then.
+//! Transport-independent DualShock 4 HID contract — the pure report codec shared by the Windows
+//! UMDF-driver backend ([`super::dualshock4_windows`]) and the Linux UHID backend
+//! ([`super::dualshock4`]).
//!
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
@@ -17,7 +13,6 @@
//! dualshock4_input_report_usb` / `_output_report_common` parse.
use super::dualsense_proto::{DsState, Touch};
-use punktfunk_core::quic::HidOutput;
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
pub const DS4_VENDOR: u16 = 0x054C;
@@ -77,11 +72,10 @@ pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter
}
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
-/// 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player LEDs or adaptive
-/// triggers, so those never appear).
+/// 0xCA plane; the lightbar rides the HID-output 0xCD plane as a `Led` event (DS4 has no player LEDs
+/// or adaptive triggers, so those never appear).
#[derive(Default)]
pub struct Ds4Feedback {
- pub hidout: Vec,
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
pub rumble: Option<(u16, u16)>,
/// Lightbar RGB, if the report carried it (deduped by the manager).
@@ -149,6 +143,14 @@ mod tests {
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
assert_eq!(r[35] & 0x7F, 0); // contact id 0
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
+
+ // A rich-plane pad click (`touch_click`, no BTN_TOUCHPAD in the frame) rides the
+ // touchpad-click bit at byte 7 bit 1 via `buttons2_with_click` — the Linux backend used to
+ // serialize raw `buttons[2]` here and drop it.
+ assert_eq!(r[7] & 0x02, 0); // no click yet
+ st.touch_click[0] = true;
+ serialize_state(&mut r, &st, 0, 0);
+ assert_eq!(r[7] & 0x02, 0x02);
}
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
diff --git a/crates/punktfunk-host/src/inject/uhid_manager.rs b/crates/punktfunk-host/src/inject/uhid_manager.rs
new file mode 100644
index 00000000..ee83f0b7
--- /dev/null
+++ b/crates/punktfunk-host/src/inject/uhid_manager.rs
@@ -0,0 +1,468 @@
+//! The generic stateful virtual-pad manager ([`UhidManager`]) shared by the five backends that
+//! keep a full per-pad report state (Linux UHID DualSense / DualShock 4 / Steam Deck, Windows UMDF
+//! DualSense / DualShock 4): event routing, the frame merge, rich-input application, the silence
+//! heartbeat, and the feedback pump with rumble + hidout dedup are written once here; a backend
+//! supplies only its per-controller pieces via [`PadProto`]. The stateless backends (Linux uinput,
+//! Windows XUSB) write frames straight through with no state vec / heartbeat / rich plane, so they
+//! use [`PadSlots`] directly instead.
+
+use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame, MAX_PADS};
+use crate::inject::dualsense_proto::HidoutDedup;
+use crate::inject::pad_slots::PadSlots;
+use anyhow::Result;
+use punktfunk_core::quic::{HidOutput, RichInput};
+use std::time::{Duration, Instant};
+
+/// What one feedback pass extracted from a pad's driver/kernel channel. `rumble` rides the
+/// universal 0xCA plane (deduped against the last-forwarded level); `hidout` carries the rich
+/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
+#[derive(Default)]
+pub struct PadFeedback {
+ /// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
+ pub rumble: Option<(u16, u16)>,
+ pub hidout: Vec,
+}
+
+/// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot
+/// share because it differs per protocol: the transport open, the report-state model and its
+/// GameStream/rich-input mappers, the state write, and the feedback poll.
+///
+/// The `&mut self` receivers let a backend carry configuration (the Steam-paddle remap policy, a
+/// pad identity); most implementations are otherwise stateless.
+pub trait PadProto {
+ /// The per-pad transport (a UHID fd, a UMDF shared-memory channel, the Deck transport enum).
+ type Pad;
+ /// The pad's full report state (`DsState`, `SteamState`) — `Copy` like both of those, so the
+ /// manager can hand a snapshot to [`write_state`](Self::write_state) without borrow gymnastics.
+ type State: Copy;
+
+ /// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"`.
+ const LABEL: &'static str;
+ /// Device name in the create-failure line ("virtual `` creation failed …").
+ const DEVICE: &'static str;
+ /// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
+ const CREATE_HINT: &'static str;
+
+ /// Open the virtual pad for wire index `idx`, logging its own success line (it knows the
+ /// transport detail worth printing); failures are logged by the manager's create gate.
+ fn open(&mut self, idx: u8) -> Result;
+ /// The all-neutral report state a fresh or unplugged pad (re)starts from.
+ fn neutral(&self) -> Self::State;
+ /// Fold one decoded button/stick frame into a new state, preserving from `prev` every field
+ /// that arrives on the rich plane instead (touch contacts / clicks, motion) — the G2 hook, in
+ /// one place per backend. Paddle remap policy is applied here too.
+ fn merge_frame(&self, prev: &Self::State, f: &GamepadFrame) -> Self::State;
+ /// Apply one rich client→host event (touchpad contact / motion sample) to the state.
+ fn apply_rich(&self, st: &mut Self::State, rich: RichInput);
+ /// Write the full state to the pad (best-effort; the next frame or heartbeat re-syncs).
+ fn write_state(&self, pad: &mut Self::Pad, st: &Self::State);
+ /// Poll the pad's driver/kernel channel: answer any pending handshake and return the feedback
+ /// it carried. `idx` is the wire pad index (the DualSense GET_REPORT replies need it).
+ fn service(&self, pad: &mut Self::Pad, idx: u8) -> PadFeedback;
+ /// Whether this pad needs a heartbeat write NOW regardless of the silence gap (the Steam
+ /// backend streams through its gamepad-mode-entry pulse).
+ fn force_heartbeat(&self, _pad: &Self::Pad) -> bool {
+ false
+ }
+}
+
+/// All virtual pads of one stateful backend, driven from decoded controller events — the shared
+/// skeleton of the five UHID/UMDF managers. Method surface (`new` / `handle` / `apply_rich` /
+/// `pump` / `heartbeat`) is exactly what the session input thread already drives, so each backend
+/// re-exports itself as a `pub type … = UhidManager<…Proto>;` alias.
+pub struct UhidManager {
+ backend: B,
+ slots: PadSlots,
+ /// Each pad's current full report — buttons/sticks merged with persisted rich-plane fields.
+ state: Vec,
+ /// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send it.
+ last_rumble: Vec<(u16, u16)>,
+ /// Last rich feedback forwarded per pad, so an output report that only changed the rumble
+ /// doesn't re-send unchanged lightbar/LED/trigger state.
+ hidout_dedup: Vec,
+ /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
+ last_write: Vec,
+}
+
+impl UhidManager {
+ pub fn new() -> UhidManager {
+ UhidManager::with_backend(B::default())
+ }
+}
+
+impl Default for UhidManager {
+ fn default() -> UhidManager {
+ UhidManager::new()
+ }
+}
+
+impl UhidManager {
+ pub fn with_backend(backend: B) -> UhidManager {
+ let state = (0..MAX_PADS).map(|_| backend.neutral()).collect();
+ UhidManager {
+ backend,
+ slots: PadSlots::new(B::LABEL, B::DEVICE, B::CREATE_HINT),
+ state,
+ last_rumble: vec![(0, 0); MAX_PADS],
+ hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
+ last_write: vec![Instant::now(); MAX_PADS],
+ }
+ }
+
+ /// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
+ pub fn handle(&mut self, ev: &GamepadEvent) {
+ match ev {
+ GamepadEvent::Arrival { index, kind, .. } => {
+ tracing::info!(index, kind, "controller arrival ({})", B::LABEL);
+ self.ensure(*index as usize);
+ }
+ GamepadEvent::State(f) => {
+ let idx = f.index as usize;
+ if idx >= MAX_PADS {
+ return;
+ }
+ // Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
+ let swept = self.slots.sweep(f.active_mask);
+ for i in 0..MAX_PADS {
+ if swept & (1 << i) != 0 {
+ self.reset_pad(i);
+ }
+ }
+ if f.active_mask & (1 << idx) == 0 {
+ return; // this event WAS the unplug
+ }
+ self.ensure(idx);
+ // Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields
+ // (touch + motion arrive separately and must survive a button-only frame).
+ self.state[idx] = self.backend.merge_frame(&self.state[idx], f);
+ self.write(idx);
+ }
+ }
+ }
+
+ /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
+ /// preserving its button/stick state. Rich events never create a pad (a controller must have
+ /// arrived first); they're dropped if the pad isn't present.
+ pub fn apply_rich(&mut self, rich: RichInput) {
+ let idx = match rich {
+ RichInput::Touchpad { pad, .. }
+ | RichInput::Motion { pad, .. }
+ | RichInput::TouchpadEx { pad, .. } => pad as usize,
+ };
+ if idx >= MAX_PADS || self.slots.get(idx).is_none() {
+ return;
+ }
+ self.backend.apply_rich(&mut self.state[idx], rich);
+ self.write(idx);
+ }
+
+ /// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` (or the backend
+ /// forces a write). The UHID/UMDF drivers treat a multi-second input silence — a held-steady
+ /// stick produces no wire events — as an unplugged controller; re-sending the current state is
+ /// idempotent (a stale-but-correct frame, never a phantom input).
+ pub fn heartbeat(&mut self, max_gap: Duration) {
+ let now = Instant::now();
+ for i in 0..MAX_PADS {
+ let Some(pad) = self.slots.get(i) else {
+ continue;
+ };
+ if self.backend.force_heartbeat(pad)
+ || now.duration_since(self.last_write[i]) >= max_gap
+ {
+ self.write(i);
+ }
+ }
+ }
+
+ /// Service every pad: answer any pending driver/kernel handshake and route a game's feedback
+ /// back out. `rumble` is invoked `(index, low, high)` only when the motor level *changes* (the
+ /// universal 0xCA plane); `hidout` is invoked per rich feedback event that isn't an exact
+ /// repeat of the last-forwarded value (the 0xCD plane). Call frequently — kernel/driver init
+ /// handshakes block until answered.
+ pub fn pump(
+ &mut self,
+ mut rumble: impl FnMut(u16, u16, u16),
+ mut hidout: impl FnMut(HidOutput),
+ ) {
+ for i in 0..MAX_PADS {
+ let Some(pad) = self.slots.get_mut(i) else {
+ continue;
+ };
+ let fb = self.backend.service(pad, i as u8);
+ if let Some(r) = fb.rumble {
+ if self.last_rumble[i] != r {
+ self.last_rumble[i] = r;
+ rumble(i as u16, r.0, r.1);
+ }
+ }
+ for h in fb.hidout {
+ // Skip rich feedback that repeats the last-forwarded value (a game's output report
+ // re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
+ if self.hidout_dedup[i].should_forward(&h) {
+ hidout(h);
+ }
+ }
+ }
+ }
+
+ /// Write the pad's current state (if it exists) and reset its heartbeat clock — on every write
+ /// (real input or heartbeat), so an actively-used pad emits no extra reports.
+ fn write(&mut self, idx: usize) {
+ let st = self.state[idx];
+ if let Some(pad) = self.slots.get_mut(idx) {
+ self.backend.write_state(pad, &st);
+ }
+ self.last_write[idx] = Instant::now();
+ }
+
+ /// Gate-checked create; a FRESH pad starts from neutral state + re-armed dedups.
+ fn ensure(&mut self, idx: usize) {
+ let backend = &mut self.backend;
+ if self.slots.ensure(idx, |i| backend.open(i)) {
+ self.reset_pad(idx);
+ }
+ }
+
+ /// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
+ /// (re)connect starts from scratch and is always forwarded.
+ fn reset_pad(&mut self, idx: usize) {
+ self.state[idx] = self.backend.neutral();
+ self.last_rumble[idx] = (0, 0);
+ self.hidout_dedup[idx].clear();
+ self.last_write[idx] = Instant::now();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::cell::RefCell;
+
+ /// Scripted mock: `open` fails while `fail_opens > 0`; `service` replays canned feedback;
+ /// `MockState` carries a marker for the frame-merge preserve check.
+ #[derive(Default)]
+ struct MockProto {
+ fail_opens: RefCell,
+ feedback: RefCell>,
+ force_hb: bool,
+ }
+
+ #[derive(Clone, Copy, Default, PartialEq, Debug)]
+ struct MockState {
+ buttons: u32,
+ /// Stands in for the rich-plane fields (touch/motion/clicks): set by `apply_rich`,
+ /// must survive `merge_frame`.
+ rich_marker: u16,
+ }
+
+ /// Per-pad transport stub recording every state write.
+ #[derive(Default)]
+ struct MockPad {
+ writes: RefCell>,
+ }
+
+ impl PadProto for MockProto {
+ type Pad = MockPad;
+ type State = MockState;
+ const LABEL: &'static str = "Mock";
+ const DEVICE: &'static str = "mock pad";
+ const CREATE_HINT: &'static str = "";
+
+ fn open(&mut self, _idx: u8) -> Result {
+ let mut fails = self.fail_opens.borrow_mut();
+ if *fails > 0 {
+ *fails -= 1;
+ anyhow::bail!("scripted open failure");
+ }
+ Ok(MockPad::default())
+ }
+ fn neutral(&self) -> MockState {
+ MockState::default()
+ }
+ fn merge_frame(&self, prev: &MockState, f: &GamepadFrame) -> MockState {
+ MockState {
+ buttons: f.buttons,
+ rich_marker: prev.rich_marker, // the preserve-rich-fields contract
+ }
+ }
+ fn apply_rich(&self, st: &mut MockState, rich: RichInput) {
+ if let RichInput::Touchpad { x, .. } = rich {
+ st.rich_marker = x;
+ }
+ }
+ fn write_state(&self, pad: &mut MockPad, st: &MockState) {
+ pad.writes.borrow_mut().push(*st);
+ }
+ fn service(&self, _pad: &mut MockPad, _idx: u8) -> PadFeedback {
+ let mut fb = self.feedback.borrow_mut();
+ if fb.is_empty() {
+ PadFeedback::default()
+ } else {
+ fb.remove(0)
+ }
+ }
+ fn force_heartbeat(&self, _pad: &MockPad) -> bool {
+ self.force_hb
+ }
+ }
+
+ fn frame(idx: i16, mask: u16, buttons: u32) -> GamepadEvent {
+ GamepadEvent::State(GamepadFrame {
+ index: idx,
+ active_mask: mask,
+ buttons,
+ ..Default::default()
+ })
+ }
+
+ fn touch(pad: u8, x: u16) -> RichInput {
+ RichInput::Touchpad {
+ pad,
+ finger: 0,
+ active: true,
+ x,
+ y: 0,
+ }
+ }
+
+ fn mgr() -> UhidManager {
+ UhidManager::new()
+ }
+
+ #[test]
+ fn arrival_eager_creates_the_pad() {
+ // G10 as a generic regression test: Arrival must build the device before the first frame.
+ let mut m = mgr();
+ m.handle(&GamepadEvent::Arrival {
+ index: 2,
+ kind: 1,
+ capabilities: 0,
+ });
+ assert!(m.slots.get(2).is_some());
+ }
+
+ #[test]
+ fn button_frame_preserves_rich_fields_and_writes_merged_state() {
+ // G2 as a generic regression test: rich-plane state must survive a button-only frame.
+ let mut m = mgr();
+ m.handle(&frame(0, 0b1, 0));
+ m.apply_rich(touch(0, 777));
+ m.handle(&frame(0, 0b1, 0xA));
+ let pad = m.slots.get(0).unwrap();
+ let writes = pad.writes.borrow();
+ let last = writes.last().unwrap();
+ assert_eq!(last.buttons, 0xA);
+ assert_eq!(last.rich_marker, 777); // preserved across the merge
+ }
+
+ #[test]
+ fn removal_frame_never_recreates_the_pad_it_swept() {
+ let mut m = mgr();
+ m.handle(&frame(1, 0b10, 0));
+ assert!(m.slots.get(1).is_some());
+ // Bit 1 cleared and the frame IS pad 1's removal — sweep, then early-return (no ensure).
+ m.handle(&frame(1, 0b00, 0));
+ assert!(m.slots.get(1).is_none());
+ }
+
+ #[test]
+ fn rich_event_for_an_absent_pad_is_dropped_and_never_creates() {
+ let mut m = mgr();
+ m.apply_rich(touch(3, 42));
+ assert!(m.slots.get(3).is_none());
+ // …and it left no state behind: a later create starts truly neutral.
+ m.handle(&frame(3, 0b1000, 0));
+ assert_eq!(m.state[3].rich_marker, 0);
+ }
+
+ #[test]
+ fn create_failure_backs_off_then_state_still_tracks() {
+ let mut m = mgr();
+ *m.backend.fail_opens.borrow_mut() = 1;
+ m.handle(&frame(0, 0b1, 0x1));
+ // Open failed: no pad, but the merged state is tracked (matching the old managers).
+ assert!(m.slots.get(0).is_none());
+ assert_eq!(m.state[0].buttons, 0x1);
+ // Next frame inside the backoff window: still no pad, no panic.
+ m.handle(&frame(0, 0b1, 0x3));
+ assert!(m.slots.get(0).is_none());
+ assert_eq!(m.state[0].buttons, 0x3);
+ }
+
+ #[test]
+ fn rumble_dedup_forwards_changes_only_and_rearms_on_recreate() {
+ let mut m = mgr();
+ m.handle(&frame(0, 0b1, 0));
+ let collect = |m: &mut UhidManager| {
+ let out = RefCell::new(Vec::new());
+ m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
+ out.into_inner()
+ };
+ let rumble = |r| PadFeedback {
+ rumble: Some(r),
+ hidout: Vec::new(),
+ };
+ *m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))];
+ assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
+ assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
+ assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
+ // Unplug + recreate re-arms the dedup: the same level forwards again.
+ m.handle(&frame(0, 0b0, 0));
+ m.handle(&frame(0, 0b1, 0));
+ *m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
+ assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
+ }
+
+ #[test]
+ fn hidout_dedup_drops_exact_repeats() {
+ let mut m = mgr();
+ m.handle(&frame(0, 0b1, 0));
+ let led = |r| HidOutput::Led {
+ pad: 0,
+ r,
+ g: 0,
+ b: 0,
+ };
+ *m.backend.feedback.borrow_mut() = vec![PadFeedback {
+ rumble: None,
+ hidout: vec![led(10), led(10), led(20)],
+ }];
+ let out = RefCell::new(0u32);
+ m.pump(
+ |_, _, _| {},
+ |_| {
+ *out.borrow_mut() += 1;
+ },
+ );
+ assert_eq!(out.into_inner(), 2); // 10 forwarded once, 20 forwarded; the repeat dropped
+ }
+
+ #[test]
+ fn heartbeat_reemits_silent_pads_and_honors_force() {
+ let mut m = mgr();
+ m.handle(&frame(0, 0b1, 0x5));
+ let writes = |m: &UhidManager| m.slots.get(0).unwrap().writes.borrow().len();
+ let after_frame = writes(&m);
+ // A pad written just now is NOT re-emitted under a huge gap…
+ m.heartbeat(Duration::from_secs(3600));
+ assert_eq!(writes(&m), after_frame);
+ // …but a zero gap counts it as silent and re-emits the CURRENT state.
+ m.heartbeat(Duration::ZERO);
+ assert_eq!(writes(&m), after_frame + 1);
+ assert_eq!(
+ m.slots
+ .get(0)
+ .unwrap()
+ .writes
+ .borrow()
+ .last()
+ .unwrap()
+ .buttons,
+ 0x5
+ );
+ // The backend's force flag overrides the gap entirely (the Steam mode-entry pulse).
+ m.backend.force_hb = true;
+ m.heartbeat(Duration::from_secs(3600));
+ assert_eq!(writes(&m), after_frame + 2);
+ }
+}
diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs
index 29873c30..5fdf4246 100644
--- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs
+++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs
@@ -18,17 +18,16 @@
//! must already be installed; the installer stages it.)
use super::dualsense_proto::{
- parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN,
- DS_TOUCH_H, DS_TOUCH_W,
+ parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
+ DS_TOUCH_W,
};
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::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{anyhow, Result};
-use punktfunk_core::quic::{HidOutput, RichInput};
+use punktfunk_core::quic::RichInput;
use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering};
-use std::time::{Duration, Instant};
+use std::time::Duration;
use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
@@ -60,7 +59,9 @@ pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUAL
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_` software devnode (the driver
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
/// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections.
-struct DsWinPad {
+/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
+/// Linux pads.
+pub struct DsWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
_sw: Option,
@@ -351,180 +352,90 @@ impl DsWinPad {
}
}
-/// All virtual DualSense pads of a session — the Windows analogue of
-/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
-/// thread drives either backend identically.
-pub struct DualSenseWindowsManager {
- pads: Vec>,
- state: Vec,
- last_rumble: Vec<(u16, u16)>,
- /// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an
- /// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback.
- hidout_dedup: Vec,
- last_write: Vec,
- /// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
- /// permanently disabling every pad for the session.
- gate: PadGate,
+/// The Windows-DualSense half of the shared stateful manager (see [`PadProto`]): the UMDF
+/// sealed-channel open, the same [`DsState`] mappers as `linux/dualsense.rs`, and the section
+/// feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
+pub struct DsWinProto {
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`.
remap: crate::inject::steam_remap::RemapConfig,
}
-impl Default for DualSenseWindowsManager {
- fn default() -> DualSenseWindowsManager {
- DualSenseWindowsManager::new()
- }
-}
-
-impl DualSenseWindowsManager {
- pub fn new() -> DualSenseWindowsManager {
- DualSenseWindowsManager {
- pads: (0..MAX_PADS).map(|_| None).collect(),
- state: vec![DsState::neutral(); MAX_PADS],
- last_rumble: vec![(0, 0); MAX_PADS],
- hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
- last_write: vec![Instant::now(); MAX_PADS],
- gate: PadGate::new(),
+impl Default for DsWinProto {
+ fn default() -> DsWinProto {
+ DsWinProto {
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
+}
- /// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
- pub fn handle(&mut self, ev: &GamepadEvent) {
- match ev {
- GamepadEvent::Arrival { index, kind, .. } => {
- tracing::info!(index, kind, "controller arrival (DualSense/Windows)");
- self.ensure(*index as usize);
- }
- GamepadEvent::State(f) => {
- let idx = f.index as usize;
- if idx >= MAX_PADS {
- return;
- }
- 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 (DualSense/Windows)");
- *slot = None;
- self.state[i] = DsState::neutral();
- self.last_rumble[i] = (0, 0);
- self.hidout_dedup[i].clear();
- }
- }
- if f.active_mask & (1 << idx) == 0 {
- return;
- }
- self.ensure(idx);
- let prev = self.state[idx];
- // Steam back grips have no DualSense slot — fold them onto standard buttons per the
- // configured policy (default drop) so they aren't silently lost, exactly as
- // `linux/dualsense.rs` does.
- let buttons =
- crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
- let mut s = DsState::from_gamepad(
- buttons,
- f.ls_x,
- f.ls_y,
- f.rs_x,
- f.rs_y,
- f.left_trigger,
- f.right_trigger,
- );
- s.touch = prev.touch;
- s.gyro = prev.gyro;
- s.accel = prev.accel;
- s.touch_click = prev.touch_click;
- self.state[idx] = s;
- self.write(idx);
- }
- }
+impl PadProto for DsWinProto {
+ type Pad = DsWinPad;
+ type State = DsState;
+ const LABEL: &'static str = "DualSense/Windows";
+ const DEVICE: &'static str = "DualSense";
+ const CREATE_HINT: &'static str =
+ " (install/repair: punktfunk-host.exe driver install --gamepad)";
+
+ fn open(&mut self, idx: u8) -> Result {
+ let p = DsWinPad::open(idx)?;
+ tracing::info!(
+ index = idx,
+ "virtual DualSense created (Windows UMDF shm channel)"
+ );
+ Ok(p)
}
- /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
- pub fn apply_rich(&mut self, rich: RichInput) {
- let idx = match rich {
- RichInput::Touchpad { pad, .. }
- | RichInput::Motion { pad, .. }
- | RichInput::TouchpadEx { pad, .. } => pad as usize,
- };
- if idx >= MAX_PADS || self.pads[idx].is_none() {
- return;
- }
- // The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
- // dual pads split the one touchpad left/right, pad clicks ride touch_click.
- self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
- self.write(idx);
+ fn neutral(&self) -> DsState {
+ DsState::neutral()
}
- fn write(&mut self, idx: usize) {
- let st = self.state[idx];
- if let Some(pad) = self.pads[idx].as_mut() {
- pad.write_state(&st);
- }
- self.last_write[idx] = Instant::now();
+ /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
+ /// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
+ fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
+ // Steam back grips have no DualSense slot — fold them onto standard buttons per the
+ // configured policy (default drop) so they aren't silently lost.
+ let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
+ let mut s = DsState::from_gamepad(
+ buttons,
+ f.ls_x,
+ f.ls_y,
+ f.rs_x,
+ f.rs_y,
+ f.left_trigger,
+ f.right_trigger,
+ );
+ s.touch = prev.touch;
+ s.gyro = prev.gyro;
+ s.accel = prev.accel;
+ s.touch_click = prev.touch_click;
+ s
}
- /// Re-emit each live pad's current report if it's been silent for `max_gap` (the driver's timer
- /// streams whatever's in the section, so this just keeps the section fresh / future-proofs parity
- /// with the UHID backend's heartbeat).
- pub fn heartbeat(&mut self, max_gap: Duration) {
- let now = Instant::now();
- for i in 0..self.pads.len() {
- if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
- self.write(i);
- }
- }
+ /// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
+ /// split the one touchpad left/right, pad clicks ride touch_click.
+ fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
+ st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
- fn ensure(&mut self, idx: usize) {
- if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
- return;
- }
- match DsWinPad::open(idx as u8) {
- Ok(p) => {
- tracing::info!(
- index = idx,
- "virtual DualSense created (Windows UMDF shm channel)"
- );
- self.pads[idx] = Some(p);
- self.state[idx] = DsState::neutral();
- self.last_rumble[idx] = (0, 0);
- self.hidout_dedup[idx].clear();
- self.last_write[idx] = Instant::now();
- self.gate.on_success();
- }
- Err(e) => {
- tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
- self.gate.on_failure(Instant::now());
- }
- }
+ fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
+ pad.write_state(st);
}
- /// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
- /// only on change (universal 0xCA plane); `hidout` fires for each rich DualSense feedback event
- /// (lightbar / player LEDs / adaptive triggers — 0xCD plane).
- pub fn pump(
- &mut self,
- mut rumble: impl FnMut(u16, u16, u16),
- mut hidout: impl FnMut(HidOutput),
- ) {
- for i in 0..self.pads.len() {
- let Some(pad) = self.pads[i].as_mut() else {
- continue;
- };
- let fb = pad.service(i as u8);
- if let Some(r) = fb.rumble {
- if self.last_rumble[i] != r {
- self.last_rumble[i] = r;
- rumble(i as u16, r.0, r.1);
- }
- }
- for h in fb.hidout {
- // Skip rich feedback that repeats the last-forwarded value (the game's output report
- // re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
- if self.hidout_dedup[i].should_forward(&h) {
- hidout(h);
- }
- }
+ /// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
+ /// lightbar/player-LED/trigger events on the 0xCD plane.
+ fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
+ let fb = pad.service(idx);
+ PadFeedback {
+ rumble: fb.rumble,
+ hidout: fb.hidout,
}
}
}
+
+/// All virtual DualSense pads of a session — the Windows analogue of
+/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
+/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
+/// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID
+/// backend's silence heartbeat.
+pub type DualSenseWindowsManager = UhidManager;
diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs
index 29ba5d1a..c119fa05 100644
--- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs
+++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs
@@ -16,15 +16,16 @@ use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
};
use super::gamepad_raii::PadChannel;
-use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
-use crate::inject::pad_gate::PadGate;
+use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput};
-use std::time::{Duration, Instant};
+use std::time::Duration;
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_` devnode plus the sealed
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
-struct Ds4WinPad {
+/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
+/// Linux pads.
+pub struct Ds4WinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
_sw: Option,
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
@@ -141,180 +142,96 @@ impl Ds4WinPad {
}
}
-/// All virtual DualShock 4 pads of a session — the Windows analogue of
-/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the
-/// Windows DualSense manager so the session input thread drives either backend identically.
-pub struct DualShock4WindowsManager {
- pads: Vec>,
- state: Vec,
- last_rumble: Vec<(u16, u16)>,
- last_led: Vec>,
- last_write: Vec,
- /// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
- /// permanently disabling every pad for the session.
- gate: PadGate,
+/// The Windows-DualShock-4 half of the shared stateful manager (see [`PadProto`]): the UMDF
+/// sealed-channel open (device-type 1), the same [`DsState`] mappers as `linux/dualshock4.rs`, and
+/// the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in
+/// [`UhidManager`]; the lightbar dedup that used to be a bespoke `last_led` vec now rides the
+/// shared `HidoutDedup` (identical semantics — `Led` is compared against the last-forwarded value
+/// and re-armed on create/unplug).
+pub struct Ds4WinProto {
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualshock4.rs`.
remap: crate::inject::steam_remap::RemapConfig,
}
-impl Default for DualShock4WindowsManager {
- fn default() -> DualShock4WindowsManager {
- DualShock4WindowsManager::new()
- }
-}
-
-impl DualShock4WindowsManager {
- pub fn new() -> DualShock4WindowsManager {
- DualShock4WindowsManager {
- pads: (0..MAX_PADS).map(|_| None).collect(),
- state: vec![DsState::neutral(); MAX_PADS],
- last_rumble: vec![(0, 0); MAX_PADS],
- last_led: vec![None; MAX_PADS],
- last_write: vec![Instant::now(); MAX_PADS],
- gate: PadGate::new(),
+impl Default for Ds4WinProto {
+ fn default() -> Ds4WinProto {
+ Ds4WinProto {
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
+}
- /// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
- pub fn handle(&mut self, ev: &GamepadEvent) {
- match ev {
- GamepadEvent::Arrival { index, kind, .. } => {
- tracing::info!(index, kind, "controller arrival (DualShock 4/Windows)");
- self.ensure(*index as usize);
- }
- GamepadEvent::State(f) => {
- let idx = f.index as usize;
- if idx >= MAX_PADS {
- return;
- }
- 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 (DualShock 4/Windows)");
- *slot = None;
- self.state[i] = DsState::neutral();
- self.last_rumble[i] = (0, 0);
- self.last_led[i] = None;
- }
- }
- if f.active_mask & (1 << idx) == 0 {
- return;
- }
- self.ensure(idx);
- let prev = self.state[idx];
- // Steam back grips have no DS4 slot — fold them onto standard buttons per the
- // configured policy (default drop) so they aren't silently lost, exactly as
- // `linux/dualshock4.rs` does.
- let buttons =
- crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
- let mut s = DsState::from_gamepad(
- buttons,
- f.ls_x,
- f.ls_y,
- f.rs_x,
- f.rs_y,
- f.left_trigger,
- f.right_trigger,
- );
- s.touch = prev.touch;
- s.gyro = prev.gyro;
- s.accel = prev.accel;
- s.touch_click = prev.touch_click;
- self.state[idx] = s;
- self.write(idx);
- }
- }
+impl PadProto for Ds4WinProto {
+ type Pad = Ds4WinPad;
+ type State = DsState;
+ const LABEL: &'static str = "DualShock 4/Windows";
+ const DEVICE: &'static str = "DualShock 4";
+ const CREATE_HINT: &'static str =
+ " (install/repair: punktfunk-host.exe driver install --gamepad)";
+
+ fn open(&mut self, idx: u8) -> Result {
+ let p = Ds4WinPad::open(idx)?;
+ tracing::info!(
+ index = idx,
+ "virtual DualShock 4 created (Windows UMDF shm channel)"
+ );
+ Ok(p)
}
- /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
- pub fn apply_rich(&mut self, rich: RichInput) {
- let idx = match rich {
- RichInput::Touchpad { pad, .. }
- | RichInput::Motion { pad, .. }
- | RichInput::TouchpadEx { pad, .. } => pad as usize,
- };
- if idx >= MAX_PADS || self.pads[idx].is_none() {
- return;
- }
- // The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
- // dual pads split the one touchpad left/right, pad clicks ride touch_click.
- self.state[idx].apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
- self.write(idx);
+ fn neutral(&self) -> DsState {
+ DsState::neutral()
}
- fn write(&mut self, idx: usize) {
- let st = self.state[idx];
- if let Some(pad) = self.pads[idx].as_mut() {
- pad.write_state(&st);
- }
- self.last_write[idx] = Instant::now();
+ /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
+ /// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
+ fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
+ // Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
+ // policy (default drop) so they aren't silently lost.
+ let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
+ let mut s = DsState::from_gamepad(
+ buttons,
+ f.ls_x,
+ f.ls_y,
+ f.rs_x,
+ f.rs_y,
+ f.left_trigger,
+ f.right_trigger,
+ );
+ s.touch = prev.touch;
+ s.gyro = prev.gyro;
+ s.accel = prev.accel;
+ s.touch_click = prev.touch_click;
+ s
}
- /// Re-emit each live pad's current report if it's been silent for `max_gap` (parity with the
- /// other backends' heartbeat — keeps the section fresh).
- pub fn heartbeat(&mut self, max_gap: Duration) {
- let now = Instant::now();
- for i in 0..self.pads.len() {
- if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
- self.write(i);
- }
- }
+ /// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
+ /// split the one touchpad left/right, pad clicks ride touch_click.
+ fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
+ st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
}
- fn ensure(&mut self, idx: usize) {
- if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
- return;
- }
- match Ds4WinPad::open(idx as u8) {
- Ok(p) => {
- tracing::info!(
- index = idx,
- "virtual DualShock 4 created (Windows UMDF shm channel)"
- );
- self.pads[idx] = Some(p);
- self.state[idx] = DsState::neutral();
- self.last_rumble[idx] = (0, 0);
- self.last_led[idx] = None;
- self.last_write[idx] = Instant::now();
- self.gate.on_success();
- }
- Err(e) => {
- tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
- self.gate.on_failure(Instant::now());
- }
- }
+ fn write_state(&self, pad: &mut Ds4WinPad, st: &DsState) {
+ pad.write_state(st);
}
- /// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
- /// only on change (universal 0xCA plane); `hidout` fires the lightbar (0xCD `Led`), deduped.
- pub fn pump(
- &mut self,
- mut rumble: impl FnMut(u16, u16, u16),
- mut hidout: impl FnMut(HidOutput),
- ) {
- for i in 0..self.pads.len() {
- let Some(pad) = self.pads[i].as_mut() else {
- continue;
- };
- let fb = pad.service();
- if let Some(r) = fb.rumble {
- if self.last_rumble[i] != r {
- self.last_rumble[i] = r;
- rumble(i as u16, r.0, r.1);
- }
- }
- if let Some(rgb) = fb.led {
- if self.last_led[i] != Some(rgb) {
- self.last_led[i] = Some(rgb);
- hidout(HidOutput::Led {
- pad: i as u8,
- r: rgb.0,
- g: rgb.1,
- b: rgb.2,
- });
- }
- }
+ /// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the
+ /// lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive triggers).
+ fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback {
+ let fb = pad.service();
+ PadFeedback {
+ rumble: fb.rumble,
+ hidout: fb
+ .led
+ .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
+ .into_iter()
+ .collect(),
}
}
}
+
+/// All virtual DualShock 4 pads of a session — the Windows analogue of
+/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface (via
+/// the shared [`UhidManager`]) as the Windows DualSense manager so the session input thread drives
+/// either backend identically.
+pub type DualShock4WindowsManager = UhidManager;
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