Merge branch 'gamepad-g12-skeleton': G12/3.3 UhidManager skeleton extraction
ci / web (push) Successful in 43s
ci / docs-site (push) Successful in 1m19s
apple / swift (push) Successful in 4m28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 38s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
ci / bench (push) Successful in 6m20s
arch / build-publish (push) Successful in 11m25s
deb / build-publish (push) Successful in 13m3s
windows-host / package (push) Successful in 14m33s
android / android (push) Successful in 15m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m39s
ci / rust (push) Successful in 22m7s
apple / screenshots (push) Successful in 19m33s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / deploy-docs (push) Successful in 23s

The deferred Phase 3.3 of the gamepad review (gamepad-review-cleanup.md
§3a): the seven virtual-pad managers' copy-pasted lifecycle (slot table,
active_mask unplug sweep, gate-checked create, rumble/hidout dedup,
heartbeat) extracted into shared PadSlots<P> + PadProto/UhidManager<B>;
each backend now supplies only its protocol half via a type alias, with
zero Pads-router edits. Includes the 3.3.0 pre-step fixing the drifted
Linux DS4 backend (rich-plane pad clicks + the Steam left pad were dead
on the DS4 kind).

10 commits, each verified as it landed: Linux .21 clippy -D warnings +
full host suite 290 pass / 0 fail + fmt; Windows CI VM .133 clippy
--all-targets -D warnings EXITCODE 0. On-glass kind-cycling smoke
(one real pad per platform) still owed post-merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 09:09:34 +02:00
11 changed files with 1121 additions and 1121 deletions
+17 -4
View File
@@ -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;
@@ -506,11 +506,18 @@ 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;
/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the
/// `Vec<Option<Pad>>` table, `active_mask` unplug sweep, and gate-checked create every backend
/// manager used to copy-paste (G12).
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/pad_slots.rs"]
pub mod pad_slots;
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"]
@@ -538,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 {
@@ -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,87 +161,49 @@ 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<Option<DualSensePad>>,
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
state: Vec<DsState>,
/// 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<HidoutDedup>,
/// 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<Instant>,
/// 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);
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<DualSensePad> {
let p = DualSensePad::open(idx)?;
tracing::info!(
index = idx,
"virtual DualSense created (UHID hid-playstation)"
);
Ok(p)
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
fn neutral(&self) -> DsState {
DsState::neutral()
}
// 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];
/// 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 buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad(
buttons,
f.ls_x,
@@ -257,107 +217,37 @@ impl DualSenseManager {
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
self.state[idx] = s;
self.write(idx);
}
}
s
}
/// 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);
/// 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(&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();
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
let _ = pad.write_state(st);
}
/// 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);
}
}
}
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());
}
}
}
/// 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<DsLinuxProto>;
@@ -8,20 +8,22 @@
//! 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 crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
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::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";
@@ -129,96 +131,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<HidOutput>,
/// `(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 +177,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
@@ -349,83 +261,52 @@ 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<Option<DualShock4Pad>>,
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
state: Vec<DsState>,
/// 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<Option<(u8, u8, u8)>>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>,
/// 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);
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<DualShock4Pad> {
let p = DualShock4Pad::open(idx)?;
tracing::info!(
index = idx,
"virtual DualShock 4 created (UHID hid-playstation)"
);
Ok(p)
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
fn neutral(&self) -> DsState {
DsState::neutral()
}
// 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);
/// 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,
@@ -438,217 +319,51 @@ impl DualShock4Manager {
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
self.state[idx] = s;
self.write(idx);
}
}
s.touch_click = prev.touch_click;
s
}
/// 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;
}
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;
}
}
}
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, DS4_TOUCH_W, DS4_TOUCH_H);
}
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();
fn write_state(&self, pad: &mut DualShock4Pad, st: &DsState) {
let _ = pad.write_state(st);
}
/// 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);
}
}
}
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());
}
}
}
/// 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;
};
/// 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();
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,
});
}
}
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<Ds4LinuxProto>;
#[cfg(test)]
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]
@@ -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<Option<VirtualPad>>,
slots: PadSlots<VirtualPad>,
/// 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,30 +609,19 @@ 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 {
for (i, pad) in self.slots.iter_mut() {
if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high);
}
}
}
}
}
@@ -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,59 +351,36 @@ fn open_transport(idx: u8) -> Result<DeckTransport> {
Ok(DeckTransport::Uhid(p))
}
pub struct SteamControllerManager {
pads: Vec<Option<DeckTransport>>,
state: Vec<SteamState>,
last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>,
/// 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 = "";
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(),
}
fn open(&mut self, idx: u8) -> Result<DeckTransport> {
open_transport(idx)
}
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);
fn neutral(&self) -> SteamState {
SteamState::neutral()
}
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];
/// 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,
@@ -431,86 +403,41 @@ impl SteamControllerManager {
// 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);
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(),
}
}
/// 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);
}
}
}
/// 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()
}
}
/// 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<SteamProto>;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,184 @@
//! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux
//! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`].
use crate::gamestream::gamepad::MAX_PADS;
use crate::inject::pad_gate::PadGate;
use anyhow::Result;
use std::time::Instant;
// The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit.
const _: () = assert!(MAX_PADS <= 16);
/// The slot table + lifecycle every virtual-pad manager repeats: `Vec<Option<P>>` keyed by wire pad
/// index, the `active_mask` unplug sweep, and the [`PadGate`]-guarded create. Extracted verbatim
/// from seven copy-pasted managers (G12) so a lifecycle fix lands once, not seven times.
///
/// Division of labor: `PadSlots` owns the pads' *existence* (create / sweep / lookup) and logs the
/// shared lifecycle lines (unplug, create-failure); the backend keeps everything per-controller —
/// its state model, feedback pump, and the success log inside `open` (which knows the transport
/// detail worth printing). Per-index sibling state (`state` / `last_rumble` / dedup / clocks) stays
/// in the manager, which resets it on the indices [`sweep`](Self::sweep) returns and on a `true`
/// from [`ensure`](Self::ensure).
pub struct PadSlots<P> {
pads: Vec<Option<P>>,
/// Create-retry gate: a transient backend failure backs off and retries instead of permanently
/// disabling every pad for the session.
gate: PadGate,
/// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"` — keeps every
/// existing per-backend line byte-identical (ops greps survive the extraction).
label: &'static str,
/// Device name in the create-failure line ("virtual `<device>` creation failed …").
device: &'static str,
/// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
hint: &'static str,
}
impl<P> PadSlots<P> {
/// An empty table of [`MAX_PADS`] slots whose lifecycle log lines carry `label` / `device` /
/// `hint` (see the field docs).
pub fn new(label: &'static str, device: &'static str, hint: &'static str) -> PadSlots<P> {
PadSlots {
pads: (0..MAX_PADS).map(|_| None).collect(),
gate: PadGate::new(),
label,
device,
hint,
}
}
/// The backend tag this table logs with (for the manager's own arrival line).
pub fn label(&self) -> &'static str {
self.label
}
/// Drop every allocated pad whose `active_mask` bit cleared (the unplug sweep run on each state
/// frame), logging each. Returns the swept indices as a bitmask so the caller resets its
/// per-index sibling state; an index another manager owns is `None` here, so it is never swept.
pub fn sweep(&mut self, active_mask: u16) -> u16 {
let mut swept = 0u16;
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged ({})", self.label);
*slot = None;
swept |= 1 << i;
}
}
swept
}
/// Create the pad at `idx` via `open` if the slot is empty and the create gate allows it.
/// Returns `true` only on a fresh create (the caller resets its per-index sibling state);
/// `open` logs its own success line (it knows the transport detail), failure is logged here.
pub fn ensure(&mut self, idx: usize, open: impl FnOnce(u8) -> Result<P>) -> bool {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return false;
}
match open(idx as u8) {
Ok(p) => {
self.pads[idx] = Some(p);
self.gate.on_success();
true
}
Err(e) => {
tracing::error!(
error = %format!("{e:#}"),
"virtual {} creation failed — retrying with backoff{}",
self.device,
self.hint
);
self.gate.on_failure(Instant::now());
false
}
}
}
/// The live pad at `idx`, if any (out-of-range → `None`).
pub fn get(&self, idx: usize) -> Option<&P> {
self.pads.get(idx).and_then(|s| s.as_ref())
}
/// The live pad at `idx`, mutably, if any (out-of-range → `None`).
pub fn get_mut(&mut self, idx: usize) -> Option<&mut P> {
self.pads.get_mut(idx).and_then(|s| s.as_mut())
}
/// Iterate the live pads as `(index, &mut pad)` (the feedback-pump shape).
pub fn iter_mut(&mut self) -> impl Iterator<Item = (usize, &mut P)> {
self.pads
.iter_mut()
.enumerate()
.filter_map(|(i, s)| s.as_mut().map(|p| (i, p)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::bail;
fn slots() -> PadSlots<u32> {
PadSlots::new("Test", "test pad", "")
}
#[test]
fn ensure_creates_once_and_reports_freshness() {
let mut s = slots();
// Fresh create → true; the pad is live.
assert!(s.ensure(3, |i| Ok(i as u32 * 10)));
assert_eq!(s.get(3), Some(&30));
// Occupied slot → no re-open (the closure must not run), no reset signal.
assert!(!s.ensure(3, |_| panic!("re-opened an occupied slot")));
// Out of range → never opens.
assert!(!s.ensure(MAX_PADS, |_| panic!("opened out of range")));
assert_eq!(s.get(MAX_PADS), None);
}
#[test]
fn sweep_drops_only_cleared_bits_and_returns_them_once() {
let mut s = slots();
assert!(s.ensure(0, |_| Ok(0)));
assert!(s.ensure(2, |_| Ok(2)));
assert!(s.ensure(5, |_| Ok(5)));
// Mask keeps 2, clears 0 and 5; empty slots (1, 3, …) are untouched non-events.
let swept = s.sweep(0b0000_0100);
assert_eq!(swept, 0b0010_0001);
assert_eq!(s.get(0), None);
assert_eq!(s.get(2), Some(&2));
assert_eq!(s.get(5), None);
// A second identical sweep is a no-op: the indices were returned exactly once.
assert_eq!(s.sweep(0b0000_0100), 0);
}
#[test]
fn create_failure_arms_the_gate_and_success_heals_it() {
let mut s = slots();
assert!(!s.ensure(1, |_| bail!("transient")));
// Backoff in effect: the next attempt is blocked without even calling `open`.
assert!(!s.ensure(1, |_| panic!("open during backoff")));
// The gate is manager-wide (create failures are systemic), so other indices block too.
assert!(!s.ensure(2, |_| panic!("open during backoff")));
// …and a sweep-then-recreate of a *different* live pad is equally gated, but the table
// itself is intact: nothing was allocated.
assert_eq!(s.get(1), None);
}
#[test]
fn recreate_after_sweep_resets_freshness() {
let mut s = slots();
assert!(s.ensure(4, |_| Ok(1)));
s.sweep(0);
assert_eq!(s.get(4), None);
// The slot is free again → a fresh create (true) with a new value.
assert!(s.ensure(4, |_| Ok(2)));
assert_eq!(s.get(4), Some(&2));
}
#[test]
fn iter_mut_yields_live_pads_with_indices() {
let mut s = slots();
assert!(s.ensure(1, |_| Ok(10)));
assert!(s.ensure(6, |_| Ok(60)));
let seen: Vec<(usize, u32)> = s.iter_mut().map(|(i, p)| (i, *p)).collect();
assert_eq!(seen, vec![(1, 10), (6, 60)]);
}
}
@@ -1,10 +1,6 @@
//! Transport-independent DualShock 4 HID contract — the pure report codec used by the Windows
//! UMDF-driver backend ([`super::dualshock4_windows`]).
//!
//! FIXME(ds4-dedup): the Linux UHID backend ([`super::dualshock4`]) still carries its own byte-
//! identical copy of this codec (`serialize_state` / `parse_ds4_output` / `Ds4Feedback` / the touch
//! dims). Fold it onto this module once the Linux build can be re-validated (it is `cfg(linux)`, so
//! it can't be compile-checked from a Windows host). Keep the two in sync until then.
//! Transport-independent DualShock 4 HID contract — the pure report codec shared by the Windows
//! UMDF-driver backend ([`super::dualshock4_windows`]) and the Linux UHID backend
//! ([`super::dualshock4`]).
//!
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
@@ -17,7 +13,6 @@
//! dualshock4_input_report_usb` / `_output_report_common` parse.
use super::dualsense_proto::{DsState, Touch};
use punktfunk_core::quic::HidOutput;
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
pub const DS4_VENDOR: u16 = 0x054C;
@@ -77,11 +72,10 @@ pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter
}
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player LEDs or adaptive
/// triggers, so those never appear).
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane as a `Led` event (DS4 has no player LEDs
/// or adaptive triggers, so those never appear).
#[derive(Default)]
pub struct Ds4Feedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
pub rumble: Option<(u16, u16)>,
/// Lightbar RGB, if the report carried it (deduped by the manager).
@@ -149,6 +143,14 @@ mod tests {
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
assert_eq!(r[35] & 0x7F, 0); // contact id 0
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
// A rich-plane pad click (`touch_click`, no BTN_TOUCHPAD in the frame) rides the
// touchpad-click bit at byte 7 bit 1 via `buttons2_with_click` — the Linux backend used to
// serialize raw `buttons[2]` here and drop it.
assert_eq!(r[7] & 0x02, 0); // no click yet
st.touch_click[0] = true;
serialize_state(&mut r, &st, 0, 0);
assert_eq!(r[7] & 0x02, 0x02);
}
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
@@ -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<HidOutput>,
}
/// 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 `<DEVICE>` 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<Self::Pad>;
/// 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<B: PadProto> {
backend: B,
slots: PadSlots<B::Pad>,
/// Each pad's current full report — buttons/sticks merged with persisted rich-plane fields.
state: Vec<B::State>,
/// 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<HidoutDedup>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>,
}
impl<B: PadProto + Default> UhidManager<B> {
pub fn new() -> UhidManager<B> {
UhidManager::with_backend(B::default())
}
}
impl<B: PadProto + Default> Default for UhidManager<B> {
fn default() -> UhidManager<B> {
UhidManager::new()
}
}
impl<B: PadProto> UhidManager<B> {
pub fn with_backend(backend: B) -> UhidManager<B> {
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<u32>,
feedback: RefCell<Vec<PadFeedback>>,
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<Vec<MockState>>,
}
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<MockPad> {
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<MockProto> {
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<MockProto>| {
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<MockProto>| 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);
}
}
@@ -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_<index>` 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<super::gamepad_raii::SwDevice>,
@@ -351,75 +352,50 @@ 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<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,
/// 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);
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<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;
if idx >= MAX_PADS {
return;
fn neutral(&self) -> DsState {
DsState::neutral()
}
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];
/// 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, exactly as
// `linux/dualsense.rs` does.
let buttons =
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
// 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,
@@ -433,98 +409,33 @@ impl DualSenseWindowsManager {
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
self.state[idx] = s;
self.write(idx);
}
}
s
}
/// 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);
/// 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(&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();
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
pad.write_state(st);
}
/// 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);
}
}
/// 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<DsWinProto>;
@@ -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_<index>` 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<super::gamepad_raii::SwDevice>,
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
@@ -141,73 +142,53 @@ 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<Option<Ds4WinPad>>,
state: Vec<DsState>,
last_rumble: Vec<(u16, u16)>,
last_led: Vec<Option<(u8, u8, u8)>>,
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,
/// 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);
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<Ds4WinPad> {
let p = Ds4WinPad::open(idx)?;
tracing::info!(
index = idx,
"virtual DualShock 4 created (Windows UMDF shm channel)"
);
Ok(p)
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
fn neutral(&self) -> DsState {
DsState::neutral()
}
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);
/// 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,
@@ -221,100 +202,36 @@ impl DualShock4WindowsManager {
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
self.state[idx] = s;
self.write(idx);
}
}
s
}
/// 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);
/// 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 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();
fn write_state(&self, pad: &mut Ds4WinPad, st: &DsState) {
pad.write_state(st);
}
/// 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);
}
}
}
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());
}
}
}
/// 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;
};
/// 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();
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,
});
}
}
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<Ds4WinProto>;
@@ -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<Option<XusbWinPad>>,
slots: PadSlots<XusbWinPad>,
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<Instant>,
/// 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) => {
if self.slots.ensure(idx, XusbWinPad::open) {
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());
}
}
}
@@ -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