From 5cd66eca59e5b499080604143fa8e2ad72dd30cd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 11:12:47 +0200 Subject: [PATCH 01/19] fix(gamepad/apple): stop releasing held guide on concurrent input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sync()` XOR-diffs the full `GamepadWire.allButtons` set (which includes guide) against `slot.buttons`, but `buttonMask` deliberately omits guide — it's driven separately by the Home handler via `sendGuide`. So while guide was physically held, the first stick/trigger/face-button move made `changed` carry the guide bit and the diff loop emitted a spurious guide-UP (then the real release was swallowed by `sendGuide`'s `guard now != slot.buttons`). Effect: you could not hold PS/guide while doing anything else — e.g. holding guide to keep the host's Steam overlay engaged released it the instant you touched a stick. The Rust reference client folds guide through the same diff as every other button and has no such split. Fix: preserve the current held guide bit through the diff (`buttonMask(g) | (slot.buttons & GamepadWire.guide)`) so guide is never seen as "changed"; `sendGuide` stays the sole toggler and `flush`/`allButtons` still release it on close/deactivation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/PunktfunkKit/Gamepad/GamepadCapture.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index f15b02c2..44da56d5 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -257,7 +257,12 @@ public final class GamepadCapture { /// tagged with the slot's wire pad index. private func sync(_ slot: Slot, _ g: GCExtendedGamepad) { guard !suspended else { return } - let newButtons = Self.buttonMask(g) + // guide is driven separately (`sendGuide`, off the Home handler) and deliberately kept out + // of `buttonMask`. Preserve its current held state here so the XOR diff below never sees it + // as "changed" — otherwise the first stick/button move after a guide press would emit a + // spurious guide-UP while the button is still physically held (and drop the bit from + // `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it. + let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide) let changed = newButtons ^ slot.buttons if changed != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 { From 68b9f108ab5ab23e2891297fed5dfbb35f777d89 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 11:14:26 +0200 Subject: [PATCH 02/19] feat(gamepad/apple): send Share/Create as BTN_MISC1 + pin wire bits to the C ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G5: buttonMask mapped the dedicated share/create/capture element onto BTN_BACK, the same bit as View (buttonOptions). On an Xbox-Series pad those are two distinct physical buttons, so Share was indistinguishable from View on the host and never delivered the capture bit the host already decodes (DualSense mute / Steam quick-access). Route it to BTN_MISC1 instead, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. Adds `misc1` to GamepadWire and allButtons so a held capture button is released on flush like the others. (On-glass verify owed on a real Xbox-Series pad; a clone pad that exposes one button as both buttonOptions and Share now emits back+misc1 for it — harmless on a plain xpad session and rare otherwise.) G22 (partial): define paddle1..4 for wire completeness, but leave them out of buttonMask/allButtons until the GameController paddleButton1..4 ↔ BTN_PADDLE physical correspondence is confirmed on a real Elite pad. G15: replace the 3-bit spot-check with an exhaustive assertion of every GamepadWire button/axis constant against the generated C ABI header (punktfunk_core.h), so any Swift-side drift from punktfunk_core::input::gamepad fails CI. swift build + full PunktfunkKit suite green (124 passed, 5 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 14 +++--- .../PunktfunkKit/Gamepad/GamepadWire.swift | 18 +++++++- .../PunktfunkKitTests/GamepadWireTests.swift | 45 ++++++++++++++++++- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 44da56d5..fa251b71 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -305,11 +305,15 @@ public final class GamepadCapture { if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } - // The share/create/capture element (Xbox Series share, a clone pad's screenshot button — - // e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose - // the create button BOTH as buttonOptions and as the share element this OR is harmless — - // same wire bit. - if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back } + // The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone + // pad's screenshot button — e.g. the GameSir G8's, below its d-pad) → the wire's capture + // bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad + // this is a button physically DISTINCT from View (buttonOptions, above), so it must not + // collapse onto back — the host reads MISC1 as its own control (DualSense mute / Steam + // quick-access). Caveat: a pad that surfaces ONE physical button as both buttonOptions and + // this share element now emits back+misc1 for it — harmless on a plain xpad session (no + // misc button) and rare otherwise. NOTE: on-glass verify on a real Xbox-Series pad. + if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.misc1 } if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift index 0d90c7cc..b2fae0d0 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift @@ -26,11 +26,27 @@ public enum GamepadWire { public static let y: UInt32 = 0x8000 /// DualSense touchpad click (Moonlight's extended-button bit position). public static let touchpadClick: UInt32 = 0x10_0000 + /// Misc / capture button — Xbox-Series Share, DualSense Create, Steam-Deck quick-access + /// (Moonlight's extended-button namespace; `input::gamepad::BTN_MISC1`). The host routes it to + /// the DualSense mute / Steam quick-access menu; a plain virtual xpad has no such button. + public static let misc1: UInt32 = 0x0020_0000 + /// Back-grip paddles (Xbox Elite P1–P4 / DualSense Edge / Steam-Deck L4-L5-R4-R5), in + /// Moonlight's extended-button namespace (`input::gamepad::BTN_PADDLE1..4`, R4/L4/R5/L5). + /// Defined for wire completeness and pinned by the tests; `GamepadCapture.buttonMask` does not + /// read them yet — the GameController `paddleButton1..4` ↔ BTN_PADDLE physical correspondence + /// needs confirming on a real Elite pad first (see the gamepad-review-cleanup plan, G22), so + /// they are intentionally absent from `allButtons` until that forwarding lands. + public static let paddle1: UInt32 = 0x0001_0000 + public static let paddle2: UInt32 = 0x0002_0000 + public static let paddle3: UInt32 = 0x0004_0000 + public static let paddle4: UInt32 = 0x0008_0000 + /// Every button `buttonMask`/`sendGuide` can set — walked by `sync`'s transition diff and by + /// `flush` on release. Paddles are excluded until their capture lands (see above). public static let allButtons: [UInt32] = [ dpadUp, dpadDown, dpadLeft, dpadRight, start, back, leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide, - a, b, x, y, touchpadClick, + a, b, x, y, touchpadClick, misc1, ] public static let axisLSX: UInt32 = 0 diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift index eee4c8c8..f93e8f38 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift @@ -27,11 +27,16 @@ final class GamepadWireTests: XCTestCase { XCTAssertEqual(GamepadWire.x, 0x4000) XCTAssertEqual(GamepadWire.y, 0x8000) XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000) + XCTAssertEqual(GamepadWire.misc1, 0x0020_0000) // Every button is enumerated exactly once (releaseAll walks this list). let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 } - XCTAssertEqual(combined, 0x0010_F7FF) - XCTAssertEqual(GamepadWire.allButtons.count, 16) + XCTAssertEqual(combined, 0x0030_F7FF) + XCTAssertEqual(GamepadWire.allButtons.count, 17) XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count) + // Paddles are defined but not yet forwarded, so they stay out of allButtons for now. + for paddle in [GamepadWire.paddle1, GamepadWire.paddle2, GamepadWire.paddle3, GamepadWire.paddle4] { + XCTAssertFalse(GamepadWire.allButtons.contains(paddle)) + } // Axis ids. XCTAssertEqual(GamepadWire.axisLSX, 0) XCTAssertEqual(GamepadWire.axisLSY, 1) @@ -41,6 +46,42 @@ final class GamepadWireTests: XCTestCase { XCTAssertEqual(GamepadWire.axisRT, 5) } + func testButtonBitsMatchTheCABIVerbatim() { + // Assert EVERY wire constant against the generated C ABI header (punktfunk_core.h, the same + // source `punktfunk_core::input::gamepad` emits), so a Swift-side edit that drifts from the + // Rust contract fails CI — not just the handful spot-checked above. (Cross-cutting review + // finding G15: the button values were re-declared per client with only a 3-of-19 check.) + XCTAssertEqual(GamepadWire.dpadUp, UInt32(PUNKTFUNK_BTN_DPAD_UP)) + XCTAssertEqual(GamepadWire.dpadDown, UInt32(PUNKTFUNK_BTN_DPAD_DOWN)) + XCTAssertEqual(GamepadWire.dpadLeft, UInt32(PUNKTFUNK_BTN_DPAD_LEFT)) + XCTAssertEqual(GamepadWire.dpadRight, UInt32(PUNKTFUNK_BTN_DPAD_RIGHT)) + XCTAssertEqual(GamepadWire.start, UInt32(PUNKTFUNK_BTN_START)) + XCTAssertEqual(GamepadWire.back, UInt32(PUNKTFUNK_BTN_BACK)) + XCTAssertEqual(GamepadWire.leftStickClick, UInt32(PUNKTFUNK_BTN_LS_CLICK)) + XCTAssertEqual(GamepadWire.rightStickClick, UInt32(PUNKTFUNK_BTN_RS_CLICK)) + XCTAssertEqual(GamepadWire.leftShoulder, UInt32(PUNKTFUNK_BTN_LB)) + XCTAssertEqual(GamepadWire.rightShoulder, UInt32(PUNKTFUNK_BTN_RB)) + XCTAssertEqual(GamepadWire.guide, UInt32(PUNKTFUNK_BTN_GUIDE)) + XCTAssertEqual(GamepadWire.a, UInt32(PUNKTFUNK_BTN_A)) + XCTAssertEqual(GamepadWire.b, UInt32(PUNKTFUNK_BTN_B)) + XCTAssertEqual(GamepadWire.x, UInt32(PUNKTFUNK_BTN_X)) + XCTAssertEqual(GamepadWire.y, UInt32(PUNKTFUNK_BTN_Y)) + XCTAssertEqual(GamepadWire.touchpadClick, UInt32(PUNKTFUNK_BTN_TOUCHPAD)) + XCTAssertEqual(GamepadWire.misc1, UInt32(PUNKTFUNK_GAMEPAD_BTN_MISC1)) + XCTAssertEqual(GamepadWire.paddle1, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE1)) + XCTAssertEqual(GamepadWire.paddle2, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE2)) + XCTAssertEqual(GamepadWire.paddle3, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE3)) + XCTAssertEqual(GamepadWire.paddle4, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE4)) + // Axis ids and pad count share the same header. + XCTAssertEqual(GamepadWire.axisLSX, UInt32(PUNKTFUNK_AXIS_LS_X)) + XCTAssertEqual(GamepadWire.axisLSY, UInt32(PUNKTFUNK_AXIS_LS_Y)) + XCTAssertEqual(GamepadWire.axisRSX, UInt32(PUNKTFUNK_AXIS_RS_X)) + XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y)) + XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT)) + XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT)) + XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS)) + } + func testPadIndexRidesFlagsOnEveryPerPadEvent() { // The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis. let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3) From 236c59754ba19387fec7e6c43ab99f55fdd77655 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 11:25:03 +0200 Subject: [PATCH 03/19] refactor(gamepad/windows): drop the dead shell fork, use pf-client-core's service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clients/windows/src/gamepad.rs was a 629-line near-verbatim fork of pf-client-core's SDL gamepad service, frozen at an old single-pad design. Commit 9822fc3b removed its attach/detach entry points but left the machinery, so `Worker.attached` was initialized None and never set — ~300-400 lines (button/axis/touchpad/motion forwarding, Ds5Feedback, the rumble/HID feedback loop) were logically unreachable, never flagged because the guards read a runtime Option the compiler can't prove is always None. The live remainder (pad enumeration + pin persistence) had drifted from core: it opened every device for metadata (vs core's no-open id-getters), force-enabled the Valve HIDAPI drivers unconditionally, lacked the steam_virtual skip (so it could pin Steam Input's virtual pad and kill gyro), and derived the pin key from an opened handle — risking a cross-process byte-mismatch with the session, which resolves the same key from id-getters. The shell's only live job is enumerating pads for the Settings list and persisting the pin; the spawned punktfunk-session already runs the full pf-client-core service and does all real forwarding (session/main.rs). So delete the fork and point the shell at pf_client_core::gamepad::GamepadService directly — its start()/pads()/set_pinned()/clone() + PadInfo{key,name, kind_label()} are a strict superset of what the shell uses. Idle, core's service is hands-off the hardware (id-getter metadata, no device open, HIDAPI off), which is the intended behavior and fixes the drift class above. - delete clients/windows/src/gamepad.rs (-629) and `mod gamepad;` - main.rs / app/mod.rs: use pf_client_core::gamepad::GamepadService - drop the now-unused direct sdl3 dep (pf-client-core pulls it on Windows with the same build-from-source,hidapi features); sync Cargo.lock Pre-checks (dev Mac): std mpsc Sender: Sync confirmed on the pinned 1.96.0 (so core's GamepadService is Sync for the WinUI cross-thread sharing, no core change needed); rustfmt clean; no dangling refs. Windows compile is deferred to CI (windows-only crate, unbuildable on macOS). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 - clients/windows/Cargo.toml | 8 +- clients/windows/src/app/mod.rs | 2 +- clients/windows/src/gamepad.rs | 629 --------------------------------- clients/windows/src/main.rs | 8 +- 5 files changed, 10 insertions(+), 638 deletions(-) delete mode 100644 clients/windows/src/gamepad.rs diff --git a/Cargo.lock b/Cargo.lock index 426e9c93..22bc2523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3046,7 +3046,6 @@ dependencies = [ "mdns-sd", "pf-client-core", "punktfunk-core", - "sdl3", "serde", "serde_json", "tracing", diff --git a/clients/windows/Cargo.toml b/clients/windows/Cargo.toml index 6db0d0de..87f1e626 100644 --- a/clients/windows/Cargo.toml +++ b/clients/windows/Cargo.toml @@ -62,10 +62,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63 # decode + present live in the spawned punktfunk-session binary.) ffmpeg-next = "8" -# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform; -# built from source via the bundled CMake on Windows (no system SDL3). -sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] } - +# Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service +# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3 +# itself (built from source via the bundled CMake on Windows) is pulled transitively by +# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here. mdns-sd = "0.20" async-channel = "2" serde = { version = "1", features = ["derive"] } diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 716e2e4d..1dee48d9 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -32,9 +32,9 @@ mod stream; mod style; use crate::discovery::{self, DiscoveredHost}; -use crate::gamepad::GamepadService; use crate::trust::{KnownHosts, Settings}; use hosts::HostsProps; +use pf_client_core::gamepad::GamepadService; use punktfunk_core::client::NativeClient; use speed::{SpeedProps, SpeedState}; use std::collections::HashMap; diff --git a/clients/windows/src/gamepad.rs b/clients/windows/src/gamepad.rs deleted file mode 100644 index 3a957c01..00000000 --- a/clients/windows/src/gamepad.rs +++ /dev/null @@ -1,629 +0,0 @@ -//! App-lifetime gamepad service over SDL3 (mirrors the Swift/GTK clients' `GamepadManager` + -//! capture/feedback). Ported near-verbatim from the GTK Linux client — SDL3 is cross-platform, -//! so the only Windows change is the build (`sdl3` is compiled from source via the bundled -//! CMake, since there is no system SDL3). -//! -//! One worker thread owns SDL for the process lifetime: it tracks connected pads, selects the -//! ONE controller forwarded as pad 0 (user pin, else the most recently connected), and — while -//! a session is attached — forwards buttons/axes, DualSense touchpad contacts and motion -//! samples (0xCC), and renders feedback: rumble on every pad, lightbar via SDL, and on a real -//! DualSense the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). -//! Held state is zeroed on the wire when the active pad switches or the session detaches, so -//! nothing sticks down. -//! -//! This thread is also the single consumer of the rumble and HID-output pull planes. - -use punktfunk_core::client::NativeClient; -use punktfunk_core::config::GamepadPref; -use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; -use punktfunk_core::quic::{HidOutput, RichInput}; -use std::collections::HashMap; -use std::sync::mpsc::{Receiver, Sender}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -/// Motion scale constants, shared convention with the other clients (`GamepadWire`): derived -/// from hid-playstation's math over the host's fixed calibration blob. SDL hands us gyro in -/// rad/s and accel in m/s²; the DualSense report wants raw LSBs. -const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI; -const ACCEL_LSB_PER_G: f32 = 10_000.0; -const G: f32 = 9.80665; - -#[derive(Clone, Debug)] -pub struct PadInfo { - /// Stable identity (`vid:pid:name`, the same format as `pf-client-core`'s `PadInfo::key`) - /// — persisted as `Settings::forward_pad` so the pin survives restarts AND reaches the - /// spawned session binary, whose own gamepad service applies the same key. - pub key: String, - pub name: String, - /// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense, - /// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360). - pub pref: GamepadPref, -} - -impl PadInfo { - /// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger - /// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`). - fn is_dualsense(&self) -> bool { - self.pref == GamepadPref::DualSense - } - - /// A short human label for the detected pad family, shown next to the name in the settings - /// GUI's controller list ("" for a generic pad the name already describes). - pub fn kind_label(&self) -> &'static str { - match self.pref { - GamepadPref::DualSense => "DualSense", - GamepadPref::DualShock4 => "DualShock 4", - GamepadPref::XboxOne => "Xbox One", - GamepadPref::SteamDeck => "Steam Deck", - GamepadPref::SteamController => "Steam Controller", - _ => "", - } - } -} - -/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create. -fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { - use sdl3::gamepad::GamepadType as T; - match t { - T::PS5 => GamepadPref::DualSense, - T::PS4 => GamepadPref::DualShock4, - T::XboxOne => GamepadPref::XboxOne, - _ => GamepadPref::Xbox360, - } -} - -enum Ctl { - Pin(Option), -} - -#[derive(Clone)] -pub struct GamepadService { - pads: Arc>>, - // `Arc>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the - // WinUI app shares it across the UI thread and the settings-pin path. - ctl: Arc>>, -} - -impl GamepadService { - pub fn start() -> GamepadService { - let pads = Arc::new(Mutex::new(Vec::new())); - let (ctl, ctl_rx) = std::sync::mpsc::channel(); - let p = pads.clone(); - if let Err(e) = std::thread::Builder::new() - .name("punktfunk-gamepad".into()) - .spawn(move || { - if let Err(e) = run(&p, &ctl_rx) { - tracing::warn!(error = %e, "gamepad service ended — pads disabled"); - } - }) - { - tracing::warn!(error = %e, "gamepad service failed to start"); - } - GamepadService { - pads, - ctl: Arc::new(Mutex::new(ctl)), - } - } - - /// Connected controllers, most recently attached first (the settings GUI's list order). - pub fn pads(&self) -> Vec { - self.pads.lock().unwrap().clone() - } - - /// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic. - /// The pin survives the pad disconnecting: it re-applies the moment a matching - /// controller shows up again (same semantics as `pf-client-core`'s service). The spawned - /// `punktfunk-session` binary owns the actual forwarding; this persists the selection. - pub fn set_pinned(&self, key: Option) { - let _ = self.ctl.lock().unwrap().send(Ctl::Pin(key)); - } -} - -fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) { - let _ = connector.send_input(&InputEvent { - kind, - _pad: [0; 3], - code, - x, - y: 0, - flags: 0, // pad index 0 — single-pad model - }); -} - -fn button_bit(b: sdl3::gamepad::Button) -> Option { - use sdl3::gamepad::Button; - Some(match b { - Button::South => wire::BTN_A, - Button::East => wire::BTN_B, - Button::West => wire::BTN_X, - Button::North => wire::BTN_Y, - Button::Back => wire::BTN_BACK, - Button::Start => wire::BTN_START, - Button::Guide => wire::BTN_GUIDE, - Button::LeftStick => wire::BTN_LS_CLICK, - Button::RightStick => wire::BTN_RS_CLICK, - Button::LeftShoulder => wire::BTN_LB, - Button::RightShoulder => wire::BTN_RB, - Button::DPadUp => wire::BTN_DPAD_UP, - Button::DPadDown => wire::BTN_DPAD_DOWN, - Button::DPadLeft => wire::BTN_DPAD_LEFT, - Button::DPadRight => wire::BTN_DPAD_RIGHT, - Button::Touchpad => wire::BTN_TOUCHPAD, - // Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button. - // PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`). - Button::RightPaddle1 => wire::BTN_PADDLE1, - Button::LeftPaddle1 => wire::BTN_PADDLE2, - Button::RightPaddle2 => wire::BTN_PADDLE3, - Button::LeftPaddle2 => wire::BTN_PADDLE4, - Button::Misc1 => wire::BTN_MISC1, - _ => return None, - }) -} - -/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput -/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255. -fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { - use sdl3::gamepad::Axis; - match axis { - Axis::LeftX => (wire::AXIS_LS_X, v as i32), - Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)), - Axis::RightX => (wire::AXIS_RS_X, v as i32), - Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)), - Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7), - Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7), - } -} - -/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the host -/// parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. Enable bits -/// select only the fields each update touches, so rumble (driven separately through SDL) and -/// untouched fields keep their state. -#[derive(Default)] -struct Ds5Feedback; - -impl Ds5Feedback { - const RIGHT_TRIGGER: usize = 10; - const LEFT_TRIGGER: usize = 21; - const PAD_LIGHTS: usize = 43; - const LED_RGB: usize = 44; - - fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] { - let mut p = [0u8; 47]; - let (flag, off) = if which == 1 { - (0x04, Self::RIGHT_TRIGGER) - } else { - (0x08, Self::LEFT_TRIGGER) - }; - p[0] = flag; - let n = effect.len().min(11); - p[off..off + n].copy_from_slice(&effect[..n]); - p - } - - fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] { - let mut p = [0u8; 47]; - p[1] = 0x04; // lightbar enable - p[Self::LED_RGB] = r; - p[Self::LED_RGB + 1] = g; - p[Self::LED_RGB + 2] = b; - p - } - - fn player_packet(bits: u8) -> [u8; 47] { - let mut p = [0u8; 47]; - p[1] = 0x10; // player-LED enable - p[Self::PAD_LIGHTS] = bits & 0x1F; - p - } -} - -struct Worker { - subsystem: sdl3::GamepadSubsystem, - opened: HashMap, - /// Connection order; the most recently connected is the auto selection. - order: Vec, - /// The user pin by stable key (`PadInfo::key`); resolved to an instance id per lookup - /// so it re-applies whenever a matching pad (re)connects. - pinned: Option, - attached: Option>, - /// Wire state of the active pad — zeroed on the wire at switch/detach. - last_axis: [i32; 6], - held_buttons: Vec, - /// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad - /// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad. - held_touches: std::collections::HashSet<(u8, u8)>, - last_accel: [i16; 3], -} - -impl Worker { - fn active_id(&self) -> Option { - self.pinned - .as_deref() - .and_then(|key| { - self.order - .iter() - .rev() // prefer the most recently connected pad with this identity - .find(|&&id| self.pad_info(id).is_some_and(|p| p.key == key)) - .copied() - }) - .or_else(|| self.order.last().copied()) - } - - fn pad_info(&self, id: u32) -> Option { - let pad = self.opened.get(&id)?; - let mut pref = pref_for_type( - self.subsystem - .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), - ); - let (vid, pid) = (pad.vendor_id().unwrap_or(0), pad.product_id().unwrap_or(0)); - // No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205, - // SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad. - if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) { - pref = GamepadPref::SteamDeck; - } - let name = pad.name().unwrap_or_else(|| "Controller".into()); - Some(PadInfo { - // Must match pf-client-core's `PadInfo::key` byte-for-byte — the persisted - // `forward_pad` is applied by BOTH services (this one and the session's). - key: format!("{vid:04x}:{pid:04x}:{name}"), - name, - pref, - }) - } - - /// Zero everything the host believes is held — on pad switch and detach. - fn flush_held(&mut self) { - if let Some(c) = &self.attached { - for b in self.held_buttons.drain(..) { - send(c, InputKind::GamepadButton, b, 0); - } - for (id, v) in self.last_axis.iter_mut().enumerate() { - if *v != 0 && *v != i32::MIN { - send(c, InputKind::GamepadAxis, id as u32, 0); - } - *v = i32::MIN; - } - for (surface, finger) in self.held_touches.drain() { - let rich = if surface == 0 { - RichInput::Touchpad { - pad: 0, - finger, - active: false, - x: 0, - y: 0, - } - } else { - RichInput::TouchpadEx { - pad: 0, - surface, - finger, - touch: false, - click: false, - x: 0, - y: 0, - pressure: 0, - } - }; - let _ = c.send_rich_input(rich); - } - } else { - self.held_buttons.clear(); - self.last_axis = [i32::MIN; 6]; - self.held_touches.clear(); - } - } - - /// Sensors stream only while a session wants them (they cost USB/BT bandwidth). - fn set_sensors(&mut self, enabled: bool) { - let Some(id) = self.active_id() else { return }; - if let Some(pad) = self.opened.get_mut(&id) { - use sdl3::sensor::SensorType; - for s in [SensorType::Gyroscope, SensorType::Accelerometer] { - if unsafe { pad.has_sensor(s) } { - let _ = pad.sensor_set_enabled(s, enabled); - } - } - } - } - - /// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam - /// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and - /// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned). - fn forward_touch( - &mut self, - which: u32, - touchpad: u32, - finger: u8, - x: f32, - y: f32, - active: bool, - ) { - let Some(c) = self.attached.as_ref() else { - return; - }; - let multi = self - .opened - .get(&which) - .map(|p| p.touchpads_count() >= 2) - .unwrap_or(false); - let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); - let surface = if multi { (touchpad as u8) + 1 } else { 0 }; - let rich = if multi { - RichInput::TouchpadEx { - pad: 0, - surface, - finger, - touch: active, - click: false, - x: (cx * 65535.0 - 32768.0) as i16, - y: (cy * 65535.0 - 32768.0) as i16, - pressure: 0, - } - } else { - RichInput::Touchpad { - pad: 0, - finger, - active, - x: (cx * 65535.0) as u16, - y: (cy * 65535.0) as u16, - } - }; - let _ = c.send_rich_input(rich); - if active { - self.held_touches.insert((surface, finger)); - } else { - self.held_touches.remove(&(surface, finger)); - } - } -} - -#[allow(clippy::too_many_lines)] -fn run(pads_out: &Mutex>, ctl: &Receiver) -> Result<(), String> { - // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own - // thread. - sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); - sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); - // Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the - // paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. - sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); - sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1"); - let sdl = sdl3::init().map_err(|e| e.to_string())?; - let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; - let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; - - let mut w = Worker { - subsystem, - opened: HashMap::new(), - order: Vec::new(), - pinned: None, - attached: None, - last_axis: [i32::MIN; 6], - held_buttons: Vec::new(), - held_touches: std::collections::HashSet::new(), - last_accel: [0; 3], - }; - - let publish = |w: &Worker| { - let mut list: Vec = w.order.iter().filter_map(|&id| w.pad_info(id)).collect(); - list.reverse(); // most recent first — the Settings list order - *pads_out.lock().unwrap() = list; - }; - - loop { - // Control plane from the UI thread. - loop { - match ctl.try_recv() { - Ok(Ctl::Pin(key)) => { - let before = w.active_id(); - w.pinned = key; - if w.active_id() != before { - w.flush_held(); - if w.attached.is_some() { - w.set_sensors(true); - } - } - publish(&w); - } - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone - } - } - - while let Some(event) = pump.poll_event() { - use sdl3::event::Event; - let active = w.active_id(); - match event { - Event::ControllerDeviceAdded { which, .. } => { - if !w.opened.contains_key(&which) { - match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) { - Ok(pad) => { - tracing::info!( - name = pad.name().unwrap_or_default(), - "gamepad attached" - ); - w.opened.insert(which, pad); - w.order.push(which); - if w.attached.is_some() && w.active_id() == Some(which) { - w.set_sensors(true); - } - publish(&w); - } - Err(e) => tracing::warn!(error = %e, "gamepad open failed"), - } - } - } - Event::ControllerDeviceRemoved { which, .. } => { - if w.opened.remove(&which).is_some() { - w.order.retain(|&id| id != which); - if active == Some(which) { - w.flush_held(); - } - tracing::info!("gamepad detached"); - publish(&w); - } - } - Event::ControllerButtonDown { which, button, .. } - if active == Some(which) && w.attached.is_some() => - { - if let Some(bit) = button_bit(button) { - w.held_buttons.push(bit); - send( - w.attached.as_ref().unwrap(), - InputKind::GamepadButton, - bit, - 1, - ); - } - } - Event::ControllerButtonUp { which, button, .. } - if active == Some(which) && w.attached.is_some() => - { - if let Some(bit) = button_bit(button) { - w.held_buttons.retain(|&b| b != bit); - send( - w.attached.as_ref().unwrap(), - InputKind::GamepadButton, - bit, - 0, - ); - } - } - Event::ControllerAxisMotion { - which, axis, value, .. - } if active == Some(which) && w.attached.is_some() => { - let (id, v) = axis_value(axis, value); - if w.last_axis[id as usize] != v { - w.last_axis[id as usize] = v; - send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v); - } - } - // Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy - // `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface. - Event::ControllerTouchpadDown { - which, - touchpad, - finger, - x, - y, - .. - } - | Event::ControllerTouchpadMotion { - which, - touchpad, - finger, - x, - y, - .. - } if active == Some(which) && w.attached.is_some() => { - w.forward_touch(which, touchpad as u32, finger as u8, x, y, true); - } - Event::ControllerTouchpadUp { - which, - touchpad, - finger, - x, - y, - .. - } if active == Some(which) && w.attached.is_some() => { - w.forward_touch(which, touchpad as u32, finger as u8, x, y, false); - } - // Motion: accel events update the cache; each gyro event ships a sample (the - // DualSense reports both at ~250 Hz). Scale convention shared with the other - // clients — sign/scale derived, not yet live-verified. - Event::ControllerSensorUpdated { - which, - sensor, - data, - .. - } if active == Some(which) && w.attached.is_some() => { - use sdl3::sensor::SensorType; - match sensor { - SensorType::Accelerometer => { - for (i, v) in data.iter().enumerate() { - w.last_accel[i] = - (v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16; - } - } - SensorType::Gyroscope => { - let mut gyro = [0i16; 3]; - for (i, v) in data.iter().enumerate() { - gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; - } - let _ = - w.attached - .as_ref() - .unwrap() - .send_rich_input(RichInput::Motion { - pad: 0, - gyro, - accel: w.last_accel, - }); - } - _ => {} - } - } - _ => {} - } - } - - // Feedback planes (this thread is their single consumer). Rumble arrives as - // self-terminating v2 envelopes: the host renews an active level and lets an abandoned one - // lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences - // at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the - // proven 5 s duration and rely on its periodic re-send as before. - if let Some(connector) = w.attached.clone() { - while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) { - if pad == 0 { - // Floor the lease so a jittered renewal can't gap the actuator between writes. - let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240)); - if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) { - // Surface a failed SDL rumble write: a swallowed error here (DualSense not in - // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The - // host logs the send side on 0xCA, so the two together pinpoint host-game vs - // client-render. - if let Err(e) = p.set_rumble(low, high, dur_ms) { - tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"); - } else { - tracing::debug!(low, high, "rumble: rendered"); - } - } else { - tracing::debug!(low, high, "rumble: received but no active pad to render"); - } - } - } - while let Ok(hid) = connector.next_hidout(Duration::ZERO) { - let Some(id) = w.active_id() else { continue }; - let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense()); - let Some(pad) = w.opened.get_mut(&id) else { - continue; - }; - match hid { - HidOutput::Led { pad: 0, r, g, b } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); - } - HidOutput::Led { pad: 0, r, g, b } => { - let _ = pad.set_led(r, g, b); - } - HidOutput::PlayerLeds { pad: 0, bits } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::player_packet(bits)); - } - HidOutput::Trigger { - pad: 0, - which, - ref effect, - } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect)); - } - _ => {} - } - } - } - - std::thread::sleep(Duration::from_millis(if w.attached.is_some() { - 2 - } else { - 30 - })); - } -} diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 4e28917c..c6340683 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -24,8 +24,6 @@ mod app; #[cfg(windows)] mod discovery; #[cfg(windows)] -mod gamepad; -#[cfg(windows)] mod gpu; #[cfg(windows)] mod probe; @@ -85,7 +83,11 @@ fn main() { tracing::error!(error = %e, "Windows App SDK bootstrap failed"); std::process::exit(1); } - let gamepad = gamepad::GamepadService::start(); + // The shared SDL gamepad service (pf-client-core). The shell only enumerates pads (Settings + // list) and persists the pin; the spawned punktfunk-session runs the SAME service and does the + // actual forwarding — so, unlike the old shell fork, we never `attach()` here. Idle it stays + // hands-off the hardware (id-getter metadata, no device open, Valve HIDAPI drivers off). + let gamepad = pf_client_core::gamepad::GamepadService::start(); if let Err(e) = app::run(identity, gamepad) { tracing::error!(error = %e, "WinUI app failed"); std::process::exit(1); From 2642ba6ad0f1ee4061b8e0c152582e537861a4ac Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 12:04:59 +0200 Subject: [PATCH 04/19] fix(gamepad/host): keep Steam Deck trackpad clicks across a button frame (G2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SteamControllerManager::handle rebuilds `SteamState.buttons` from the gamepad frame every tick via from_gamepad, preserving only the rich-plane TOUCH bits — so a held trackpad CLICK (set on the rich plane by apply_rich, stored in `buttons`) was wiped on the very next button/stick frame and only flickered back on the next rich event. This is the exact trap the DualSense backend already dodges by keeping click in a separate `touch_click` field. Mirror that: add persisted `lpad_click`/`rpad_click` bools to SteamState set by apply_rich (instead of pressing LPAD_CLICK/RPAD_CLICK into `buttons`), OR them into the report's click bits in serialize_deck_state, and preserve them across the rebuild in handle() like touch/coords/motion. RPAD_CLICK's other owner — the DualSense touchpad-click wire button via from_gamepad — stays in `buttons` and is OR'd at serialize, so the two sources release independently (a released BTN_TOUCHPAD can't strand a rich click, and vice-versa). Adds a regression test (rich_click_survives_a_buttons_rebuild). All 17 inject::{steam,dualsense,dualshock4}_proto tests pass on Linux (.21). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/inject/linux/steam_controller.rs | 6 ++ .../src/inject/proto/steam_proto.rs | 61 +++++++++++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index 79373dad..29894970 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -415,6 +415,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); } diff --git a/crates/punktfunk-host/src/inject/proto/steam_proto.rs b/crates/punktfunk-host/src/inject/proto/steam_proto.rs index 7cb0ab94..2e18b0fb 100644 --- a/crates/punktfunk-host/src/inject/proto/steam_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/steam_proto.rs @@ -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] From 43e52437c0f5ccc7aeae44bebbbbf7c9426e6026 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 12:04:59 +0200 Subject: [PATCH 05/19] fix(gamepad/host): map BTN_MISC1 to the DualSense mute button (G6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DsState::from_gamepad mapped GUIDE→PS and TOUCHPAD→TOUCHPAD into buttons[2] but never handled BTN_MISC1, so the mic-mute / capture button clients send was inert on every PlayStation-family virtual pad (DualSense/DualShock4), and btn2::MUTE was dead code. Map BTN_MISC1 → btn2::MUTE (rebuilt from the wire bit each frame like PS/TOUCHPAD, so no persistence gap) and drop the #[allow(dead_code)]. Test extended (from_gamepad_maps_touchpad_click); green on Linux (.21). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/inject/proto/dualsense_proto.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index 62889ae6..1487ad7c 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -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 } @@ -669,12 +675,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); } From 5109a4c80ab0d1e8d4f3db25d9bd2c1178d00e3a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 12:33:49 +0200 Subject: [PATCH 06/19] refactor(inject/host): extract the shared PadGate create-retry policy + fix the permanent broken latch (G3/G12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven virtual-pad managers (Linux uinput/uhid: gamepad, dualsense, dualshock4, steam_controller; Windows XUSB/UMDF: gamepad, dualsense, dualshock4) carried an identical copy-pasted `broken: bool` latch that was set on the FIRST pad-creation error and never cleared — so a single transient failure (a startup race on /dev/uinput, a momentary EBUSY, the Windows companion driver not yet ready) permanently disabled EVERY controller for the rest of the session, even after the cause cleared. Extract that latch into one shared, unit-tested `PadGate` (inject/pad_gate.rs) with the fix baked in: capped exponential backoff (1s doubling to 30s) instead of a permanent kill. After a failure, creation is blocked only until the backoff elapses — so the manager no longer re-attempts (and re-logs) on every one of the 60–240 input frames/sec — then a single retry is allowed; a success resets the backoff. A genuinely broken setup therefore self-heals within one backoff window of the fix (udev reload / driver install / next client connect) with no host restart. The gate is manager-wide, matching the old flag's semantics (these failures are systemic, not per-slot). This folds G3 (broken latch) into G12 (dedup the manager skeleton): the latch now lives in one place across all seven backends. Verified on the Linux host build (.21): cargo clippy -D warnings clean, full punktfunk-host suite 277 passed / 0 failed, 4 new PadGate tests green. Windows managers verified separately on the x64 box. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/inject.rs | 5 + .../src/inject/linux/dualsense.rs | 15 ++- .../src/inject/linux/dualshock4.rs | 15 ++- .../src/inject/linux/gamepad.rs | 19 ++- .../src/inject/linux/steam_controller.rs | 14 +- crates/punktfunk-host/src/inject/pad_gate.rs | 122 ++++++++++++++++++ .../src/inject/windows/dualsense_windows.rs | 14 +- .../src/inject/windows/dualshock4_windows.rs | 14 +- .../src/inject/windows/gamepad_windows.rs | 14 +- 9 files changed, 193 insertions(+), 39 deletions(-) create mode 100644 crates/punktfunk-host/src/inject/pad_gate.rs diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index b44a970b..9fc1696a 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -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"] diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index d71fe414..1b9a94bc 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -18,6 +18,7 @@ use super::dualsense_proto::{ 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}; @@ -180,8 +181,9 @@ pub struct DualSenseManager { /// 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, - /// 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, @@ -200,7 +202,7 @@ impl DualSenseManager { state: vec![DsState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -300,7 +302,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) { @@ -313,10 +315,11 @@ impl DualSenseManager { self.state[idx] = DsState::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 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()); } } } diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index f602aee8..53d24f58 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -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>, /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat). last_write: Vec, - /// 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()); } } } diff --git a/crates/punktfunk-host/src/inject/linux/gamepad.rs b/crates/punktfunk-host/src/inject/linux/gamepad.rs index edf83d55..722e6803 100644 --- a/crates/punktfunk-host/src/inject/linux/gamepad.rs +++ b/crates/punktfunk-host/src/inject/linux/gamepad.rs @@ -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}; @@ -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()); } } } diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index 29894970..8c7f7746 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -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}; @@ -353,7 +354,9 @@ pub struct SteamControllerManager { state: Vec, last_rumble: Vec<(u16, u16)>, last_write: Vec, - 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 { @@ -369,7 +372,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(), } } @@ -465,7 +468,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) { @@ -474,10 +477,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()); } } } diff --git a/crates/punktfunk-host/src/inject/pad_gate.rs b/crates/punktfunk-host/src/inject/pad_gate.rs new file mode 100644 index 00000000..d4813861 --- /dev/null +++ b/crates/punktfunk-host/src/inject/pad_gate.rs @@ -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, + /// 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)); + } +} diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index cbca4599..cc6ce9d9 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -23,6 +23,7 @@ use super::dualsense_proto::{ }; use super::gamepad_raii::PadChannel; 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; @@ -385,7 +386,9 @@ pub struct DualSenseWindowsManager { state: Vec, last_rumble: Vec<(u16, u16)>, last_write: Vec, - 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, } impl Default for DualSenseWindowsManager { @@ -401,7 +404,7 @@ impl DualSenseWindowsManager { state: vec![DsState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), } } @@ -486,7 +489,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) { @@ -499,10 +502,11 @@ impl DualSenseWindowsManager { self.state[idx] = DsState::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 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()); } } } diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index 6f24a994..fd91f308 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -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,9 @@ pub struct DualShock4WindowsManager { last_rumble: Vec<(u16, u16)>, last_led: Vec>, last_write: Vec, - 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, } impl Default for DualShock4WindowsManager { @@ -166,7 +169,7 @@ 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(), } } @@ -251,7 +254,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 +268,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()); } } } diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index dd245194..5d38c747 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -14,6 +14,7 @@ use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{anyhow, Result}; use std::ffi::c_void; use std::time::{Duration, Instant}; @@ -291,7 +292,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, - 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 +309,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,10 +325,11 @@ impl GamepadManager { ); self.pads[idx] = Some(p); self.last_rumble[idx] = (0, 0); + 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()); } } } From 0c427cb3f18130fc648191877e8e16472a1116c5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 13:14:02 +0200 Subject: [PATCH 07/19] fix(inject/host/linux): re-assert absolute gamepad button state each frame (G8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uinput gamepad backend emitted only XOR-changed button edges while advancing `prev_buttons` unconditionally. Because `emit()` is best-effort (a full kernel queue silently drops the write), a dropped EV_KEY edge was never re-synced — the button stayed stuck (pressed-not-released, or vice versa) until it next toggled. The axes never had this problem: they re-emit their absolute value every frame. Re-assert every mapped button's absolute state each frame, exactly like the axes, and drop the now-unused `prev_buttons` field. 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). The `emit()` "next frame re-syncs state" comment is now honest for buttons too. Verified on the Linux host build (.21): cargo clippy -D warnings clean (no dead-field warning), full punktfunk-host suite 277 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/inject/linux/gamepad.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/gamepad.rs b/crates/punktfunk-host/src/inject/linux/gamepad.rs index 722e6803..491cd1a9 100644 --- a/crates/punktfunk-host/src/inject/linux/gamepad.rs +++ b/crates/punktfunk-host/src/inject/linux/gamepad.rs @@ -266,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, next_effect_id: i16, gain: u32, @@ -370,7 +369,6 @@ impl VirtualPad { Ok(VirtualPad { fd, - prev_buttons: 0, effects: HashMap::new(), next_effect_id: 0, gain: 0xFFFF, @@ -413,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); From 6263108e1515245dbdcc68c62a46aeda7c7602af Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 13:36:13 +0200 Subject: [PATCH 08/19] fix(inject/host/windows): fold Steam back grips on the Windows DS/DS4 backends (G7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows DualSense and DualShock 4 managers passed the raw wire buttons straight into `DsState::from_gamepad`, so a client's Steam back grips (BTN_PADDLE1..4) were silently dropped and `PUNKTFUNK_STEAM_REMAP` was ignored — the Linux DS/DS4 backends already fold them via `steam_remap::fold_paddles`. Bring the Windows backends to parity: add a `remap: steam_remap::RemapConfig` field (`::from_env()` in `new()`) to both managers and fold the paddles before `from_gamepad`, exactly as `linux/dualsense.rs` / `linux/dualshock4.rs`. Default policy stays Drop (don't fire buttons the user didn't ask for); set the env to map the grips onto stick-clicks or shoulders. `steam_remap` was gated `target_os = "linux"`; widened to `any(linux, windows)`. It's pure (only punktfunk_core + std::env); its Linux-only Deck motion rescale is `pub` so it compiles clean on Windows with no dead-code warning. Verified: Linux .21 (clippy -D warnings clean, inject tests 32 pass / 0 fail — the gate widening is a no-op there); Windows .173 (clean-recheck of punktfunk-host, cargo clippy --all-targets -D warnings EXITCODE 0, steam_remap + both managers compiling on Windows for the first time). On-glass with a real DualSense/DS4 + PUNKTFUNK_STEAM_REMAP still owed. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/inject.rs | 4 +++- .../src/inject/windows/dualsense_windows.rs | 11 ++++++++++- .../src/inject/windows/dualshock4_windows.rs | 11 ++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 9fc1696a..0bf07aa6 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -527,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, diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index cc6ce9d9..9f1b3659 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -389,6 +389,9 @@ pub struct DualSenseWindowsManager { /// 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 { @@ -405,6 +408,7 @@ impl DualSenseWindowsManager { last_rumble: vec![(0, 0); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], gate: PadGate::new(), + remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -433,8 +437,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, diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index fd91f308..29ba5d1a 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -153,6 +153,9 @@ pub struct DualShock4WindowsManager { /// 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 { @@ -170,6 +173,7 @@ impl DualShock4WindowsManager { last_led: vec![None; MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], gate: PadGate::new(), + remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -199,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, From 17457cf4ba66abf7417b5438e6499a4f02d0a8e5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 14:08:00 +0200 Subject: [PATCH 09/19] refactor(gamestream/host): source gamepad BTN_* from punktfunk_core + pin the wire bits (G13/G15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gamestream/gamepad.rs` hand-declared its own copy of the GameStream buttonFlags/buttonFlags2 layout, which had drifted from the single source of truth in `punktfunk_core::input::gamepad`: the click bits were named `BTN_LS_CLK`/`BTN_RS_CLK` (vs core's `…_CLICK`). The two layouts are bit-identical — GameStream/Limelight and the punktfunk/1 native wire are one contract — so define the gamestream names as `pub const` aliases of the core constants. Values now come solely from core (can't drift); kept as `pub const` (not a `pub use` re-export) because on Windows the only consumer — the Linux uinput button map — is cfg'd out, where an unused re-export lints as an error but an unused pub const does not. Rename the two injector call-sites (`inject/linux/gamepad.rs`) to the canonical `BTN_LS_CLICK`/`BTN_RS_CLICK`. G15 host half: replace the 3-bit gamestream-vs-core spot-check with an exhaustive golden-value test (`gamepad_wire_bits_are_pinned`) that freezes every button bit + axis id to its exact wire value, so renumbering a bit in core — which would silently break every shipped client — fails a test first. The host counterpart to the client-side C-ABI cross-checks. Verified on Linux .21: clippy -D warnings clean, pin test + gamepad suite green. (Windows verified together with the rest of Phase 3.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/gamestream/gamepad.rs | 55 +++++++++++-------- .../src/inject/linux/gamepad.rs | 4 +- crates/punktfunk-host/src/punktfunk1.rs | 51 +++++++++++++++-- 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/gamepad.rs b/crates/punktfunk-host/src/gamestream/gamepad.rs index 4479479b..535c2c3e 100644 --- a/crates/punktfunk-host/src/gamestream/gamepad.rs +++ b/crates/punktfunk-host/src/gamestream/gamepad.rs @@ -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`]). diff --git a/crates/punktfunk-host/src/inject/linux/gamepad.rs b/crates/punktfunk-host/src/inject/linux/gamepad.rs index 491cd1a9..2e85ce93 100644 --- a/crates/punktfunk-host/src/inject/linux/gamepad.rs +++ b/crates/punktfunk-host/src/inject/linux/gamepad.rs @@ -89,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), diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 04a8e48b..38147ed2 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -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. From d611645ffc7db091cac96afc288f306785c46ca4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 14:08:00 +0200 Subject: [PATCH 10/19] refactor(inject/host/windows): hoist the shared SwCreateCtx into gamepad_raii (G14) The `SwDeviceCreate` completion-callback context (`SwCreateCtx`, the `sw_create_cb` extern callback, and the `instance_id()` accessor) was copy-pasted byte-for-byte in the XUSB (`gamepad_windows.rs`) and DualSense/DS4 (`dualsense_windows.rs`) backends. Hoist the one copy into `gamepad_raii.rs` as `pub(super)`; both `create_swdevice` bodies now build the shared type and pass the shared callback. Prunes the now-orphaned HRESULT/SetEvent/HANDLE imports from the two siblings. Pure move + dedup, no behavior change. Windows-verified with the rest of Phase 3 (clippy --all-targets -D warnings). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/inject/windows/dualsense_windows.rs | 52 ++----------------- .../src/inject/windows/gamepad_raii.rs | 52 ++++++++++++++++++- .../src/inject/windows/gamepad_windows.rs | 51 ++---------------- 3 files changed, 58 insertions(+), 97 deletions(-) diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 9f1b3659..1b960c2b 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -21,19 +21,19 @@ use super::dualsense_proto::{ parse_ds_output, serialize_state, DsFeedback, DsState, 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::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 @@ -72,50 +72,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 { - 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. diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs index 5effe302..d41f76e1 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs @@ -22,11 +22,12 @@ 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, @@ -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 @@ -403,6 +404,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 { + 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); diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index 5d38c747..10cc8638 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -12,18 +12,18 @@ //! 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::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 @@ -44,49 +44,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 { - 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_` 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 From 59fc820226fc7d2f8a823ac651fc6c11b1887de3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 14:14:28 +0200 Subject: [PATCH 11/19] perf(inject/host): dedup the DualSense HID-output feedback plane (G17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, but forwarded every rich `HidOutput` event verbatim — flooding the 0xCD feedback plane to the client during continuous rumble. Add a shared `HidoutDedup` (dualsense_proto, used by both the Linux UHID and Windows UMDF managers) that forwards Led/PlayerLeds/Trigger only on a value change (per side for the two triggers) and always forwards one-shot TrackpadHaptic pulses — mirroring the rumble dedup two lines above and the DS4 backend's lightbar dedup. Reset per pad on create/unplug. Verified on Linux .21 (clippy -D warnings clean, new HidoutDedup unit test + full suite green); Windows .173 with the rest of Phase 3. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/inject/linux/dualsense.rs | 14 ++- .../src/inject/proto/dualsense_proto.rs | 109 ++++++++++++++++++ .../src/inject/windows/dualsense_windows.rs | 16 ++- 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index 1b9a94bc..16c05761 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -13,7 +13,7 @@ //! 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, }; @@ -178,6 +178,9 @@ pub struct DualSenseManager { state: Vec, /// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it. last_rumble: Vec<(u16, u16)>, + /// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an + /// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback. + hidout_dedup: Vec, /// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which /// re-emits the current state during input silence so the kernel never sees the device go quiet. last_write: Vec, @@ -201,6 +204,7 @@ 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], gate: PadGate::new(), remap: crate::inject::steam_remap::RemapConfig::from_env(), @@ -226,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 { @@ -314,6 +319,7 @@ 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(); } @@ -346,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); + } } } } diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index 1487ad7c..9fc189d6 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -445,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, + /// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2. + trigger: [Option>; 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 diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 1b960c2b..47e356ed 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -18,8 +18,8 @@ //! 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::{sw_create_cb, PadChannel, SwCreateCtx}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; @@ -341,6 +341,9 @@ pub struct DualSenseWindowsManager { pads: Vec>, state: Vec, last_rumble: Vec<(u16, u16)>, + /// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an + /// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback. + hidout_dedup: Vec, last_write: Vec, /// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of /// permanently disabling every pad for the session. @@ -362,6 +365,7 @@ 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], gate: PadGate::new(), remap: crate::inject::steam_remap::RemapConfig::from_env(), @@ -386,6 +390,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 { @@ -466,6 +471,7 @@ 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(); } @@ -496,7 +502,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); + } } } } From 48933dc405e517d905a6a8a5e7f1f64b328c0d3a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 18:04:30 +0200 Subject: [PATCH 12/19] fix(gamepad/android): batched HAT, rumble-duration floor, bind eviction, held exit chord (G4/G9/G18/G24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four Android gamepad fixes bringing the client to parity with SDL/Apple: G4 — HAT batched history. Android batches joystick ACTION_MOVEs, so a rapid d-pad tap (press+release within one batch) lived only in the event's historical samples; onMotion read just the final getAxisValue and missed it. Feed every historical HAT sample through the transition logic (new `applyHat`) before the current one. Sticks/triggers stay latest-wins. G9 — floor the rumble one-shot duration. A v2 lease can carry ttl_ms==0 with a nonzero amplitude (past the (0,0) stop guard); createOneShot throws on a non-positive duration, and on the VibratorManager path the effect is built outside the vibrate() runCatching, so the throw would kill the whole rumble poll thread. `durationMs.coerceAtLeast(1)`. G18 — evict feedback binds on disconnect. Rumble/light bindings were cached by device id and freed only at session stop, so a controller unplugged mid-session leaked its open LightsSession. Add GamepadFeedback.onDeviceRemoved(deviceId) (closes the session, cancels rumble), invoked from GamepadRouter's slot-close via a new onSlotClosed callback wired in StreamScreen. The bind maps are now guarded by a lock (the poll threads write them; eviction runs on the main thread). G24 — held exit chord + releases. The emergency-exit chord (Select+Start+ L1+R1) quit the stream the instant it completed — an accidental brush killed the session, and the four held buttons were never released host-side. Now completing the chord ARMS a 1.5 s hold timer (matching DISCONNECT_HOLD on SDL/Apple); onExitChord fires only if still held at expiry, after releasing the held buttons + zeroing the axes on the triggering pad(s). onButton no longer returns the exit bool (async now); MainActivity + StreamScreen updated. G25 (Android half): no change — Android's stick/trigger `.toInt()` already truncates, the chosen cross-client convention. G23 (rich-input plane) stays deferred to its own doc. Verified on this Mac: :kit + :app compileDebugKotlin clean; kit lint unchanged at its pre-existing baseline. On-glass on a real phone + pad still owed (per the Android-regressions-only-show-on-hardware history): watch batched d-pad taps, the 1.5 s exit hold, and a mid-session unplug. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/io/unom/punktfunk/MainActivity.kt | 12 +-- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 8 +- .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 23 +++++- .../io/unom/punktfunk/kit/GamepadFeedback.kt | 76 ++++++++++++++----- .../io/unom/punktfunk/kit/GamepadRouter.kt | 74 +++++++++++++++--- 5 files changed, 152 insertions(+), 41 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index a98f48a2..70b8b63a 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -127,12 +127,12 @@ class MainActivity : ComponentActivity() { if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { val bit = Gamepad.buttonBit(event.keyCode) if (bit != 0) { - // The router forwards the bit on this device's own wire pad index, tracks held - // state per pad, and reports when the emergency-exit chord (Select + Start + L1 + - // R1) completed on any one pad (a couch user has no keyboard/Back). - if (gamepadRouter?.onButton(event, bit) == true) { - requestStreamExit?.let { exit -> window.decorView.post { exit() } } - } + // The router forwards the bit on this device's own wire pad index and tracks held + // state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled + // inside the router: holding it for ~1.5 s fires router.onExitChord (wired in + // StreamScreen), so a couch user with no keyboard/Back can still leave — but an + // accidental brush of the four buttons no longer quits instantly. + gamepadRouter?.onButton(event, bit) return true // consumed } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 83444567..3fbcf4a7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -180,13 +180,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { val router = GamepadRouter(context, handle, initialSettings.gamepad) activity?.gamepadRouter = router // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips - // the keep-alive linger), unlike a host-ended / backgrounded drop. + // the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it + // (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream + // the same way the Back gesture does. activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + router.onExitChord = { activity?.requestStreamExit?.invoke() } activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad // index via the router; poll threads stopped + joined before the router is released and the // session closed. val feedback = GamepadFeedback(handle, router).also { it.start() } + // Free a disconnected controller's rumble/lights bindings promptly (else the open lights + // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. + router.onSlotClosed = feedback::onDeviceRemoved onDispose { closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 608016c6..08c0a431 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -219,14 +219,31 @@ object Gamepad { ), ) - // HAT → dpad button transitions (track previous, emit only the deltas). - val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)) + // HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad + // tap (press+release inside one batch window) lives only in the historical samples — the + // final getAxisValue would show the HAT already back at rest and miss the tap entirely. + // Feed every historical HAT sample (oldest→newest) through the same transition logic + // before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins: + // only the final value matters for an analog axis.) + for (h in 0 until event.historySize) { + applyHat( + sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)), + sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)), + ) + } + applyHat( + sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)), + sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)), + ) + } + + /** Emit dpad button deltas for one HAT sample (`hx`/`hy` each −1/0/+1), tracking held state. */ + private fun applyHat(hx: Int, hy: Int) { if (hx != hatX) { if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false) if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true) hatX = hx } - val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)) if (hy != hatY) { if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false) if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index 3d731053..b82daf75 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -64,10 +64,16 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute private var rumbleThread: Thread? = null private var hidoutThread: Thread? = null - // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the - // rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the - // main thread AFTER joining those threads (join establishes the happens-before), so plain maps are - // race-free. A null value caches "this controller has no vibrator / no controllable lights". + // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble + // thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes + // from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining + // the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain + // HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the + // null value that caches "this controller has no vibrator / no controllable lights"). The lock + // guards only the map ops — rendering runs on the returned reference outside it; a stale reference + // is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd + // no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed. + private val bindsLock = Any() private val rumbleBinds = HashMap() private val lightBinds = HashMap() @@ -122,13 +128,35 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute rumbleThread = null hidoutThread = null // Threads are dead — drop any held rumble and close every lights session. - for (b in rumbleBinds.values) b?.let { - runCatching { it.vm?.cancel() } - runCatching { it.legacy?.cancel() } + synchronized(bindsLock) { + for (b in rumbleBinds.values) b?.let { + runCatching { it.vm?.cancel() } + runCatching { it.legacy?.cancel() } + } + for (b in lightBinds.values) b?.let { runCatching { it.session.close() } } + rumbleBinds.clear() + lightBinds.clear() + } + } + + /** + * Evict and release the bindings for a controller that just disconnected — invoked from + * [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its + * `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session + * immediately instead of leaking it until [stop]. A no-op for a device with no cached binding. + * The next feedback for that pad index rebinds against whatever controller now holds it. + */ + // Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists + // under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers. + @Suppress("MissingPermission", "NewApi") + fun onDeviceRemoved(deviceId: Int) { + synchronized(bindsLock) { + rumbleBinds.remove(deviceId)?.let { + runCatching { it.vm?.cancel() } + runCatching { it.legacy?.cancel() } + } + lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } } } - for (b in lightBinds.values) b?.let { runCatching { it.session.close() } } - rumbleBinds.clear() - lightBinds.clear() } // ---- Rumble ---- @@ -136,10 +164,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute /** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */ private fun rumbleBindFor(pad: Int): RumbleBind? { val dev = router?.deviceForPad(pad) ?: return null - if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id] - val bind = bindRumble(dev) - rumbleBinds[dev.id] = bind - return bind + synchronized(bindsLock) { + if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id] + val bind = bindRumble(dev) + rumbleBinds[dev.id] = bind + return bind + } } private fun bindRumble(dev: InputDevice): RumbleBind? { @@ -217,9 +247,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute } // One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it - // self-terminates on a lost stop; cancel on zero. + // self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot` + // throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0 + // (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0) + // stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching, + // so an uncaught throw here would kill the whole rumble poll thread. private fun oneShot(amp: Int, durationMs: Long): VibrationEffect = - VibrationEffect.createOneShot(durationMs, amp) + VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp) // ---- HID output ---- @@ -268,10 +302,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute private fun lightBindFor(pad: Int): LightBind? { if (Build.VERSION.SDK_INT < 33) return null val dev = router?.deviceForPad(pad) ?: return null - if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id] - val bind = bindLights(dev) - lightBinds[dev.id] = bind - return bind + synchronized(bindsLock) { + if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id] + val bind = bindLights(dev) + lightBinds[dev.id] = bind + return bind + } } private fun bindLights(dev: InputDevice): LightBind? { diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 2e3dee95..ce80b1fe 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -44,6 +44,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */ private val slots = ConcurrentHashMap() + /** + * Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown. + * `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / + * lights bindings are released promptly instead of leaking until the feedback threads stop. + */ + var onSlotClosed: ((deviceId: Int) -> Unit)? = null + + /** + * Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller + * leaves the stream. `StreamScreen` wires this to the deliberate-quit exit. + */ + var onExitChord: (() -> Unit)? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + /** The pending exit-chord hold timer, or null when the chord isn't currently armed. */ + private var pendingExit: Runnable? = null + private val inputManager = context.getSystemService(InputManager::class.java) private val listener = object : InputManager.InputDeviceListener { override fun onInputDeviceAdded(deviceId: Int) { @@ -55,7 +72,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett } init { - inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper())) + inputManager?.registerInputDeviceListener(listener, mainHandler) // Open a slot for every controller already connected when the session starts — the pads that // will never fire onInputDeviceAdded during this session; their Arrival lands before any input. for (id in InputDevice.getDeviceIds()) { @@ -66,28 +83,55 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** * One gamepad button transition for the device that produced [event] (already resolved to BTN_* * bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the - * slot's pad index, tracks held state, and returns true when this press completed the emergency - * stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream - * (mirrors the Linux client's escape chord: any one controller can leave). + * slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select + + * Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly; + * [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is + * ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave. */ - fun onButton(event: KeyEvent, bit: Int): Boolean { - val slot = slotFor(event.device) ?: return false + fun onButton(event: KeyEvent, bit: Int) { + val slot = slotFor(event.device) ?: return when (event.action) { KeyEvent.ACTION_DOWN -> { // repeatCount guard: don't re-send a held button as auto-repeat. if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) slot.held = slot.held or bit - if (slot.held and EXIT_CHORD == EXIT_CHORD) { - slot.held = 0 - return true - } + // Full chord now held on this pad → start the hold countdown (idempotent while held). + if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit() } KeyEvent.ACTION_UP -> { NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) slot.held = slot.held and bit.inv() + // A chord button lifted before the hold elapsed → cancel, unless another pad still + // holds the full chord. + if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) { + disarmExit() + } } } - return false + } + + /** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */ + private fun armExit() { + if (pendingExit != null) return // already counting down + val r = Runnable { + pendingExit = null + // Fire only if the chord survived the full hold on some pad. + val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD } + if (held.isNotEmpty()) { + // Release the held buttons + zero the axes on every triggering pad so nothing sticks + // host-side once we leave, then signal the deliberate exit. + for (s in held) releaseHeld(s) + onExitChord?.invoke() + } + } + pendingExit = r + mainHandler.postDelayed(r, EXIT_HOLD_MS) + } + + /** Cancel a pending exit-chord hold timer. */ + private fun disarmExit() { + pendingExit?.let { mainHandler.removeCallbacks(it) } + pendingExit = null } /** @@ -124,6 +168,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett */ fun release() { inputManager?.unregisterInputDeviceListener(listener) + disarmExit() // drop any pending exit-chord timer so it can't fire after teardown // Snapshot the ids first — closeSlot mutates the map. for (id in slots.keys.toList()) closeSlot(id) } @@ -173,6 +218,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett val slot = slots.remove(deviceId) ?: return releaseHeld(slot) NativeBridge.nativeSendGamepadRemove(handle, slot.index) + // If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer. + if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit() + // Release this controller's feedback bindings (close its lights session / cancel rumble). + onSlotClosed?.invoke(deviceId) } /** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */ @@ -200,5 +249,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */ const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB + + /** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */ + const val EXIT_HOLD_MS = 1500L } } From 26cac9ce205739792c7877c0795a098ddf1c9fb7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 22:05:13 +0200 Subject: [PATCH 13/19] fix(gamepad): truncate stick/trigger axes uniformly across clients (G25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple's GamepadCapture rounded axis values (`(v * scale).rounded()`) while SDL-core and Android truncate, so a half-pressed control emitted 128 on Apple vs 127 elsewhere. Drop `.rounded()` so `Int32(Float)` truncates toward zero on Apple too; rails are unchanged (full deflection stays 255 / ±32767). Also clamp SDL-core's LeftX/RightX to a symmetric -32767 like the Y axes and the other clients already do, instead of letting the raw i16 reach -32768. Verified: Apple `swift build` + full PunktfunkKit suite (124 pass); SDL half on Windows .173 `cargo clippy -p pf-client-core -- -D warnings` (green). Co-Authored-By: Claude Opus 4.8 --- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 12 ++++++------ crates/pf-client-core/src/gamepad.rs | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index fa251b71..b5224180 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -271,12 +271,12 @@ public final class GamepadCapture { slot.buttons = newButtons } let newAxes: [Int32] = [ - Int32((g.leftThumbstick.xAxis.value * 32767).rounded()), - Int32((g.leftThumbstick.yAxis.value * 32767).rounded()), - Int32((g.rightThumbstick.xAxis.value * 32767).rounded()), - Int32((g.rightThumbstick.yAxis.value * 32767).rounded()), - Int32((g.leftTrigger.value * 255).rounded()), - Int32((g.rightTrigger.value * 255).rounded()), + Int32(g.leftThumbstick.xAxis.value * 32767), + Int32(g.leftThumbstick.yAxis.value * 32767), + Int32(g.rightThumbstick.xAxis.value * 32767), + Int32(g.rightThumbstick.yAxis.value * 32767), + Int32(g.leftTrigger.value * 255), + Int32(g.rightTrigger.value * 255), ] for (i, v) in newAxes.enumerated() where v != slot.axes[i] { connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad)) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 9ed548dd..3922c7a3 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -558,9 +558,9 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option { fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { use sdl3::gamepad::Axis; match axis { - Axis::LeftX => (wire::AXIS_LS_X, v as i32), + Axis::LeftX => (wire::AXIS_LS_X, (v as i32).max(-32767)), Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)), - Axis::RightX => (wire::AXIS_RS_X, v as i32), + Axis::RightX => (wire::AXIS_RS_X, (v as i32).max(-32767)), Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)), Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7), Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7), From aedffc69dd03d2e8614f4e5c069146c2803c5369 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 22:05:13 +0200 Subject: [PATCH 14/19] fix(gamepad/client): bound legacy Steam Deck rumble on a lost stop-frame (G16) Against a legacy (no-TTL) host, a held Deck rumble droned forever if the stop datagram was lost: the 40 ms keep-alive re-kicked the actuator indefinitely and only the v2 lease `deadline` ever bounded it. Add a per-slot `updated_at` clock bumped ONLY by real host datagrams (never by the keep-alive re-kick, unlike `last_at`), and in the legacy branch (`ttl_ms == 0`) issue a single (0, 0) once it is stale past LEGACY_RUMBLE_CEILING_MS (1000 ms = 2x the host's flat 500 ms legacy refresh). A genuinely-held legacy rumble refreshes every 500 ms and never trips; the v2 `deadline` path is untouched and stays authoritative. Verified: Windows .173 `cargo clippy -p pf-client-core -- -D warnings` (green). On-glass owed: real Deck with an induced legacy stop-frame drop. Co-Authored-By: Claude Opus 4.8 --- crates/pf-client-core/src/gamepad.rs | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 3922c7a3..cd72c225 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -71,6 +71,15 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); /// left untouched. const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40; +/// Ceiling on a *legacy* (no-TTL) host's Steam Deck rumble: silence the actuator once a real host +/// update has been absent this long. A legacy host re-sends the held level as a flat 500 ms refresh, +/// so a genuinely-held rumble refreshes the per-slot update clock (`RumbleState::updated_at`) every +/// 500 ms and never approaches this — only a lost *stop* datagram (the host went quiet entirely) +/// lets the 40 ms keep-alive drone on. 2× the 500 ms refresh bounds that lost stop to ~1 s, +/// mirroring the Windows host's `RUMBLE_IDLE_TIMEOUT` residual cutoff. The v2 path is bounded by its +/// lease `deadline` instead and never trips this (see [`Worker::render_feedback`]). +const LEGACY_RUMBLE_CEILING_MS: u64 = 1_000; + /// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple /// `GamepadMenuInput` parity; menus want deliberate flicks, not drift). const MENU_DEADZONE: u16 = 16384; @@ -617,6 +626,12 @@ struct RumbleState { /// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`]. last: (u16, u16), last_at: Option, + /// When the last *real* host rumble datagram landed on this slot — set only in the feedback + /// drain, never bumped by the Deck keep-alive re-kick (unlike `last_at`, which the keep-alive + /// refreshes every ~40 ms). A legacy host carries no lease, so this per-slot clock is what + /// bounds a lost stop-frame: once it is stale past `LEGACY_RUMBLE_CEILING_MS` the keep-alive + /// stops and issues one (0, 0). See [`Worker::render_feedback`]. + updated_at: Option, /// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a /// Deck keep-alive re-issue (see [`Worker::issue_rumble`]). jitter: bool, @@ -1492,6 +1507,10 @@ impl Worker { } _ => None, }; + // Mark this as a real host update. Unlike `last_at` (which the Deck keep-alive + // re-kick refreshes every ~40 ms), this clock advances only here, so a legacy + // lost-stop can be bounded by `LEGACY_RUMBLE_CEILING_MS` in the keep-alive below. + slot.rumble.updated_at = Some(Instant::now()); Self::issue_rumble(slot, low, high, deck); } } @@ -1511,6 +1530,17 @@ impl Worker { slot.rumble.deadline = None; slot.rumble.ttl_ms = 0; Self::issue_rumble(slot, 0, 0, true); + } else if slot.rumble.ttl_ms == 0 + && slot + .rumble + .updated_at + .is_some_and(|t| t.elapsed() >= Duration::from_millis(LEGACY_RUMBLE_CEILING_MS)) + { + // Legacy host (no v2 lease): a held rumble refreshes `updated_at` every ~500 ms, so + // this only trips on a lost stop-frame the host never followed up — silence the + // actuator once instead of letting the 40 ms keep-alive drone forever. `issue_rumble` + // sets `last` to (0, 0), so the top-of-loop guard skips this slot on later ticks. + Self::issue_rumble(slot, 0, 0, true); } else if slot .rumble .last_at From 60af4de3ba990971efa7577634b13b100cad8026 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 22:05:13 +0200 Subject: [PATCH 15/19] docs(gamepad/android): document the two-motor vibratorIds ordering assumption (G20) The two-motor split assumes ids[0] = light/right and ids[1] = heavy/left, an ordering `VibratorManager.getVibratorIds()` does not guarantee. Record the assumption and its tactile-only failure mode (a heavy-first pad inverts the feel but nothing silences or crashes) at the call site. No behavior change: a per-pad fix needs on-glass verification, and a blanket count-based fallback is unsafe (extra ids may be DualSense trigger actuators that must stay silent). Co-Authored-By: Claude Opus 4.8 --- .../main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index b82daf75..76583034 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -214,7 +214,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute } val combo = CombinedVibration.startParallel() if (bind.amplitudeControlled && bind.ids.size >= 2) { - // ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention). + // Two-motor split — ASSUMPTION: ids[0] = light/right, ids[1] = heavy/left + // (XInput/Moonlight convention). Android does not guarantee the order of + // VibratorManager.getVibratorIds(), so a pad that enumerates heavy-first would + // invert the feel: the stronger amplitude drives the physically-lighter motor. + // Failure mode is tactile only — both motors still fire, nothing silences or + // crashes — so this stays the default pending per-pad on-glass verification (G20). + // ids beyond the first two (rare) are left alone here. if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs)) if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs)) } else { From 31bc863084b5e9a6b5cdf5d3f54ae1f5387928b0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 22:05:13 +0200 Subject: [PATCH 16/19] fix(inject/host/windows): free the per-section security descriptor (G19) `sddl_sa` leaked the `LocalAlloc`'d PSECURITY_DESCRIPTOR that ConvertStringSecurityDescriptorToSecurityDescriptorW returns, once per DATA section and once per bootstrap mailbox create (amplifiable under pad-flap via create_named's squat-retry loop). Wrap it in a `SecAttr` RAII owner that `LocalFree`s on drop; it outlives every CreateFileMappingW (the section copies the security info at create time), and create_named builds one and reuses it across retries instead of re-allocating. Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green) -- confirms the LocalFree/HLOCAL signature at the pinned windows-rs rev. Co-Authored-By: Claude Opus 4.8 --- .../src/inject/windows/gamepad_raii.rs | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs index d41f76e1..ef6280e8 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs @@ -34,8 +34,8 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{ }; 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, @@ -66,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 { +/// 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 { 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, @@ -79,10 +105,13 @@ fn sddl_sa(sddl: PCWSTR) -> Result { None, )?; } - Ok(SECURITY_ATTRIBUTES { - nLength: core::mem::size_of::() as u32, - lpSecurityDescriptor: psd.0, - bInheritHandle: false.into(), + Ok(SecAttr { + sa: SECURITY_ATTRIBUTES { + nLength: core::mem::size_of::() as u32, + lpSecurityDescriptor: psd.0, + bInheritHandle: false.into(), + }, + psd, }) } @@ -94,7 +123,9 @@ impl Shm { /// validated on-glass — `design/idd-push-security.md`). pub(super) fn create_unnamed(size: usize) -> Result { 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 @@ -107,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 { + // 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 { @@ -114,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. @@ -132,7 +165,8 @@ impl Shm { fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result { // 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, From 2f214532d9604e3fcd44a8e4a99e141fef1c9760 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 22:05:13 +0200 Subject: [PATCH 17/19] fix(inject/host/windows): eager-create the XUSB pad on Arrival + refresh last_active (G10) The XUSB manager's `handle` dropped `GamepadEvent::Arrival` via a `let else`, so the GameStream path never created the pad until the first `State` and missed the first XInput poll. Match on the event and `ensure` eagerly on Arrival, mirroring the DualSense backend. Also refresh `last_active` on create and unplug so a freshly-created pad's residual-rumble idle clock starts fresh rather than inheriting a stale Instant (which could force off a legitimate rumble at once). Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green). Co-Authored-By: Claude Opus 4.8 --- .../src/inject/windows/gamepad_windows.rs | 63 ++++++++++--------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index 10cc8638..5537676a 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -282,6 +282,7 @@ 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) => { @@ -292,35 +293,41 @@ impl GamepadManager { } 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, - ); } } From 1af11cc64da76c414c2a28da93486dd0b4adba98 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 22:05:13 +0200 Subject: [PATCH 18/19] fix(inject/host/windows): order the pad change-detect fields with Release/Acquire (G21) The XUSB `packet` publish and the XUSB `rumble_seq` / DualSense `out_seq` reads used plain unaligned accesses with no fence, so a driver could observe a bumped change-detect field over a torn body on a weakly-ordered core (ARM64). Publish `packet` via a Release AtomicU32 store behind a Release fence, and Acquire-load the seq fields, mirroring the gamepad_raii PadChannel seq-fence precedent. The DualSense input report embeds its seq mid-report with no driver-gated change-detect field, so it gets a Release fence after the copy and a documented residual (a per-frame input generation is deferred). No-op on x86-TSO. Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green). Co-Authored-By: Claude Opus 4.8 --- .../src/inject/windows/dualsense_windows.rs | 25 ++++++++++++++++--- .../src/inject/windows/gamepad_windows.rs | 21 +++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 47e356ed..29873c30 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -27,6 +27,7 @@ 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, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ @@ -291,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); }; } @@ -313,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; diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index 5537676a..68e929b8 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -17,6 +17,7 @@ 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, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ @@ -193,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; @@ -202,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); } } @@ -216,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; } From 764b5d938bfc67d7569f9ab513bfc7c91749ec80 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 22:24:03 +0200 Subject: [PATCH 19/19] fix(gamepad): resolve the menu diagonal tie-break horizontally on all clients (G25) The gamepad-UI navigation resolvers disagreed on which way a perfect 45-degree stick push (|x| == |y|) resolves: the SDL core picked horizontal (`ax >= ay`) while Apple (`abs(x) > abs(y)`) and Android (`abs(Y) >= abs(X)`) picked vertical. Align Apple (`>` -> `>=`) and Android (`>=` -> `>`) to the SDL core so an exact diagonal moves focus the same way on every client (horizontal wins). This is client-local menu navigation only and never reaches the wire. Completes the last deferred G25 sub-part. Verified: Apple `swift build` + full suite (124 pass); Android `:app:compileDebugKotlin`. Co-Authored-By: Claude Opus 4.8 --- .../app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt | 5 ++++- .../Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt index a9e8994b..dd98af48 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt @@ -241,7 +241,10 @@ private fun resolveDir(s: NavInputState): NavDir? { if (s.hatY >= 0.5f) return NavDir.DOWN if (s.hatX <= -0.5f) return NavDir.LEFT if (s.hatX >= 0.5f) return NavDir.RIGHT - return if (abs(s.stickY) >= abs(s.stickX)) { + // Horizontal wins an exact |x| == |y| diagonal tie (Y must be strictly greater to take the + // vertical branch), matching the SDL core and Apple nav so a perfect 45° push resolves the + // same on every client. + return if (abs(s.stickY) > abs(s.stickX)) { when { s.stickY <= -STICK_HIGH -> NavDir.UP s.stickY >= STICK_HIGH -> NavDir.DOWN diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift index fb6d21e6..d162ebeb 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift @@ -140,7 +140,9 @@ public final class GamepadMenuInput { let stick = gamepad.leftThumbstick let x = stick.xAxis.value let y = stick.yAxis.value - if abs(x) > abs(y), abs(x) > deadzone { + // Horizontal wins an exact |x| == |y| diagonal tie (>=), matching the SDL core and Android + // nav so a perfect 45° push resolves to the same direction on every client. + if abs(x) >= abs(y), abs(x) > deadzone { return x > 0 ? .right : .left } else if abs(y) > deadzone { return y > 0 ? .up : .down