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
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:
@@ -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 60–240 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user