Merge branch 'gamepad-apple-cleanup': cross-client + host gamepad review cleanup (G1–G25)
audit / bun-audit (push) Successful in 13s
ci / docs-site (push) Successful in 49s
ci / web (push) Successful in 53s
decky / build-publish (push) Successful in 21s
audit / cargo-audit (push) Successful in 2m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
apple / swift (push) Successful in 4m22s
ci / bench (push) Successful in 5m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
windows-host / package (push) Successful in 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m58s
flatpak / build-publish (push) Successful in 6m18s
docker / deploy-docs (push) Successful in 27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m14s
arch / build-publish (push) Successful in 16m1s
android / android (push) Successful in 16m36s
deb / build-publish (push) Successful in 16m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
ci / rust (push) Successful in 22m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m19s
release / apple (push) Successful in 20m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m49s
apple / screenshots (push) Successful in 18m55s

48-finding cross-client + host gamepad audit (2026-07-13). Apple/Android/SDL-core
capture + feedback and the Linux/Windows host injectors: held-guide release, the
permanent broken-latch cliff (PadGate), Steam Deck trackpad clicks, DualSense mute,
Windows DS/DS4 paddle fold, uinput button re-sync, gamestream BTN_* dedup, the dead
Windows shell fork, legacy-Deck rumble ceiling, XUSB arrival, ARM64 fences, the
truncate-everywhere value convention, and more. See
punktfunk-planning/design/gamepad-review-cleanup.md.
This commit is contained in:
2026-07-13 22:29:41 +02:00
30 changed files with 981 additions and 935 deletions
+32 -23
View File
@@ -50,29 +50,38 @@ pub struct GamepadFrame {
pub rs_y: i16,
}
// buttonFlags bits (Limelight.h).
pub const BTN_DPAD_UP: u32 = 0x0001;
pub const BTN_DPAD_DOWN: u32 = 0x0002;
pub const BTN_DPAD_LEFT: u32 = 0x0004;
pub const BTN_DPAD_RIGHT: u32 = 0x0008;
pub const BTN_START: u32 = 0x0010;
pub const BTN_BACK: u32 = 0x0020;
pub const BTN_LS_CLK: u32 = 0x0040;
pub const BTN_RS_CLK: u32 = 0x0080;
pub const BTN_LB: u32 = 0x0100;
pub const BTN_RB: u32 = 0x0200;
pub const BTN_GUIDE: u32 = 0x0400;
pub const BTN_A: u32 = 0x1000;
pub const BTN_B: u32 = 0x2000;
pub const BTN_X: u32 = 0x4000;
pub const BTN_Y: u32 = 0x8000;
// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`):
// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the
// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd.
pub const BTN_PADDLE1: u32 = 0x0001_0000;
pub const BTN_PADDLE2: u32 = 0x0002_0000;
pub const BTN_PADDLE3: u32 = 0x0004_0000;
pub const BTN_PADDLE4: u32 = 0x0008_0000;
// GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to
// punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core`
// instead of re-declaring the values (the two drifted while separately hand-typed: the click bits
// were named `BTN_LS_CLK`/`BTN_RS_CLK` here vs the core `…_CLICK`). `decode` merges the two 16-bit
// halves into `buttons` raw; these names exist for the uinput injector's button map + hat math. The
// extended touchpad-click / Share bits (`BTN_TOUCHPAD` / `BTN_MISC1`) ride `buttons` too but are
// consumed straight from `punktfunk_core` by the DualSense/DS4 protos, so they aren't re-named here.
//
// These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole
// consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there,
// whereas an unused `pub const` does not. The values still come only from core, so they can't drift;
// the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`.
use punktfunk_core::input::gamepad as wire;
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;
pub const BTN_DPAD_LEFT: u32 = wire::BTN_DPAD_LEFT;
pub const BTN_DPAD_RIGHT: u32 = wire::BTN_DPAD_RIGHT;
pub const BTN_START: u32 = wire::BTN_START;
pub const BTN_BACK: u32 = wire::BTN_BACK;
pub const BTN_LS_CLICK: u32 = wire::BTN_LS_CLICK;
pub const BTN_RS_CLICK: u32 = wire::BTN_RS_CLICK;
pub const BTN_LB: u32 = wire::BTN_LB;
pub const BTN_RB: u32 = wire::BTN_RB;
pub const BTN_GUIDE: u32 = wire::BTN_GUIDE;
pub const BTN_A: u32 = wire::BTN_A;
pub const BTN_B: u32 = wire::BTN_B;
pub const BTN_X: u32 = wire::BTN_X;
pub const BTN_Y: u32 = wire::BTN_Y;
pub const BTN_PADDLE1: u32 = wire::BTN_PADDLE1;
pub const BTN_PADDLE2: u32 = wire::BTN_PADDLE2;
pub const BTN_PADDLE3: u32 = wire::BTN_PADDLE3;
pub const BTN_PADDLE4: u32 = wire::BTN_PADDLE4;
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
+8 -1
View File
@@ -506,6 +506,11 @@ 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.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/pad_gate.rs"]
pub mod pad_gate;
/// 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"]
@@ -522,7 +527,9 @@ pub mod steam_gadget;
#[path = "inject/proto/steam_proto.rs"]
pub mod steam_proto;
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
#[cfg(target_os = "linux")]
/// Shared by the Linux and Windows DualSense/DS4 backends (the slot-less pads that must fold the
/// Steam back grips); the Deck motion rescale is Linux-only but harmless to compile on Windows.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/steam_remap.rs"]
pub mod steam_remap;
/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean,
@@ -13,11 +13,12 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION,
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, 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 anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
@@ -177,11 +178,15 @@ pub struct DualSenseManager {
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>,
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
broken: bool,
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig,
@@ -199,8 +204,9 @@ impl 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],
broken: false,
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
@@ -224,6 +230,7 @@ impl DualSenseManager {
*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 {
@@ -300,7 +307,7 @@ impl DualSenseManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match DualSensePad::open(idx as u8) {
@@ -312,11 +319,13 @@ impl DualSenseManager {
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 — controller input disabled");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
@@ -343,7 +352,11 @@ impl DualSenseManager {
}
}
for h in fb.hidout {
hidout(h);
// 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);
}
}
}
}
@@ -15,6 +15,7 @@
use super::dualsense_proto::{DsState, Touch};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
@@ -365,8 +366,9 @@ pub struct DualShock4Manager {
last_led: Vec<Option<(u8, u8, u8)>>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>,
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
broken: bool,
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig,
@@ -386,7 +388,7 @@ impl DualShock4Manager {
last_rumble: vec![(0, 0); MAX_PADS],
last_led: vec![None; MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
@@ -522,7 +524,7 @@ impl DualShock4Manager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match DualShock4Pad::open(idx as u8) {
@@ -536,10 +538,11 @@ impl DualShock4Manager {
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 — controller input disabled");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
@@ -19,6 +19,7 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{bail, Result};
use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd};
@@ -88,8 +89,8 @@ const BUTTON_MAP: [(u32, u16); 15] = [
(gamepad::BTN_BACK, BTN_SELECT),
(gamepad::BTN_START, BTN_START),
(gamepad::BTN_GUIDE, BTN_MODE),
(gamepad::BTN_LS_CLK, BTN_THUMBL),
(gamepad::BTN_RS_CLK, BTN_THUMBR),
(gamepad::BTN_LS_CLICK, BTN_THUMBL),
(gamepad::BTN_RS_CLICK, BTN_THUMBR),
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
@@ -265,7 +266,6 @@ struct Effect {
/// One virtual X-Box-360 pad backed by a uinput device.
pub struct VirtualPad {
fd: OwnedFd,
prev_buttons: u32,
effects: HashMap<i16, Effect>,
next_effect_id: i16,
gain: u32,
@@ -369,7 +369,6 @@ impl VirtualPad {
Ok(VirtualPad {
fd,
prev_buttons: 0,
effects: HashMap::new(),
next_effect_id: 0,
gain: 0xFFFF,
@@ -412,15 +411,17 @@ impl VirtualPad {
};
}
/// Apply one decoded frame: button transitions, axes, D-pad hat, one SYN_REPORT.
/// Apply one decoded frame: button state, axes, D-pad hat, one SYN_REPORT.
pub fn apply(&mut self, f: &GamepadFrame) {
let changed = self.prev_buttons ^ f.buttons;
// Re-assert every mapped button's absolute state each frame — exactly like the axes below —
// instead of only writing XOR-changed edges. `emit` is best-effort (a full kernel queue drops
// the write), so an edge-only scheme would strand a dropped press/release until that button
// next toggles; re-asserting re-syncs it on the following frame. Restating an unchanged key is
// free downstream: the kernel input core discards an EV_KEY whose value already matches the
// device's current state (no duplicate event reaches consumers, and BTN_* keys don't autorepeat).
for (bit, key) in BUTTON_MAP {
if changed & bit != 0 {
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
}
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
}
self.prev_buttons = f.buttons;
// Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow).
self.emit(EV_ABS, ABS_X, f.ls_x as i32);
@@ -557,8 +558,9 @@ pub struct GamepadManager {
/// 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,
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events.
broken: bool,
/// Create-retry gate: a transient `/dev/uinput` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
}
impl GamepadManager {
@@ -572,7 +574,7 @@ impl GamepadManager {
GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
identity,
broken: false,
gate: PadGate::new(),
}
}
@@ -608,14 +610,17 @@ impl GamepadManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
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),
Ok(p) => {
self.pads[idx] = Some(p);
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
@@ -24,6 +24,7 @@ use super::steam_proto::{
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
@@ -360,7 +361,9 @@ pub struct SteamControllerManager {
state: Vec<SteamState>,
last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>,
broken: bool,
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
}
impl Default for SteamControllerManager {
@@ -376,7 +379,7 @@ impl SteamControllerManager {
state: vec![SteamState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
gate: PadGate::new(),
}
}
@@ -422,6 +425,12 @@ impl SteamControllerManager {
s.gyro = prev.gyro;
s.accel = prev.accel;
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
// Trackpad CLICK arrives on the rich plane too and must survive a button-only frame,
// exactly like touch/coords/motion above. It lives in its own fields (not `buttons`,
// which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD
// wire-button's RPAD_CLICK — the two are OR'd only at serialize.
s.lpad_click = prev.lpad_click;
s.rpad_click = prev.rpad_click;
self.state[idx] = s;
self.write(idx);
}
@@ -466,7 +475,7 @@ impl SteamControllerManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match open_transport(idx as u8) {
@@ -475,10 +484,11 @@ impl SteamControllerManager {
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 — controller input disabled");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
@@ -0,0 +1,122 @@
//! Shared virtual-pad creation-retry policy, used by every backend manager (Linux uinput/uhid,
//! Windows XUSB/UMDF). See [`PadGate`].
use std::time::{Duration, Instant};
/// Backoff after the first failed pad creation…
const FIRST_BACKOFF: Duration = Duration::from_secs(1);
/// …doubling on each consecutive failure, capped here so a persistently-broken host retries at most
/// this often (a negligible cost) while still self-healing within one window of the fix.
const MAX_BACKOFF: Duration = Duration::from_secs(30);
/// Create-retry gate shared by every virtual-pad manager.
///
/// Each backend used to carry a `broken: bool` that latched permanently on the FIRST pad-creation
/// error, so a single transient failure — a startup race on `/dev/uinput`, a momentary `EBUSY`, the
/// Windows companion driver not yet ready — disabled EVERY controller for the rest of the session,
/// even after the underlying cause cleared. `PadGate` replaces that latch with capped exponential
/// backoff:
///
/// * After a failure, creation is blocked only until the backoff elapses — so the manager does not
/// re-attempt (and re-log) on every one of the 60240 input frames a second — then a single
/// retry is permitted.
/// * A success clears the backoff, so the next failure starts fresh from [`FIRST_BACKOFF`].
/// * Consecutive failures widen the window, doubling up to [`MAX_BACKOFF`].
///
/// Even a genuinely broken setup (bad `/dev/uinput` permissions, missing Windows driver) therefore
/// self-heals within [`MAX_BACKOFF`] of the fix — a udev-rule reload, a driver install, the next
/// client connect — with no host restart, while costing at most one failed syscall plus one log
/// line per backoff window. The gate is manager-wide (not per slot), matching the old `broken`
/// flag: these failures are systemic (device-node permissions, absent driver), not per-controller.
#[derive(Debug, Default)]
pub struct PadGate {
/// When the current backoff ends. `None` = creation is allowed right now.
retry_at: Option<Instant>,
/// Current backoff length: `ZERO` until the first failure, then [`FIRST_BACKOFF`] doubling
/// toward [`MAX_BACKOFF`].
backoff: Duration,
}
impl PadGate {
/// A gate that permits creation immediately (no failures recorded yet).
pub fn new() -> PadGate {
PadGate::default()
}
/// May a pad be created at `now`? `true` unless a post-failure backoff is still in effect.
pub fn allow(&self, now: Instant) -> bool {
match self.retry_at {
None => true,
Some(t) => now >= t,
}
}
/// Record a successful pad creation — clear the backoff so the next failure starts fresh.
pub fn on_success(&mut self) {
self.retry_at = None;
self.backoff = Duration::ZERO;
}
/// Record a failed pad creation at `now` — arm the next retry a capped-exponential backoff out.
pub fn on_failure(&mut self, now: Instant) {
self.backoff = if self.backoff.is_zero() {
FIRST_BACKOFF
} else {
(self.backoff * 2).min(MAX_BACKOFF)
};
self.retry_at = Some(now + self.backoff);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_gate_allows_creation() {
assert!(PadGate::new().allow(Instant::now()));
}
#[test]
fn failure_blocks_until_backoff_elapses_then_allows_one_retry() {
let t0 = Instant::now();
let mut g = PadGate::new();
g.on_failure(t0);
// Blocked for the whole first-backoff window…
assert!(!g.allow(t0));
assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1)));
// …then a single retry is permitted.
assert!(g.allow(t0 + FIRST_BACKOFF));
}
#[test]
fn consecutive_failures_double_the_backoff_up_to_the_cap() {
let t0 = Instant::now();
let mut g = PadGate::new();
g.on_failure(t0); // window = 1s
g.on_failure(t0); // window = 2s
assert!(!g.allow(t0 + FIRST_BACKOFF)); // still blocked at 1s — the window is now 2s
assert!(g.allow(t0 + 2 * FIRST_BACKOFF));
// Drive well past the cap and confirm the window never exceeds MAX_BACKOFF.
for _ in 0..20 {
g.on_failure(t0);
}
assert!(!g.allow(t0 + MAX_BACKOFF - Duration::from_millis(1)));
assert!(g.allow(t0 + MAX_BACKOFF));
}
#[test]
fn success_resets_the_backoff() {
let t0 = Instant::now();
let mut g = PadGate::new();
g.on_failure(t0);
g.on_failure(t0); // window grown to 2s
g.on_success();
// Success clears the backoff: creation is immediately allowed again.
assert!(g.allow(t0));
// The next failure starts from FIRST_BACKOFF, not the grown value.
g.on_failure(t0);
assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1)));
assert!(g.allow(t0 + FIRST_BACKOFF));
}
}
@@ -96,7 +96,7 @@ pub mod btn1 {
pub mod btn2 {
pub const PS: u8 = 0x01;
pub const TOUCHPAD: u8 = 0x02;
#[allow(dead_code)]
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
pub const MUTE: u8 = 0x04;
}
@@ -223,6 +223,12 @@ impl DsState {
if on(gs::BTN_TOUCHPAD) {
s.buttons[2] |= btn2::TOUCHPAD;
}
// The mic-mute / capture button (Deck '…' QAM on the Steam path). Clients send it as
// BTN_MISC1; without this the DualSense mute button was inert on every PlayStation-family
// virtual pad. Rebuilt from the wire bit each frame like PS/TOUCHPAD, so no persistence gap.
if on(gs::BTN_MISC1) {
s.buttons[2] |= btn2::MUTE;
}
s
}
@@ -439,10 +445,119 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
}
}
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
#[derive(Clone, Default)]
pub struct HidoutDedup {
led: Option<(u8, u8, u8)>,
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
}
impl HidoutDedup {
/// Forget all remembered state — call when a pad is created or unplugged so the first feedback
/// after a (re)connect is always forwarded.
pub fn clear(&mut self) {
*self = HidoutDedup::default();
}
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
match h {
HidOutput::Led { r, g, b, .. } => {
let v = Some((*r, *g, *b));
if self.led == v {
false
} else {
self.led = v;
true
}
}
HidOutput::PlayerLeds { bits, .. } => {
let v = Some(*bits);
if self.player_leds == v {
false
} else {
self.player_leds = v;
true
}
}
HidOutput::Trigger { which, effect, .. } => {
let slot = (*which as usize).min(1);
if self.trigger[slot].as_deref() == Some(effect.as_slice()) {
false
} else {
self.trigger[slot] = Some(effect.clone());
true
}
}
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
#[test]
fn hidout_dedup_forwards_only_changes() {
let mut d = HidoutDedup::default();
let led = |r| HidOutput::Led {
pad: 0,
r,
g: 0,
b: 0,
};
// First value forwards; an exact repeat is dropped; a change forwards again.
assert!(d.should_forward(&led(10)));
assert!(!d.should_forward(&led(10)));
assert!(d.should_forward(&led(20)));
// Player LEDs dedup on their own field, independent of the lightbar.
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
assert!(d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
let trig = |which, byte| HidOutput::Trigger {
pad: 0,
which,
effect: vec![byte, 0, 0],
};
assert!(d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
assert!(!d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
// One-shot haptic pulses are never deduped.
let haptic = HidOutput::TrackpadHaptic {
pad: 0,
side: 0,
amplitude: 1,
period: 2,
count: 3,
};
assert!(d.should_forward(&haptic));
assert!(d.should_forward(&haptic));
// `clear` re-arms every kind.
d.clear();
assert!(d.should_forward(&led(20)));
assert!(d.should_forward(&pl(0b101)));
assert!(d.should_forward(&trig(0, 2)));
}
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
@@ -669,12 +784,16 @@ mod tests {
assert_eq!(r[53], 0x0A);
}
/// The wire touchpad-click bit (Moonlight's extended position) lands in `buttons[2]`.
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
/// `buttons[2]`.
#[test]
fn from_gamepad_maps_touchpad_click() {
use punktfunk_core::input::gamepad as gs;
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD);
// BTN_MISC1 → the mic-mute / capture button (G6: was previously dropped entirely).
let s = DsState::from_gamepad(gs::BTN_MISC1, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::MUTE);
let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], 0);
}
@@ -156,6 +156,15 @@ pub struct SteamState {
/// (with Z/RZ negated) on the separate sensors evdev.
pub accel: [i16; 3],
pub gyro: [i16; 3],
/// Trackpad CLICK from the rich plane ([`apply_rich`]), kept OUTSIDE `buttons` because
/// [`SteamControllerManager::handle`](super::super::linux::steam_controller::SteamControllerManager)
/// rebuilds `buttons` from the gamepad frame every tick — exactly why DualSense keeps
/// `touch_click` separate. Merged into the report's click bits in [`serialize_deck_state`]. The
/// DualSense touchpad-click WIRE button still sets `RPAD_CLICK` in `buttons` via
/// [`from_gamepad`](Self::from_gamepad); the two sources are OR'd at serialize, so each releases
/// independently (a released `BTN_TOUCHPAD` can't strand a rich click, and vice-versa).
pub lpad_click: bool,
pub rpad_click: bool,
}
impl SteamState {
@@ -273,12 +282,14 @@ impl SteamState {
// left pad, anything else (0 single / 2 right) = right pad.
if surface == 1 {
self.press(btn::LPAD_TOUCH, touch);
self.press(btn::LPAD_CLICK, click);
// Click lives in its own field, NOT `buttons` — `handle()` rebuilds `buttons`
// every gamepad frame and would otherwise wipe a held click (the bug this fixes).
self.lpad_click = click;
self.lpad_x = x;
self.lpad_y = flip_y(y);
} else {
self.press(btn::RPAD_TOUCH, touch);
self.press(btn::RPAD_CLICK, click);
self.rpad_click = click;
self.rpad_x = x;
self.rpad_y = flip_y(y);
}
@@ -297,7 +308,18 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq
r[2] = ID_CONTROLLER_DECK_STATE;
r[3] = 0x3C; // payload length; the kernel ignores it
r[4..8].copy_from_slice(&seq.to_le_bytes());
r[8..16].copy_from_slice(&st.buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
// Rich-plane trackpad clicks live in their own fields (see `SteamState`) so a button-only frame
// can't wipe them; merge them into the report's click bits here. RPAD_CLICK may ALSO come from
// the DualSense touchpad-click wire button via `from_gamepad` — OR both, so either source lights
// it and each releases independently.
let mut buttons = st.buttons;
if st.lpad_click {
buttons |= btn::LPAD_CLICK;
}
if st.rpad_click {
buttons |= btn::RPAD_CLICK;
}
r[8..16].copy_from_slice(&buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes());
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
@@ -611,7 +633,9 @@ mod tests {
pressure: 100,
});
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
assert_ne!(s.buttons & btn::LPAD_CLICK, 0);
// Click now rides its own field (kept OUT of `buttons`, which handle() rebuilds each frame).
assert!(s.lpad_click);
assert_eq!(s.buttons & btn::LPAD_CLICK, 0);
assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
@@ -624,6 +648,7 @@ mod tests {
pressure: 0,
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert!(!s.rpad_click); // click:false → field cleared
assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
@@ -640,6 +665,34 @@ mod tests {
assert_eq!(s.rpad_y, 32767);
}
/// Regression (G2): a held trackpad click set on the rich plane must survive the per-frame
/// `buttons` rebuild that `SteamControllerManager::handle` performs via `from_gamepad`. Before
/// the fix, click lived in `buttons` and the rebuild wiped it every gamepad frame.
#[test]
fn rich_click_survives_a_buttons_rebuild() {
let mut held = SteamState::neutral();
held.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: 0,
y: 0,
pressure: 0,
});
assert!(held.lpad_click);
// A following button-only frame: from_gamepad rebuilds buttons (dropping the click bit),
// then handle() carries the rich fields over — the click must still reach the report.
let mut merged = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
assert_eq!(merged.buttons & btn::LPAD_CLICK, 0); // the rebuild alone loses it (the old bug)
merged.lpad_click = held.lpad_click; // what handle() now preserves
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, &merged, 0);
let serialized = u64::from_le_bytes(r[8..16].try_into().unwrap());
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
}
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
#[test]
@@ -18,21 +18,23 @@
//! must already be installed; the installer stages it.)
use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
DS_TOUCH_W,
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN,
DS_TOUCH_H, DS_TOUCH_W,
};
use super::gamepad_raii::PadChannel;
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{anyhow, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::{Duration, Instant};
use windows::core::{w, GUID, HRESULT, PCWSTR};
use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
};
use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
@@ -71,50 +73,6 @@ struct DsWinPad {
last_out_seq: u32,
}
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics).
#[repr(C)]
struct SwCreateCtx {
event: HANDLE,
result: HRESULT,
instance_id: [u16; 128],
}
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the
/// creator, which blocks on the event (so there's no concurrent access to `*ctx`).
unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE,
result: HRESULT,
ctx: *const c_void,
id: PCWSTR,
) {
if !ctx.is_null() {
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the
// creator blocks on the event). `id` is a NUL-terminated string for the callback's duration.
unsafe {
let c = ctx as *mut SwCreateCtx;
(*c).result = result;
if !id.is_null() {
for i in 0..(*c).instance_id.len() - 1 {
let ch = *id.0.add(i);
(*c).instance_id[i] = ch;
if ch == 0 {
break;
}
}
}
let _ = SetEvent((*c).event);
}
}
}
impl SwCreateCtx {
fn instance_id(&self) -> Option<String> {
let len = self.instance_id.iter().position(|&c| c == 0)?;
(len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len]))
}
}
/// The PnP identity for a virtual controller devnode — varies by controller type so the same
/// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4
/// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below.
@@ -334,13 +292,24 @@ impl DsWinPad {
self.ts = self.ts.wrapping_add(1);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the
// XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect
// field to publish last: the `pf_dualsense` driver streams the whole `input` region to game
// READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report)
// is consumed by the game's HID stack, not the driver — so it cannot serve as a separable
// publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout +
// driver change, deferred). The `Release` fence after the copy orders the report-body stores
// ahead of this pad's next `Release` publish (the bootstrap/seq stores in `channel.pump()`),
// giving the copy Release visibility on a weakly-ordered core (ARM64); on x86-TSO it is a
// no-op. Residual: absent a driver-side `Acquire` on a per-frame input generation, a torn
// single frame is still theoretically possible but self-heals on the next ~250 Hz write.
unsafe {
std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_base().add(OFF_INPUT),
r.len(),
)
);
fence(Ordering::Release);
};
}
@@ -356,9 +325,14 @@ impl DsWinPad {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
};
self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes.
// SAFETY: base points at SHM_SIZE bytes; `OFF_OUT_SEQ` (== 72) is 4-aligned off the
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `out_seq` AFTER
// writing the `output` report, so an `Acquire` load here orders the `output` copy below after
// it — a fresh seq guarantees a coherent snapshot of the output bytes on a weakly-ordered core
// (ARM64). On x86-TSO it is a plain load.
let seq = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
(*(self.channel.data_base().add(OFF_OUT_SEQ) as *const AtomicU32))
.load(Ordering::Acquire)
};
if seq != self.last_out_seq {
self.last_out_seq = seq;
@@ -384,8 +358,16 @@ 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>,
broken: bool,
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`.
remap: crate::inject::steam_remap::RemapConfig,
}
impl Default for DualSenseWindowsManager {
@@ -400,8 +382,10 @@ impl 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],
broken: false,
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
@@ -423,6 +407,7 @@ impl DualSenseWindowsManager {
*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 {
@@ -430,8 +415,13 @@ impl DualSenseWindowsManager {
}
self.ensure(idx);
let prev = self.state[idx];
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost, exactly as
// `linux/dualsense.rs` does.
let buttons =
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad(
f.buttons,
buttons,
f.ls_x,
f.ls_y,
f.rs_x,
@@ -486,7 +476,7 @@ impl DualSenseWindowsManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match DsWinPad::open(idx as u8) {
@@ -498,11 +488,13 @@ impl DualSenseWindowsManager {
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 — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)");
self.broken = true;
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());
}
}
}
@@ -527,7 +519,11 @@ impl DualSenseWindowsManager {
}
}
for h in fb.hidout {
hidout(h);
// 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);
}
}
}
}
@@ -17,6 +17,7 @@ use super::dualshock4_proto::{
};
use super::gamepad_raii::PadChannel;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput};
use std::time::{Duration, Instant};
@@ -149,7 +150,12 @@ pub struct DualShock4WindowsManager {
last_rumble: Vec<(u16, u16)>,
last_led: Vec<Option<(u8, u8, u8)>>,
last_write: Vec<Instant>,
broken: bool,
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the 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 {
@@ -166,7 +172,8 @@ impl DualShock4WindowsManager {
last_rumble: vec![(0, 0); MAX_PADS],
last_led: vec![None; MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
@@ -196,8 +203,13 @@ impl DualShock4WindowsManager {
}
self.ensure(idx);
let prev = self.state[idx];
// Steam back grips have no DS4 slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost, exactly as
// `linux/dualshock4.rs` does.
let buttons =
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad(
f.buttons,
buttons,
f.ls_x,
f.ls_y,
f.rs_x,
@@ -251,7 +263,7 @@ impl DualShock4WindowsManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match Ds4WinPad::open(idx as u8) {
@@ -265,10 +277,11 @@ impl DualShock4WindowsManager {
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 — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)");
self.broken = true;
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());
}
}
}
@@ -22,19 +22,20 @@
use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
use std::ffi::c_void;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use windows::core::{w, HSTRING, PCWSTR};
use windows::core::{w, HRESULT, HSTRING, PCWSTR};
use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_Get_DevNode_Status, CM_Locate_DevNodeW, CM_DEVNODE_STATUS_FLAGS, CM_LOCATE_DEVNODE_NORMAL,
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
};
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::{
DuplicateHandle, GetLastError, SetLastError, DUPLICATE_HANDLE_OPTIONS, ERROR_ALREADY_EXISTS,
HANDLE, INVALID_HANDLE_VALUE, WIN32_ERROR,
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR,
};
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
@@ -45,7 +46,7 @@ use windows::Win32::System::Memory::{
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
use windows::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
GetCurrentProcess, OpenProcess, SetEvent, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
};
/// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
@@ -65,11 +66,37 @@ pub(super) struct Shm {
view: MEMORY_MAPPED_VIEW_ADDRESS,
}
/// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable
/// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`).
fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
/// Owns an SDDL-derived `SECURITY_ATTRIBUTES` **and** the OS-allocated security descriptor its
/// `lpSecurityDescriptor` points at (`ConvertStringSecurityDescriptorToSecurityDescriptorW`
/// `LocalAlloc`s the descriptor). Drop `LocalFree`s it, so a `SecAttr` must outlive every
/// `CreateFileMappingW` that borrows its `sa`: the section copies the security info at create time, so
/// freeing after the create returns is safe — hence [`Shm::create_named`] builds one `SecAttr` before
/// its squat-retry loop and reuses it across attempts instead of re-allocating (and re-leaking) per
/// attempt.
struct SecAttr {
sa: SECURITY_ATTRIBUTES,
psd: PSECURITY_DESCRIPTOR,
}
impl Drop for SecAttr {
fn drop(&mut self) {
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
// allocated for us with `LocalAlloc`; release it with the matching `LocalFree`. Every
// `CreateFileMappingW` that borrowed `self.sa` has already returned (so has copied the
// security info into its section object), so no live `SECURITY_ATTRIBUTES` still points here.
unsafe {
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
}
}
}
/// Build a [`SecAttr`] from an SDDL literal — a `SECURITY_ATTRIBUTES` plus the descriptor it borrows,
/// freed together on drop. The returned owner must outlive every `CreateFileMappingW` that borrows
/// its `sa` (see [`SecAttr`]).
fn sddl_sa(sddl: PCWSTR) -> Result<SecAttr> {
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (leaked — see above).
// SAFETY: the SDDL literal is valid; `psd` receives a `LocalAlloc`'d descriptor that `SecAttr`'s
// `Drop` `LocalFree`s once the section create that borrows it has returned.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl,
@@ -78,10 +105,13 @@ fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
None,
)?;
}
Ok(SECURITY_ATTRIBUTES {
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
Ok(SecAttr {
sa: SECURITY_ATTRIBUTES {
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
},
psd,
})
}
@@ -93,7 +123,9 @@ impl Shm {
/// validated on-glass — `design/idd-push-security.md`).
pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?;
Self::create_inner(&sa, PCWSTR::null(), size).context("create unnamed gamepad DATA section")
// `sa` owns the descriptor and lives to the end of this fn, so it outlives the create.
Self::create_inner(&sa.sa, PCWSTR::null(), size)
.context("create unnamed gamepad DATA section")
}
/// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL
@@ -106,6 +138,8 @@ impl Shm {
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
/// another host instance's) mailbox.
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
// Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS
// allocation it owns) lives to the end of this fn, so it outlives every create below.
let sa = sddl_sa(w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"))?;
for attempt in 0..5 {
if attempt > 0 {
@@ -113,7 +147,7 @@ impl Shm {
}
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
unsafe { SetLastError(WIN32_ERROR(0)) };
let shm = Self::create_inner(&sa, PCWSTR(name.as_ptr()), size)
let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size)
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
@@ -131,7 +165,8 @@ impl Shm {
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the
// descriptor behind `sa` outlives this call (leaked by `sddl_sa`).
// descriptor behind `sa` outlives this call (owned by the caller's `SecAttr`, freed only once
// every create that borrows it has returned).
let map = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
@@ -403,6 +438,53 @@ impl PadChannel {
}
}
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). Shared by every
/// Windows companion backend (XUSB / DualSense / DS4): each `create_swdevice` builds one, hands it to
/// `SwDeviceCreate` alongside [`sw_create_cb`], and reads [`instance_id`](Self::instance_id) once the
/// callback has signalled.
#[repr(C)]
pub(super) struct SwCreateCtx {
pub(super) event: HANDLE,
pub(super) result: HRESULT,
pub(super) instance_id: [u16; 128],
}
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the
/// creator, which blocks on the event (so there's no concurrent access to `*ctx`).
pub(super) unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE,
result: HRESULT,
ctx: *const c_void,
id: PCWSTR,
) {
if !ctx.is_null() {
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the
// creator blocks on the event). `id` is a NUL-terminated string for the callback's duration.
unsafe {
let c = ctx as *mut SwCreateCtx;
(*c).result = result;
if !id.is_null() {
for i in 0..(*c).instance_id.len() - 1 {
let ch = *id.0.add(i);
(*c).instance_id[i] = ch;
if ch == 0 {
break;
}
}
}
let _ = SetEvent((*c).event);
}
}
}
impl SwCreateCtx {
pub(super) fn instance_id(&self) -> Option<String> {
let len = self.instance_id.iter().position(|&c| c == 0)?;
(len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len]))
}
}
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
/// `SwDeviceClose` each backend used to call in its `Drop`.
pub(super) struct SwDevice(HSWDEVICE);
@@ -12,17 +12,19 @@
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
use super::gamepad_raii::PadChannel;
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{anyhow, Result};
use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::{Duration, Instant};
use windows::core::{w, GUID, HRESULT, PCWSTR};
use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
};
use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
// asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from
@@ -43,49 +45,6 @@ const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics).
#[repr(C)]
struct SwCreateCtx {
event: HANDLE,
result: HRESULT,
instance_id: [u16; 128],
}
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result + wake the creator.
unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE,
result: HRESULT,
ctx: *const c_void,
id: PCWSTR,
) {
if !ctx.is_null() {
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the
// creator blocks on the event). `id` is a NUL-terminated string for the callback's duration.
unsafe {
let c = ctx as *mut SwCreateCtx;
(*c).result = result;
if !id.is_null() {
for i in 0..(*c).instance_id.len() - 1 {
let ch = *id.0.add(i);
(*c).instance_id[i] = ch;
if ch == 0 {
break;
}
}
}
let _ = SetEvent((*c).event);
}
}
}
impl SwCreateCtx {
fn instance_id(&self) -> Option<String> {
let len = self.instance_id.iter().position(|&c| c == 0)?;
(len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len]))
}
}
/// Spawn the `pf_xusb_<index>` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The
/// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads,
/// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but
@@ -235,7 +194,13 @@ impl XusbWinPad {
let base = self.channel.data_base();
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
// writes. Single owner (`&mut self`), so no concurrent writer races these stores.
// writes. Single owner (`&mut self`), so no concurrent writer races these stores. `packet` (the
// field XInput reads to detect a new state) is published LAST: the `Release` fence orders the
// state-body stores above before the `Release` `AtomicU32` store of `packet`, so the driver —
// which `Acquire`-loads `packet` — never observes a bumped packet over a torn body on a
// weakly-ordered core (ARM64). On x86-TSO both are plain stores. `OFF_PACKET` (== 4) is
// 4-aligned off the page-aligned section base, so the `AtomicU32` view is valid (mirrors the
// seq-fenced publish in `gamepad_raii::PadChannel::create`).
unsafe {
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
*base.add(OFF_LT) = lt;
@@ -244,7 +209,8 @@ impl XusbWinPad {
std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry);
std::ptr::write_unaligned(base.add(OFF_PACKET) as *mut u32, self.packet);
fence(Ordering::Release);
(*(base.add(OFF_PACKET) as *const AtomicU32)).store(self.packet, Ordering::Release);
}
}
@@ -258,8 +224,13 @@ impl XusbWinPad {
// SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes.
let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) };
// SAFETY: base points at SHM_SIZE bytes; `OFF_RUMBLE_SEQ` (== 24) is 4-aligned off the
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `rumble_seq` AFTER
// writing the rumble bytes, so an `Acquire` load here orders the `rumble_large`/`rumble_small`
// reads below after it — a fresh seq guarantees a coherent snapshot of the rumble bytes on a
// weakly-ordered core (ARM64). On x86-TSO it is a plain load.
let seq =
unsafe { (*(base.add(OFF_RUMBLE_SEQ) as *const AtomicU32)).load(Ordering::Acquire) };
if seq == self.last_rumble_seq {
return None;
}
@@ -291,7 +262,9 @@ pub struct GamepadManager {
/// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the
/// const's docs.
last_active: Vec<Instant>,
broken: bool,
/// 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 {
@@ -306,12 +279,12 @@ impl GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
last_rumble: vec![(0, 0); MAX_PADS],
last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(),
broken: false,
gate: PadGate::new(),
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match XusbWinPad::open(idx as u8) {
@@ -322,44 +295,52 @@ impl GamepadManager {
);
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 — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)");
self.broken = true;
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());
}
}
}
pub fn handle(&mut self, ev: &GamepadEvent) {
let GamepadEvent::State(f) = ev else {
return; // Arrival metadata — the pad is created lazily on the first State
};
let idx = f.index.max(0) 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;
self.last_rumble[i] = (0, 0);
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (Xbox 360/Windows)");
self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
let idx = f.index.max(0) 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;
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(
(f.buttons & 0xffff) as u16,
f.left_trigger,
f.right_trigger,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
);
}
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(
(f.buttons & 0xffff) as u16,
f.left_trigger,
f.right_trigger,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
);
}
}
+47 -4
View File
@@ -5335,11 +5335,54 @@ mod tests {
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0)));
assert_eq!(s.left_trigger, 255);
assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0)));
}
// The punktfunk/1 button bits are the GameStream bits — one wire contract end to end.
assert_eq!(BTN_A, crate::gamestream::gamepad::BTN_A);
assert_eq!(BTN_GUIDE, crate::gamestream::gamepad::BTN_GUIDE);
assert_eq!(BTN_DPAD_UP, crate::gamestream::gamepad::BTN_DPAD_UP);
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value, read
/// through the GameStream namespace (`crate::gamestream::gamepad`, which re-exports
/// `punktfunk_core::input::gamepad` — the punktfunk/1 native wire and the GameStream/Limelight
/// wire are one and the same). Renumbering a bit in core, or dropping one from that re-export,
/// silently breaks every already-shipped client, so it must fail here first. This is the host
/// counterpart to the client-side C-ABI cross-checks in the Apple/Android gamepad tests.
#[test]
fn gamepad_wire_bits_are_pinned() {
use crate::gamestream::gamepad as gm;
use punktfunk_core::input::gamepad as pf;
// buttonFlags — low 16 bits, named via the GameStream re-export the injectors use.
assert_eq!(gm::BTN_DPAD_UP, 0x0000_0001);
assert_eq!(gm::BTN_DPAD_DOWN, 0x0000_0002);
assert_eq!(gm::BTN_DPAD_LEFT, 0x0000_0004);
assert_eq!(gm::BTN_DPAD_RIGHT, 0x0000_0008);
assert_eq!(gm::BTN_START, 0x0000_0010);
assert_eq!(gm::BTN_BACK, 0x0000_0020);
assert_eq!(gm::BTN_LS_CLICK, 0x0000_0040);
assert_eq!(gm::BTN_RS_CLICK, 0x0000_0080);
assert_eq!(gm::BTN_LB, 0x0000_0100);
assert_eq!(gm::BTN_RB, 0x0000_0200);
assert_eq!(gm::BTN_GUIDE, 0x0000_0400);
assert_eq!(gm::BTN_A, 0x0000_1000);
assert_eq!(gm::BTN_B, 0x0000_2000);
assert_eq!(gm::BTN_X, 0x0000_4000);
assert_eq!(gm::BTN_Y, 0x0000_8000);
// buttonFlags2 — high 16 bits: back-grip paddles (re-exported), plus the touchpad-click /
// Share bits the DualSense/DS4 protos consume straight from core.
assert_eq!(gm::BTN_PADDLE1, 0x0001_0000);
assert_eq!(gm::BTN_PADDLE2, 0x0002_0000);
assert_eq!(gm::BTN_PADDLE3, 0x0004_0000);
assert_eq!(gm::BTN_PADDLE4, 0x0008_0000);
assert_eq!(pf::BTN_TOUCHPAD, 0x0010_0000);
assert_eq!(pf::BTN_MISC1, 0x0020_0000);
// Axis ids — dense, 0-based.
assert_eq!(
[
pf::AXIS_LS_X,
pf::AXIS_LS_Y,
pf::AXIS_RS_X,
pf::AXIS_RS_Y,
pf::AXIS_LT,
pf::AXIS_RT,
],
[0, 1, 2, 3, 4, 5]
);
}
/// Pull and byte-verify `count` synthetic frames through the C ABI connection.