From b597bb74bdd63dcdce68b1d2e2356dc30d4252a5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:01:37 +0200 Subject: [PATCH 01/10] fix(inject/linux/ds4): fold the Linux DS4 backend onto the shared proto codec (3.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux DualShock 4 backend missed the G2-era shared-mapping work and drifted from dualshock4_proto three ways, leaving two user-visible gaps on the DS4 kind (Windows, written later against the proto, is correct): - its serialize_state duplicated the proto's byte-for-byte EXCEPT byte 7: raw st.buttons[2] instead of buttons2_with_click(), so a rich-plane pad click never reached the report; - its inline apply_rich never set touch_click and dropped the Steam LEFT pad entirely (surface 1 skipped), where the shared dualsense_proto::DsState::apply_rich splits the one touchpad left/right; - handle() didn't preserve touch_click across button-only frames. Net effect: Deck client -> Linux host on the DS4 kind = pad clicks and the left pad dead. Delete the local serialize_state/parse_ds4_output/Ds4Feedback/pack_touch and touch-dim consts in favor of dualshock4_proto (dropping the proto's keep-in-sync FIXME), route rich events through the shared DsState::apply_rich, and preserve touch_click in the frame merge exactly like the other three DS-family managers. The proto's serialize_offsets test gains a touch_click case pinning byte 7 bit 1. Verified on .21: cargo clippy -p punktfunk-host --all-targets -D warnings clean; full suite 277 pass / 0 fail. Pre-step 3.3.0 of the G12 skeleton extraction (gamepad-review-cleanup.md §3a.2) — the behavior fix lands before the mechanical dedup. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/inject.rs | 4 +- .../src/inject/linux/dualshock4.rs | 220 ++---------------- .../src/inject/proto/dualshock4_proto.rs | 18 +- 3 files changed, 30 insertions(+), 212 deletions(-) diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 0bf07aa6..dccf62c1 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -485,8 +485,8 @@ pub mod dualsense_windows; #[cfg(target_os = "linux")] #[path = "inject/linux/dualshock4.rs"] pub mod dualshock4; -/// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend -/// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.) +/// Transport-independent DualShock 4 HID codec, shared by the Linux UHID backend ([`dualshock4`]) +/// and the Windows UMDF-driver backend ([`dualshock4_windows`]). #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/proto/dualshock4_proto.rs"] pub mod dualshock4_proto; diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index 53d24f58..683e6f9a 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -8,12 +8,16 @@ //! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute //! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal //! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is -//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; only the -//! report *byte layout*, the report descriptor, the feature-report handshake and the touchpad -//! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout -//! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse. +//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; the +//! report codec (input `0x01` serializer, output `0x05` parser, touch dims) is the pure +//! [`super::dualshock4_proto`], shared with the Windows UMDF backend — this module is only the +//! `/dev/uhid` transport plus the report descriptor + feature-report handshake the kernel needs. -use super::dualsense_proto::{DsState, Touch}; +use super::dualsense_proto::DsState; +use super::dualshock4_proto::{ + parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H, + DS4_TOUCH_W, DS4_VENDOR, +}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::inject::pad_gate::PadGate; use anyhow::{Context, Result}; @@ -129,96 +133,6 @@ const DS4_RDESC: &[u8] = &[ 0xB1, 0x02, 0xC0, ]; -const DS4_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment -const DS4_PRODUCT: u32 = 0x09CC; // DualShock 4 v2 (CUH-ZCT2) -/// USB input report `0x01` is 64 bytes total (report id + 63-byte body). -const DS4_INPUT_REPORT_LEN: usize = 64; -/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower -/// than the DualSense's 1920×1080. -pub const DS4_TOUCH_W: u16 = 1920; -pub const DS4_TOUCH_H: u16 = 942; - -/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's: -/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y). -fn pack_touch(dst: &mut [u8], t: &Touch) { - dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 }; - // Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1. - let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1)); - dst[1] = (x & 0xFF) as u8; - dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4); - dst[3] = ((y >> 4) & 0xFF) as u8; -} - -/// Serialize a full DS4 input report `0x01` (pure — unit-testable without `/dev/uhid`). Field -/// offsets per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch; -/// touch[3]; rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp; -/// gyro[3] le16; accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common` -/// field at struct offset N sits at report byte N+1. -fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) { - r[0] = 0x01; // report id - r[1] = st.lx; - r[2] = st.ly; - r[3] = st.rx; - r[4] = st.ry; - r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high) - r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3 - r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter - r[8] = st.l2; // L2 analog (z) - r[9] = st.r2; // R2 analog (rz) - r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9) - // r[12] temperature stays 0 - for (i, v) in st.gyro.iter().enumerate() { - r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12 - } - for (i, v) in st.accel.iter().enumerate() { - r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18 - } - // r[25..30] reserved2. - // status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report - // wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad. - r[30] = 0x10 | 0x0B; - // r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0. - r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one) - r[34] = ts as u8; // touch_reports[0].timestamp - pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0 - pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1 - // remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero -} - -/// What one [`DualShock4Pad::service`] 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). -#[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). - pub led: Option<(u8, u8, u8)>, -} - -/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel -/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1], -/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5], -/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write -/// doesn't masquerade as a lightbar change. -fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) { - if data.first() != Some(&0x05) || data.len() < 11 { - return; // not the USB output report (BT 0x11 is shifted) / too short - } - let flag0 = data[1]; - if flag0 & 0x01 != 0 { - // motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4]; - // scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends. - let low = (data[5] as u16) << 8; - let high = (data[4] as u16) << 8; - fb.rumble = Some((low, high)); - } - if flag0 & 0x02 != 0 { - fb.led = Some((data[6], data[7], data[8])); - } -} - /// Copy a NUL-padded C string field into the event buffer. fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { let n = s.len().min(cap - 1); @@ -265,8 +179,8 @@ impl DualShock4Pad { put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64] ev[260..262].copy_from_slice(&(DS4_RDESC.len() as u16).to_ne_bytes()); // rd_size ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus - ev[264..268].copy_from_slice(&DS4_VENDOR.to_ne_bytes()); - ev[268..272].copy_from_slice(&DS4_PRODUCT.to_ne_bytes()); + ev[264..268].copy_from_slice(&(DS4_VENDOR as u32).to_ne_bytes()); + ev[268..272].copy_from_slice(&(DS4_PRODUCT as u32).to_ne_bytes()); ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country ev[280..280 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data @@ -438,6 +352,7 @@ impl DualShock4Manager { 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); } @@ -456,49 +371,9 @@ impl DualShock4Manager { if idx >= MAX_PADS || self.pads[idx].is_none() { return; } - match rich { - RichInput::Touchpad { - finger, - active, - x, - y, - .. - } => { - // The DS4 touchpad carries two contacts; clamp to a valid slot and keep the - // reported contact id consistent (the wire `finger` is untrusted). - let slot = (finger as usize).min(1); - let t = &mut self.state[idx].touch[slot]; - t.active = active; - t.id = slot as u8; - // Normalized 0..=65535 → the DS4 touchpad range (0..=W-1 / 0..=H-1). - t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16; - t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16; - } - RichInput::Motion { gyro, accel, .. } => { - self.state[idx].gyro = gyro; - self.state[idx].accel = accel; - } - RichInput::TouchpadEx { - surface, - finger, - touch, - x, - y, - .. - } => { - // A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 → - // 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent. - if surface != 1 { - let slot = (finger as usize).min(1); - let n = |v: i16| ((v as i32) + 32768) as u32; - let t = &mut self.state[idx].touch[slot]; - t.active = touch; - t.id = slot as u8; - t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16; - t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16; - } - } - } + // 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); } @@ -586,69 +461,8 @@ impl DualShock4Manager { mod tests { use super::*; - /// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets. - #[test] - fn serialize_offsets() { - use punktfunk_core::input::gamepad as gs; - let mut st = DsState::from_gamepad( - gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB, - 16384, // lx (right) - 0, - 0, - -32768, // ry (down) — inverted to 0xFF - 200, // L2 - 0, - ); - st.gyro = [0x0102, 0x0304, 0x0506]; - st.accel = [0x1112, 0x1314, 0x1516]; - st.touch[0] = Touch { - active: true, - id: 0, - x: 100, - y: 200, - }; - let mut r = [0u8; DS4_INPUT_REPORT_LEN]; - serialize_state(&mut r, &st, 0, 0); - assert_eq!(r[0], 0x01); // report id - assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5) - assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up) - assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit - assert_eq!(r[6] & 0x01, 0x01); // L1 - // gyro le16 at 13..19, accel le16 at 19..25. - assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]); - assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]); - assert_eq!(r[33], 1); // one touch frame - 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 DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a - /// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched. - #[test] - fn parse_output_rumble_and_lightbar() { - let mut report = [0u8; 32]; - report[0] = 0x05; - report[1] = 0x01 | 0x02; // MOTOR | LED - report[4] = 0x40; // motor_right (weak/high) - report[5] = 0x80; // motor_left (strong/low) - report[6] = 0x11; // R - report[7] = 0x22; // G - report[8] = 0x33; // B - let mut fb = Ds4Feedback::default(); - parse_ds4_output(&report, &mut fb); - assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak) - assert_eq!(fb.led, Some((0x11, 0x22, 0x33))); - - let mut motor_only = [0u8; 32]; - motor_only[0] = 0x05; - motor_only[1] = 0x01; // MOTOR only - motor_only[5] = 0x10; - let mut fb2 = Ds4Feedback::default(); - parse_ds4_output(&motor_only, &mut fb2); - assert!(fb2.rumble.is_some()); - assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change - } + // The report 0x01 serializer + output 0x05 parser are covered in `dualshock4_proto` (the codec + // is shared with the Windows backend); only the UHID-transport-specific pieces are tested here. /// Feature-report arrays carry the right report id + length the kernel expects. #[test] diff --git a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs index f1a80f04..9bcae868 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 @@ -149,6 +145,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 From 528a51d75cb133ad8b5e9fc43839b2322a3e05f1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:04:22 +0200 Subject: [PATCH 02/10] 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)]); + } +} From 2bea02b0ea10fc9da7ab66a58552fbd05bca4d68 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:08:25 +0200 Subject: [PATCH 03/10] feat(inject): generic PadProto + UhidManager stateful manager (3.3 layer 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared skeleton of the five stateful UHID/UMDF managers (Linux DualSense / DualShock 4 / Steam Deck, Windows DualSense / DualShock 4), written once over PadSlots: event routing with the unplug sweep and was-the-unplug early return, the merge-preserving frame fold, rich-input application, the silence heartbeat (with a backend force hook for the Steam mode-entry pulse), and the feedback pump with rumble dedup + HidoutDedup. A backend supplies only its per-controller half via PadProto: open / neutral / merge_frame / apply_rich / write_state / service — exactly where the real protocol differences live. Method surface (new/handle/apply_rich/pump/heartbeat) matches what the punktfunk1.rs Pads router already drives, so each backend will convert as a pub type alias with zero router edits. Additive only — no backend converted yet. 8 mock-backend tests make the manager lifecycle unit-testable for the first time; G2 (rich fields survive a button-only frame) and G10 (Arrival eager-creates) are now generic regression tests, plus removal-frame no-recreate, absent-pad rich drop, create-backoff state tracking, rumble/hidout dedup + re-arm on recreate, and heartbeat gap/force semantics. Verified on .21: clippy --all-targets -D warnings clean; suite 293 pass / 0 fail (285 prior + 8 new). Part of G12/3.3 (gamepad-review-cleanup.md §3a.3, commit 3 of the §3a.4 sequence). Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/inject.rs | 6 + .../punktfunk-host/src/inject/uhid_manager.rs | 468 ++++++++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 crates/punktfunk-host/src/inject/uhid_manager.rs diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 9d8eb6b7..5a8befbb 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -517,6 +517,12 @@ pub mod pad_gate; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/pad_slots.rs"] pub mod pad_slots; +/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame +/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only +/// its per-controller protocol via [`uhid_manager::PadProto`] (G12). +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[path = "inject/uhid_manager.rs"] +pub mod uhid_manager; /// 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/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); + } +} From 4d6c2394dca0aef033098a1f36604521516e1c47 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:10:42 +0200 Subject: [PATCH 04/10] refactor(inject/linux/dualsense): convert to UhidManager (3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first backend onto the shared skeleton: DualSenseManager becomes pub type DualSenseManager = UhidManager, where DsLinuxProto supplies only the protocol half (UHID open + success log, DsState neutral/merge/apply_rich with the paddle fold, best-effort write, the GET_REPORT-answering service pass). handle/apply_rich/heartbeat/pump and the unplug sweep now come from uhid_manager — behavior-identical (same log lines, same dedup + reset semantics), zero Pads-router edits. Verified on .21: clippy --all-targets -D warnings clean; full suite 290 pass / 0 fail. Part of G12/3.3 (§3a.4 commit 4). Co-Authored-By: Claude Fable 5 --- .../src/inject/linux/dualsense.rs | 252 +++++------------- 1 file changed, 71 insertions(+), 181 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index 16c05761..1ce183a5 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -13,18 +13,16 @@ //! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it. use super::dualsense_proto::{ - parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_FEATURE_CALIBRATION, + parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC, }; -use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; -use crate::inject::pad_gate::PadGate; +use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; -use punktfunk_core::quic::{HidOutput, RichInput}; +use punktfunk_core::quic::RichInput; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; -use std::time::{Duration, Instant}; // /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a // union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372). @@ -163,201 +161,93 @@ impl Drop for DualSensePad { } } -/// All virtual DualSense pads of a session — the rich-controller analog of -/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`. -/// -/// Unlike the uinput pad, a DualSense carries touchpad + motion, which arrive on a *separate* -/// rich-input plane ([`apply_rich`](Self::apply_rich)) from the button/stick frames -/// ([`handle`](Self::handle)). So the manager keeps each pad's full [`DsState`] and re-emits the -/// merged report whenever either source changes. [`pump`](Self::pump) services the kernel -/// handshake and routes a game's feedback back out: motor rumble on the universal plane, the rich -/// LED/player-LED/trigger feedback on the HID-output plane. -pub struct DualSenseManager { - pads: Vec>, - /// Each pad's current full report — buttons/sticks merged with persisted touch + motion. - state: Vec, - /// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it. - 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, - /// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which - /// re-emits the current state during input silence so the kernel never sees the device go quiet. - last_write: Vec, - /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of - /// permanently disabling every pad for the session. - gate: PadGate, +/// The DualSense-specific half of the shared stateful manager (see [`PadProto`]): UHID transport +/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Everything lifecycle- +/// shaped (slot table, unplug sweep, heartbeat, feedback dedup) lives in [`UhidManager`]. +pub struct DsLinuxProto { /// 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. remap: crate::inject::steam_remap::RemapConfig, } -impl Default for DualSenseManager { - fn default() -> DualSenseManager { - DualSenseManager::new() - } -} - -impl DualSenseManager { - pub fn new() -> DualSenseManager { - DualSenseManager { - 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 DsLinuxProto { + fn default() -> DsLinuxProto { + DsLinuxProto { 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)"); - 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. - 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)"); - *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; // this event WAS the unplug - } - self.ensure(idx); - // Merge buttons/sticks/triggers from the frame, preserving touch + motion (those - // come on the rich-input plane and must survive a button-only frame). - 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. - 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 DsLinuxProto { + type Pad = DualSensePad; + type State = DsState; + const LABEL: &'static str = "DualSense"; + const DEVICE: &'static str = "DualSense"; + const CREATE_HINT: &'static str = ""; + + fn open(&mut self, idx: u8) -> Result { + let p = DualSensePad::open(idx)?; + tracing::info!( + index = idx, + "virtual DualSense created (UHID hid-playstation)" + ); + Ok(p) } - /// 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.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() { - let _ = pad.write_state(&st); - } - // Reset the heartbeat timer on every write (real input or heartbeat), so an actively-used - // pad emits no extra reports — the heartbeat only fills genuine input-silence gaps. - self.last_write[idx] = Instant::now(); + /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those + /// come on the rich-input plane and must survive a button-only frame). + 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`. A real DualSense - /// streams report `0x01` continuously (~250 Hz); the kernel `hid-playstation` driver / Proton / - /// SDL treat a multi-second silence (a held-steady stick produces no wire events) as an - /// unplugged controller — the "controller disconnected every few seconds" symptom. Re-sending - /// the current state is idempotent (a stale-but-correct frame, never a phantom input); - /// `write_state` bumps the report's seq + timestamp, so each is a fresh, well-formed report. - 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 DualSensePad::open(idx as u8) { - Ok(p) => { - tracing::info!( - index = idx, - "virtual DualSense created (UHID hid-playstation)" - ); - 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"); - self.gate.on_failure(Instant::now()); - } - } + fn write_state(&self, pad: &mut DualSensePad, st: &DsState) { + let _ = pad.write_state(st); } - /// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble` - /// is invoked `(index, low, high)` only when the motor level *changes* (the universal 0xCA - /// plane — both backends use it); `hidout` is invoked for each DualSense-only rich feedback - /// event (lightbar / player LEDs / adaptive triggers — the 0xCD plane). Call frequently: - /// the kernel blocks `hid-playstation` init until its GET_REPORTs are answered. - 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); - } - } + /// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs + /// are answered — call frequently) and parse 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 DualSensePad, idx: u8) -> PadFeedback { + let fb = pad.service(idx); + PadFeedback { + rumble: fb.rumble, + hidout: fb.hidout, } } } + +/// All virtual DualSense pads of a session — the rich-controller analog of +/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`. +/// +/// Unlike the uinput pad, a DualSense carries touchpad + motion, which arrive on a *separate* +/// rich-input plane (`apply_rich`) from the button/stick frames (`handle`); the shared +/// [`UhidManager`] keeps each pad's full [`DsState`], re-emits the merged report whenever either +/// source changes, and heartbeats it through input silence (a real DualSense streams report `0x01` +/// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug). +pub type DualSenseManager = UhidManager; From 446818eea6b286178bca19c03711fb69043e0527 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:36:45 +0200 Subject: [PATCH 05/10] refactor(inject/windows/dualsense): convert to UhidManager (3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DualSenseWindowsManager becomes a pub type alias of UhidManager; the proto supplies the UMDF sealed-channel open (+ success log), the DsState mappers (identical to linux/dualsense.rs, paddle fold included), and the section feedback poll. Lifecycle, dedup, and heartbeat come from the shared skeleton — behavior-identical, same log lines (LABEL DualSense/Windows + the driver-install hint). DsWinPad goes pub (it appears as type Pad in the impl of the public PadProto trait — E0446 otherwise; the Linux pads were already pub). Verified on the Windows CI VM .133 (same pinned 1.96.0 MSVC toolchain + Public-path FFmpeg/LLVM the runner uses): cargo clippy -p punktfunk-host --all-targets -- -D warnings EXITCODE 0 at the DS4-conversion tip (.173 was down; .133 carries the identical toolchain). Part of G12/3.3 (§3a.4 commit 5). Co-Authored-By: Claude Fable 5 --- .../src/inject/windows/dualsense_windows.rs | 235 ++++++------------ 1 file changed, 73 insertions(+), 162 deletions(-) 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; From f1efd3091e707cfb6e39f48945cd992ddbbd9db4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:36:45 +0200 Subject: [PATCH 06/10] refactor(inject/windows/dualshock4): convert to UhidManager (3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DualShock4WindowsManager becomes a pub type alias of UhidManager. The bespoke last_led lightbar dedup folds into the shared HidoutDedup: the proto's service() converts Ds4Feedback.led into a HidOutput::Led, and HidoutDedup compares it against the last-forwarded value with the same reset-on-create/unplug semantics the Option<(u8,u8,u8)> vec had. Everything else mirrors the DualSense conversion (same DsState mappers as linux/dualshock4.rs). Ds4WinPad goes pub (type Pad in a public-trait impl, E0446 otherwise). Verified on the Windows CI VM .133: cargo clippy -p punktfunk-host --all-targets -- -D warnings EXITCODE 0 at this tip. Part of G12/3.3 (§3a.4 commit 6). Co-Authored-By: Claude Fable 5 --- .../src/inject/windows/dualshock4_windows.rs | 235 ++++++------------ 1 file changed, 76 insertions(+), 159 deletions(-) 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; From 365d4bb8f164a50c38b53bb8cf426cff245c15f2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:38:45 +0200 Subject: [PATCH 07/10] refactor(inject/linux/dualshock4): convert to UhidManager (3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DualShock4Manager becomes a pub type alias of UhidManager (the same shape as the other three DS-family conversions); the bespoke last_led lightbar dedup folds into the shared HidoutDedup exactly as the Windows DS4 conversion did. With 3.3.0 already applied, the proto half is byte-identical to Ds4WinProto except the transport open — the codec, the mappers, and now the manager all shared. Verified on .21: clippy --all-targets -D warnings clean; full suite 290 pass / 0 fail. Part of G12/3.3 (§3a.4 commit 7). Co-Authored-By: Claude Fable 5 --- .../src/inject/linux/dualshock4.rs | 249 ++++++------------ 1 file changed, 75 insertions(+), 174 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index 683e6f9a..394dc9e0 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -18,14 +18,12 @@ use super::dualshock4_proto::{ parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H, DS4_TOUCH_W, DS4_VENDOR, }; -use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; -use crate::inject::pad_gate::PadGate; +use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; -use std::time::{Duration, Instant}; // /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`. const UHID_PATH: &str = "/dev/uhid"; @@ -263,200 +261,103 @@ impl Drop for DualShock4Pad { } } -/// All virtual DualShock 4 pads of a session — the PS4 analog of -/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`. -/// Like the DualSense it keeps each pad's full [`DsState`] and re-emits the merged report whenever -/// buttons/sticks ([`handle`](Self::handle)) or touchpad/motion ([`apply_rich`](Self::apply_rich)) -/// change. [`pump`](Self::pump) services the kernel handshake and routes a game's feedback back: -/// motor rumble on the universal plane, the lightbar on the HID-output plane. -pub struct DualShock4Manager { - pads: Vec>, - /// Each pad's current full report — buttons/sticks merged with persisted touch + motion. - state: Vec, - /// Last rumble forwarded per pad, so a report that only changes the lightbar doesn't re-send it. - last_rumble: Vec<(u16, u16)>, - /// Last lightbar RGB forwarded per pad — the kernel bundles the lightbar into every output - /// report (incl. rumble-only writes), so dedup here to avoid flooding the HID-output plane. - last_led: Vec>, - /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat). - last_write: Vec, - /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of - /// permanently disabling every pad for the session. - gate: PadGate, +/// The DualShock-4-specific half of the shared stateful manager (see [`PadProto`]): UHID transport +/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Lifecycle (slot table, +/// unplug sweep, heartbeat, dedup) lives in [`UhidManager`]; the lightbar dedup that used to be a +/// bespoke `last_led` vec (the kernel bundles the lightbar into every output report, incl. +/// rumble-only writes) now rides the shared `HidoutDedup` — identical semantics, `Led` compared +/// against the last-forwarded value and re-armed on create/unplug. +pub struct Ds4LinuxProto { /// 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. remap: crate::inject::steam_remap::RemapConfig, } -impl Default for DualShock4Manager { - fn default() -> DualShock4Manager { - DualShock4Manager::new() - } -} - -impl DualShock4Manager { - pub fn new() -> DualShock4Manager { - DualShock4Manager { - 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 Ds4LinuxProto { + fn default() -> Ds4LinuxProto { + Ds4LinuxProto { 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)"); - 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. - 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)"); - *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; // this event WAS the unplug - } - self.ensure(idx); - // Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the - // rich-input plane and must survive a button-only frame). - 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. - 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 Ds4LinuxProto { + type Pad = DualShock4Pad; + type State = DsState; + const LABEL: &'static str = "DualShock 4"; + const DEVICE: &'static str = "DualShock 4"; + const CREATE_HINT: &'static str = ""; + + fn open(&mut self, idx: u8) -> Result { + let p = DualShock4Pad::open(idx)?; + tracing::info!( + index = idx, + "virtual DualShock 4 created (UHID hid-playstation)" + ); + Ok(p) } - /// 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; 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.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() { - let _ = pad.write_state(&st); - } - self.last_write[idx] = Instant::now(); + /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those + /// arrive on the rich-input plane and must survive a button-only frame). + 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` — a real DS4 streams - /// report `0x01` continuously, and `hid-playstation` / SDL treat a multi-second silence (a - /// held-steady stick) as an unplugged controller. Idempotent (a stale-but-correct frame); - /// `write_state` bumps the counter + timestamp so each is a fresh, well-formed report. - 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 DualShock4Pad::open(idx as u8) { - Ok(p) => { - tracing::info!( - index = idx, - "virtual DualShock 4 created (UHID hid-playstation)" - ); - 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"); - self.gate.on_failure(Instant::now()); - } - } + fn write_state(&self, pad: &mut DualShock4Pad, st: &DsState) { + let _ = pad.write_state(st); } - /// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble` - /// is invoked `(index, low, high)` only when the motor level *changes* (universal 0xCA plane); - /// `hidout` carries the lightbar (0xCD `Led`), deduped. Call frequently — the kernel blocks - /// `hid-playstation` init until its GET_REPORTs are answered. - 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, - }); - } - } + /// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs + /// are answered — call frequently) and parse 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 DualShock4Pad, 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 PS4 analog of +/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`. +/// Like the DualSense, the shared [`UhidManager`] keeps each pad's full [`DsState`], re-emits the +/// merged report whenever buttons/sticks or touchpad/motion change, and heartbeats it through +/// input silence (a real DS4 streams report `0x01` continuously — `hid-playstation`/SDL treat a +/// multi-second gap as an unplug). +pub type DualShock4Manager = UhidManager; + #[cfg(test)] mod tests { use super::*; From 384fc3083327678b6310d57fecaebff1f4272726 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:41:21 +0200 Subject: [PATCH 08/10] refactor(inject/linux/steam_controller): convert to UhidManager (3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most hook-laden conversion: SteamControllerManager becomes a pub type alias of UhidManager. The Steam-specific pieces map cleanly onto the trait — open() delegates to open_transport (usbip → gadget → UHID fallback, which keeps its own per-transport logging, so no extra success line, matching the old ensure), merge_frame preserves the trackpad coords/touch-bits/clicks + motion across button-only frames (the G2 fix, verbatim), and the gamepad-mode-entry pulse rides the force_heartbeat hook. DeckTransport goes pub (type Pad in a public-trait impl). Also un-fuses a doc-comment glitch where the manager's doc had been merged onto the DeckTransport enum. Verified on .21: clippy --all-targets -D warnings clean; full suite 290 pass / 0 fail. Part of G12/3.3 (§3a.4 commit 8) — all five stateful managers now share one skeleton. Co-Authored-By: Claude Fable 5 --- .../src/inject/linux/steam_controller.rs | 247 ++++++------------ 1 file changed, 87 insertions(+), 160 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index 54dff73e..0a42a448 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -23,10 +23,9 @@ use super::steam_proto::{ btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, }; -use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; -use crate::inject::pad_gate::PadGate; +use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; -use punktfunk_core::quic::{HidOutput, RichInput}; +use punktfunk_core::quic::RichInput; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; @@ -235,16 +234,12 @@ impl Drop for SteamDeckPad { } } -/// All virtual Steam Deck pads of a session — the Steam analogue of -/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=steamdeck`. -/// Button/stick frames arrive via [`handle`](Self::handle); the right trackpad + motion via -/// [`apply_rich`](Self::apply_rich); [`pump`](Self::pump) services the kernel handshake + routes -/// rumble back; [`heartbeat`](Self::heartbeat) keeps the pad alive (and drives the mode-entry pulse). /// The transport a manager pad drives. UHID is universal but Steam Input won't promote it (a UHID /// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS) /// and **usbip** (`vhci_hcd`, universal) both present the controller on USB interface 2, which Steam -/// Input *does* promote. Selected per-pad by [`open_transport`]. -enum DeckTransport { +/// Input *does* promote. Selected per-pad by [`open_transport`]. (`pub`: the type appears as +/// `type Pad` in the `PadProto` impl, a public trait.) +pub enum DeckTransport { Uhid(SteamDeckPad), Gadget(crate::inject::steam_gadget::SteamDeckGadget), Usbip(crate::inject::steam_usbip::SteamDeckUsbip), @@ -356,160 +351,92 @@ fn open_transport(idx: u8) -> Result { Ok(DeckTransport::Uhid(p)) } -pub struct SteamControllerManager { - pads: Vec>, - state: Vec, - last_rumble: Vec<(u16, u16)>, - last_write: Vec, - /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of - /// permanently disabling every pad for the session. - gate: PadGate, -} +/// The Steam-Deck-specific half of the shared stateful manager (see [`PadProto`]): the transport +/// open (usbip → gadget → UHID fallback via [`open_transport`], which logs its own per-transport +/// outcome), the [`SteamState`] mappers, and the kernel-handshake service pass. Lifecycle (slot +/// table, unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`]; the gamepad-mode-entry +/// pulse rides the [`force_heartbeat`](PadProto::force_heartbeat) hook. +#[derive(Default)] +pub struct SteamProto; -impl Default for SteamControllerManager { - fn default() -> SteamControllerManager { - SteamControllerManager::new() +impl PadProto for SteamProto { + type Pad = DeckTransport; + type State = SteamState; + const LABEL: &'static str = "Steam Deck"; + const DEVICE: &'static str = "Steam Deck"; + const CREATE_HINT: &'static str = ""; + + fn open(&mut self, idx: u8) -> Result { + open_transport(idx) + } + + fn neutral(&self) -> SteamState { + SteamState::neutral() + } + + /// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion arrive + /// separately and must survive a button-only frame). + fn merge_frame( + &self, + prev: &SteamState, + f: &crate::gamestream::gamepad::GamepadFrame, + ) -> SteamState { + let mut s = SteamState::from_gamepad( + f.buttons, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + f.left_trigger, + f.right_trigger, + ); + s.rpad_x = prev.rpad_x; + s.rpad_y = prev.rpad_y; + s.lpad_x = prev.lpad_x; + s.lpad_y = prev.lpad_y; + s.gyro = prev.gyro; + s.accel = prev.accel; + s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH); + // Trackpad CLICK arrives on the rich plane too and must survive a button-only frame, + // exactly like touch/coords/motion above. It lives in its own fields (not `buttons`, + // which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD + // wire-button's RPAD_CLICK — the two are OR'd only at serialize. + s.lpad_click = prev.lpad_click; + s.rpad_click = prev.rpad_click; + s + } + + fn apply_rich(&self, st: &mut SteamState, rich: RichInput) { + st.apply_rich(rich); + } + + fn write_state(&self, pad: &mut DeckTransport, st: &SteamState) { + pad.write_state(st); + } + + /// Answer the kernel handshake and forward rumble on the universal plane. The Steam Deck has + /// no rich host→client feedback plane (no lightbar / adaptive triggers), so `hidout` stays + /// empty. + fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback { + PadFeedback { + rumble: pad.service(), + hidout: Vec::new(), + } + } + + /// Force a steady stream while a pad is still pulsing its gamepad-mode entry (so the `b9.6` + /// toggle completes even with no game input). + fn force_heartbeat(&self, pad: &DeckTransport) -> bool { + pad.in_mode_entry() } } -impl SteamControllerManager { - pub fn new() -> SteamControllerManager { - SteamControllerManager { - pads: (0..MAX_PADS).map(|_| None).collect(), - state: vec![SteamState::neutral(); MAX_PADS], - last_rumble: vec![(0, 0); MAX_PADS], - last_write: vec![Instant::now(); MAX_PADS], - gate: PadGate::new(), - } - } - - pub fn handle(&mut self, ev: &GamepadEvent) { - match ev { - GamepadEvent::Arrival { index, kind, .. } => { - tracing::info!(index, kind, "controller arrival (Steam Deck)"); - 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 (Steam Deck)"); - *slot = None; - self.state[i] = SteamState::neutral(); - self.last_rumble[i] = (0, 0); - } - } - if f.active_mask & (1 << idx) == 0 { - return; - } - self.ensure(idx); - // Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion - // arrive separately and must survive a button-only frame). - let prev = self.state[idx]; - let mut s = SteamState::from_gamepad( - f.buttons, - f.ls_x, - f.ls_y, - f.rs_x, - f.rs_y, - f.left_trigger, - f.right_trigger, - ); - s.rpad_x = prev.rpad_x; - s.rpad_y = prev.rpad_y; - s.lpad_x = prev.lpad_x; - s.lpad_y = prev.lpad_y; - s.gyro = prev.gyro; - s.accel = prev.accel; - s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH); - // Trackpad CLICK arrives on the rich plane too and must survive a button-only frame, - // exactly like touch/coords/motion above. It lives in its own fields (not `buttons`, - // which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD - // wire-button's RPAD_CLICK — the two are OR'd only at serialize. - s.lpad_click = prev.lpad_click; - s.rpad_click = prev.rpad_click; - self.state[idx] = s; - self.write(idx); - } - } - } - - /// Apply a rich client→host event (right trackpad / motion) 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; - } - self.state[idx].apply_rich(rich); - self.write(idx); - } - - 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(); - } - - /// Re-emit each live pad's current report when silent past `max_gap`, and force a steady stream - /// while a pad is still pulsing its gamepad-mode entry (so the `b9.6` toggle completes even with - /// no game input). - pub fn heartbeat(&mut self, max_gap: Duration) { - let now = Instant::now(); - for i in 0..self.pads.len() { - let Some(pad) = self.pads[i].as_ref() else { - continue; - }; - if pad.in_mode_entry() || now.duration_since(self.last_write[i]) >= max_gap { - self.write(i); - } - } - } - - fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { - return; - } - match open_transport(idx as u8) { - Ok(t) => { - self.pads[idx] = Some(t); - self.state[idx] = SteamState::neutral(); - self.last_rumble[idx] = (0, 0); - self.last_write[idx] = Instant::now(); - self.gate.on_success(); - } - Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — retrying with backoff"); - self.gate.on_failure(Instant::now()); - } - } - } - - /// Service every pad: answer the kernel handshake and forward rumble on the universal plane. - /// `rumble` fires `(index, low, high)` only on a level change. The Steam Deck has no rich - /// host→client feedback plane (no lightbar / adaptive triggers), so `hidout` goes unused. - pub fn pump(&mut self, mut rumble: impl FnMut(u16, u16, u16), _hidout: impl FnMut(HidOutput)) { - for i in 0..self.pads.len() { - let Some(pad) = self.pads[i].as_mut() else { - continue; - }; - if let Some(r) = pad.service() { - if self.last_rumble[i] != r { - self.last_rumble[i] = r; - rumble(i as u16, r.0, r.1); - } - } - } - } -} +/// All virtual Steam Deck pads of a session — the Steam analogue of +/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with +/// `PUNKTFUNK_GAMEPAD=steamdeck`. Button/stick frames arrive via `handle`; the trackpads + motion +/// via `apply_rich`; `pump` services the kernel handshake + routes rumble back; `heartbeat` keeps +/// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`]. +pub type SteamControllerManager = UhidManager; #[cfg(test)] mod tests { From 89aa52bc5861ce5cabb3f42a2aad59e52ccb7d7b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 01:57:00 +0200 Subject: [PATCH 09/10] 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 From 650acda334fee6cc5329aa7af1f7a66644692b44 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 02:04:51 +0200 Subject: [PATCH 10/10] chore(inject): post-extraction sweep (3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the vestigial Ds4Feedback.hidout field (parse_ds4_output never filled it and neither DS4 manager read it — the lightbar rides the led field, now converted to a HidOutput::Led by the protos) and its now-unused HidOutput import; refresh the pad_gate module doc (managers now drive it via pad_slots). Verified: .21 clippy --all-targets -D warnings + full suite 290 pass / 0 fail + cargo fmt --check clean; .133 clippy --all-targets -D warnings EXITCODE 0. Part of G12/3.3 (§3a.4 commit 10) — extraction complete. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/inject.rs | 17 +++++++++-------- .../src/inject/proto/dualshock4_proto.rs | 6 ++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 5a8befbb..4f29fadc 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -506,8 +506,9 @@ pub mod gamepad; #[cfg(target_os = "windows")] #[path = "inject/windows/gamepad_raii.rs"] mod gamepad_raii; -/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]) used by every backend manager on -/// both platforms — replaces the per-backend permanent `broken` latch with capped-backoff retry. +/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for +/// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff +/// retry. #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/pad_gate.rs"] pub mod pad_gate; @@ -517,12 +518,6 @@ pub mod pad_gate; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/pad_slots.rs"] pub mod pad_slots; -/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame -/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only -/// its per-controller protocol via [`uhid_manager::PadProto`] (G12). -#[cfg(any(target_os = "linux", target_os = "windows"))] -#[path = "inject/uhid_manager.rs"] -pub mod uhid_manager; /// 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"] @@ -550,6 +545,12 @@ pub mod steam_remap; #[cfg(target_os = "linux")] #[path = "inject/linux/steam_usbip.rs"] pub mod steam_usbip; +/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame +/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only +/// its per-controller protocol via [`uhid_manager::PadProto`] (G12). +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[path = "inject/uhid_manager.rs"] +pub mod uhid_manager; /// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere. #[cfg(not(any(target_os = "linux", target_os = "windows")))] pub mod gamepad { diff --git a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs index 9bcae868..152e98e8 100644 --- a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs @@ -13,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; @@ -73,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).