fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit #38

Merged
enricobuehler merged 1 commits from worktree-haptics-m6-presenter into main 2026-08-04 21:02:39 +00:00
Owner

Workstream M6 of the haptics sweep — the desktop session's gamepad path. Closes B13, R8, R16.

B13 — the Deck lost its trackpad at every session start

SDL's Valve HIDAPI driver clears the pad's digital mappings during enumeration, which is part of bringing the gamepad subsystem up.

So holding the drivers off from inside GamepadService::pumped could never work: receiving a GamepadSubsystem means enumeration has already happened. The hint set there detached a driver that had already done the damage, and lizard mode only returned seconds later when the firmware watchdog restored it.

The presenter now disables them with its other pre-SDL_Init hints. The threaded worker (gamepad.rs::run) always had this right — only the caller-pumped path was wrong, and it could not fix itself from where it sits, hence a separate entry point.

R8 — player LEDs did nothing on any non-DualSense pad

The match arm handled the DualSense raw-effects path and let everything else fall through a bare _, though SDL exposes set_player_index and owns the per-device pattern.

The wire carries a positional bitmask, not an index. The bridge is the popcount: every convention that reaches this wire spells "player N" as N lit LEDs — DualSense 0x04/0x0A/0x15/0x1B/0x1F and Switch/XInput 0x01/0x03/0x07/0x0F alike — so counting works for both where reading a bit position would only ever suit one. No lit LED means no player, not player 0.

The remaining unhandled variants are now named rather than swept up by _, so a new one cannot join them silently.

R16 — a forwarded pad could be left buzzing at session end

detach() only posts Ctl::Detach; the close that flushes the pad, sends the host-side GamepadRemove and explicitly zeroes the motors runs when the pump next drains it. Single mode broke out of the loop immediately after detaching, and Event::Quit never detached at all — so both skipped it entirely.

The teardown now sits where every exit converges rather than on the individual breaks.

The finding as written was incomplete. Several paths leave that loop by ? on a fatal overlay or present error, and GamepadPump had no Drop — so those still left a rumbling pad rumbling. The pump now also silences its slots on Drop. The explicit call stays, because a pad should go quiet before a session join and vkDeviceWaitIdle, not after.

Drop closes the slots directly rather than draining the queue that would have done it. Same physical outcome, and it touches no lock — whereas draining reaches a Mutex::lock().unwrap(), and a panic there during an unwind aborts the process.


Verification

Run on amd64 Linux in pf-lxcheck2 (these crates are cfg(linux/windows); a red cargo check on macOS is pre-existing and unrelated — even wol.rs fails there).

  • cargo clippy --all-targets --locked -p pf-client-core -p pf-presenter -- -D warningsexit 0
  • cargo test -p pf-client-core85 passed / 0 failed (82 baseline + 3 new)
  • cargo fmt --all --check — clean
  • Non-vacuity proven: planting the plausible wrong mapping (trailing_zeros(), the other obvious reading of a bitmask) fails 2 of the 3 new tests. The third passes under both — it pins 0 → None, an invariant common to either reading.

Not verified

No on-glass. B13's payoff is a Steam Deck keeping its trackpad cursor through a session start, and R16's is a pad that was mid-rumble when a session ended — neither is reachable from a unit test. R8's mapping is the part with genuine automated cover.

Workstream **M6** of the haptics sweep — the desktop session's gamepad path. Closes **B13, R8, R16**. ### B13 — the Deck lost its trackpad at every session start SDL's Valve HIDAPI driver clears the pad's digital mappings during *enumeration*, which is part of bringing the gamepad subsystem up. So holding the drivers off from inside `GamepadService::pumped` **could never work**: receiving a `GamepadSubsystem` means enumeration has already happened. The hint set there detached a driver that had already done the damage, and lizard mode only returned seconds later when the firmware watchdog restored it. The presenter now disables them with its other pre-`SDL_Init` hints. The threaded worker (`gamepad.rs::run`) always had this right — only the caller-pumped path was wrong, and it could not fix itself from where it sits, hence a separate entry point. ### R8 — player LEDs did nothing on any non-DualSense pad The match arm handled the DualSense raw-effects path and let everything else fall through a bare `_`, though SDL exposes `set_player_index` and owns the per-device pattern. The wire carries a positional **bitmask**, not an index. The bridge is the popcount: every convention that reaches this wire spells "player N" as N lit LEDs — DualSense `0x04/0x0A/0x15/0x1B/0x1F` and Switch/XInput `0x01/0x03/0x07/0x0F` alike — so counting works for both where reading a bit *position* would only ever suit one. No lit LED means no player, not player 0. The remaining unhandled variants are now named rather than swept up by `_`, so a new one cannot join them silently. ### R16 — a forwarded pad could be left buzzing at session end `detach()` only posts `Ctl::Detach`; the close that flushes the pad, sends the host-side `GamepadRemove` and explicitly zeroes the motors runs when the pump next drains it. Single mode broke out of the loop immediately after detaching, and `Event::Quit` never detached at all — so both skipped it entirely. The teardown now sits where every exit converges rather than on the individual `break`s. **The finding as written was incomplete.** Several paths leave that loop by `?` on a fatal overlay or present error, and `GamepadPump` had no `Drop` — so those still left a rumbling pad rumbling. The pump now also silences its slots on `Drop`. The explicit call stays, because a pad should go quiet *before* a session join and `vkDeviceWaitIdle`, not after. `Drop` closes the slots directly rather than draining the queue that would have done it. Same physical outcome, and it touches no lock — whereas draining reaches a `Mutex::lock().unwrap()`, and a panic there during an unwind aborts the process. --- ### Verification Run on amd64 Linux in `pf-lxcheck2` (these crates are `cfg(linux/windows)`; a red `cargo check` on macOS is pre-existing and unrelated — even `wol.rs` fails there). - `cargo clippy --all-targets --locked -p pf-client-core -p pf-presenter -- -D warnings` — **exit 0** - `cargo test -p pf-client-core` — **85 passed / 0 failed** (82 baseline + 3 new) - `cargo fmt --all --check` — clean - **Non-vacuity proven**: planting the *plausible* wrong mapping (`trailing_zeros()`, the other obvious reading of a bitmask) fails 2 of the 3 new tests. The third passes under both — it pins `0 → None`, an invariant common to either reading. ### Not verified No on-glass. B13's payoff is a Steam Deck keeping its trackpad cursor through a session start, and R16's is a pad that was mid-rumble when a session ended — neither is reachable from a unit test. R8's mapping is the part with genuine automated cover.
enricobuehler added 1 commit 2026-08-04 17:11:18 +00:00
fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 8m52s
ci / rust (pull_request) Successful in 12m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
8abdd74a62
Three faults in the desktop session's gamepad path.

The Steam Deck lost its built-in trackpad-mouse at the start of every session.
SDL's Valve HIDAPI driver clears the pad's digital mappings during
*enumeration*, which is part of bringing the gamepad subsystem up — so holding
the drivers off from inside GamepadService::pumped could never work: receiving
a GamepadSubsystem means the enumeration has already happened. The hint set
there detached a driver that had already done the damage, and lizard mode only
came back seconds later when the firmware watchdog restored it. The presenter
now disables them with its other pre-SDL_Init hints. The threaded worker always
had this right; only the caller-pumped path was wrong, and it could not fix
itself, hence a separate entry point its callers can place correctly.

Player LEDs did nothing at all on any pad that is not a DualSense. The match
arm handled the DualSense raw-effects path and let everything else fall through
a bare `_`, though SDL exposes set_player_index and owns the per-device
pattern. The wire carries a positional bitmask rather than an index, and the
bridge is the popcount: every convention that reaches this wire spells "player
N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the
Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both,
where reading a bit position would only ever suit one. No lit LED means no
player, not player 0. The remaining unhandled variants are now named rather
than swept up by `_`, so a new one cannot join them silently.

A forwarded pad could be left buzzing when the session ended. detach() only
posts Ctl::Detach; the close that flushes the pad, tells the host to remove it
and explicitly zeroes the motors runs when the pump next drains that message.
Single mode broke out of the loop immediately after detaching and Event::Quit
never detached at all, so both skipped it entirely. The teardown now sits where
every exit converges instead of on the individual breaks. That still leaves the
several paths that leave by `?` on a fatal overlay or present error, so the
pump also silences its slots on Drop — the explicit call stays, because a pad
should go quiet before a long teardown rather than after it. Drop closes the
slots directly rather than draining the queue that would have done it: same
physical outcome, and it touches no lock, where draining reaches an unwrap on a
Mutex that would abort the process if it panicked mid-unwind.
enricobuehler marked the pull request as ready for review 2026-08-04 21:02:27 +00:00
enricobuehler merged commit b2e716ad5f into main 2026-08-04 21:02:39 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#38