refactor(inject/windows/dualsense): convert to UhidManager<DsWinProto> (3.3)

DualSenseWindowsManager becomes a pub type alias of
UhidManager<DsWinProto>; 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 01:36:45 +02:00
parent 4d6c2394dc
commit 446818eea6
@@ -18,17 +18,16 @@
//! must already be installed; the installer stages it.) //! must already be installed; the installer stages it.)
use super::dualsense_proto::{ use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN, parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
DS_TOUCH_H, DS_TOUCH_W, DS_TOUCH_W,
}; };
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use crate::inject::pad_gate::PadGate;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::RichInput;
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering}; use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::{Duration, Instant}; use std::time::Duration;
use windows::core::{w, GUID, PCWSTR}; use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{ use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, 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_<index>` software devnode (the driver /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel. /// 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. /// 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). /// 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). /// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
@@ -351,75 +352,50 @@ impl DsWinPad {
} }
} }
/// All virtual DualSense pads of a session — the Windows analogue of /// The Windows-DualSense half of the shared stateful manager (see [`PadProto`]): the UMDF
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input /// sealed-channel open, the same [`DsState`] mappers as `linux/dualsense.rs`, and the section
/// thread drives either backend identically. /// feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
pub struct DualSenseWindowsManager { pub struct DsWinProto {
pads: Vec<Option<DsWinPad>>,
state: Vec<DsState>,
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<HidoutDedup>,
last_write: Vec<Instant>,
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button /// 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`. /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`.
remap: crate::inject::steam_remap::RemapConfig, remap: crate::inject::steam_remap::RemapConfig,
} }
impl Default for DualSenseWindowsManager { impl Default for DsWinProto {
fn default() -> DualSenseWindowsManager { fn default() -> DsWinProto {
DualSenseWindowsManager::new() DsWinProto {
}
}
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(),
remap: crate::inject::steam_remap::RemapConfig::from_env(), remap: crate::inject::steam_remap::RemapConfig::from_env(),
} }
} }
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). impl PadProto for DsWinProto {
pub fn handle(&mut self, ev: &GamepadEvent) { type Pad = DsWinPad;
match ev { type State = DsState;
GamepadEvent::Arrival { index, kind, .. } => { const LABEL: &'static str = "DualSense/Windows";
tracing::info!(index, kind, "controller arrival (DualSense/Windows)"); const DEVICE: &'static str = "DualSense";
self.ensure(*index as usize); const CREATE_HINT: &'static str =
" (install/repair: punktfunk-host.exe driver install --gamepad)";
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
let p = DsWinPad::open(idx)?;
tracing::info!(
index = idx,
"virtual DualSense created (Windows UMDF shm channel)"
);
Ok(p)
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; fn neutral(&self) -> DsState {
if idx >= MAX_PADS { DsState::neutral()
return;
} }
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 { /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
tracing::info!(index = i, "controller unplugged (DualSense/Windows)"); /// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
*slot = None; fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
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 // 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 // configured policy (default drop) so they aren't silently lost.
// `linux/dualsense.rs` does. let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let buttons =
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad( let mut s = DsState::from_gamepad(
buttons, buttons,
f.ls_x, f.ls_x,
@@ -433,98 +409,33 @@ impl DualSenseWindowsManager {
s.gyro = prev.gyro; s.gyro = prev.gyro;
s.accel = prev.accel; s.accel = prev.accel;
s.touch_click = prev.touch_click; s.touch_click = prev.touch_click;
self.state[idx] = s; s
self.write(idx); }
/// 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 write_state(&self, pad: &mut DsWinPad, st: &DsState) {
pad.write_state(st);
}
/// 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,
} }
} }
} }
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. /// All virtual DualSense pads of a session — the Windows analogue of
pub fn apply_rich(&mut self, rich: RichInput) { /// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
let idx = match rich { /// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
RichInput::Touchpad { pad, .. } /// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID
| RichInput::Motion { pad, .. } /// backend's silence heartbeat.
| RichInput::TouchpadEx { pad, .. } => pad as usize, pub type DualSenseWindowsManager = UhidManager<DsWinProto>;
};
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 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 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);
}
}
}
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());
}
}
}
/// 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);
}
}
}
}
}