fix(feedback): the pad stops keeping a game's trigger effect after the stream ends #44

Merged
enricobuehler merged 2 commits from worktree-haptics-m9-richfb into main 2026-08-04 21:07:37 +00:00
Owner

Workstream M9 of the haptics sweep — the rich-feedback lifecycle. Closes B17, R1.

Both faults leave a controller physically wrong with nothing to put it right.

B17 — nothing reset the pad on teardown

Rumble stops on its own the moment nothing renews it. The rich planes do not: an adaptive-trigger effect and a lightbar colour are latched in the controller's firmware and outlive the stream, the app, and being unplugged.

Ending a session while a game held a weapon's trigger resistance left the physical trigger stiff on the desktop afterwards, and the lightbar showing whatever the game last set — until another game happened to set one.

Apple's client already reset on teardown (playerIndex = .indexUnset, setModeOff() on both triggers). The desktop and Android halves now do the same: triggers to mode 0x00, lightbar dark, player indicator cleared. Android writes them EP0-direct like its rumble stop, because the reader thread is stopping and the interrupt-OUT queue would never drain.

R1 — one lost datagram stranded the pad on the previous value

The 0xCD plane is deduped and rides unreliable datagrams, which is a bad pairing. A change is forwarded exactly once, so when that datagram is dropped nothing re-derives it — the game keeps sending the same value and the dedup swallows every copy. The pad then holds the last weapon's trigger effect, or the last lightbar colour, for as long as the game keeps that setting. For a trigger effect that can be the rest of a level.

The dedup already remembers the current state, so it can repair itself: HidoutDedup::renewals re-emits what it has latched, once a second.

Deliberate choices:

  • Slow (1 s). This is a repair mechanism, not a transport. Steady-state cost is at most four small datagrams per second per pad, against a rumble plane that already resends at ~120 ms.
  • Idempotent by construction. A client that did receive the original simply re-applies it.
  • A forward re-stamps the clock, so a plane the game is actively driving never pays for a renewal it does not need.
  • One-shots excluded. Replaying a TrackpadHaptic would be a new pulse, not a repair. HidRaw is excluded too — the device's own refresh cadence already re-sends it verbatim.

Verification

Rust on amd64 Linux (pf-lxcheck2), after cargo clean -p pf-inject -p pf-client-core:

  • cargo clippy --all-targets --locked -p pf-inject -p pf-client-core -- -D warningsexit 0
  • cargo testpf-client-core 83 passed / 0 failed (82 baseline + 1), pf-inject 89 passed / 0 failed (86 baseline + 3)
  • cargo fmt --all --check — clean

Android: :kit:compileDebugKotlin + :kit:testDebugUnitTestexit 0.

Non-vacuity proven for R1: making renewals a no-op fails 2 of its 3 tests. The third asserts renewals are empty with nothing latched and never replay a pulse — it passes under both, correctly, because it pins an invariant rather than the behaviour change.

Not verified

B17's timing has no automated cover — it acts on a live SDL handle / USB device that cannot be constructed in a test. What is pinned is the payload it sends (reset_packet_tests): a wrong enable flag or a non-zero mode byte would silently leave the effect latched, which is the exact bug. Confirming the trigger actually releases needs a physical DualSense.

R1's repair is likewise only observable with real packet loss.

Merge-order note

B17's Android hunk sits in the same DsCapture.stop() block that M5 (#35) edits, so whichever lands second needs a one-line conflict resolved. I kept the change to a single call site (resetRichFeedback(m)) to keep that trivial. The desktop hunk is adjacent to M6 (#38)'s close_all_slots work but in a different function, so those merge cleanly.

Workstream **M9** of the haptics sweep — the rich-feedback lifecycle. Closes **B17, R1**. Both faults leave a controller physically wrong with nothing to put it right. ### B17 — nothing reset the pad on teardown Rumble stops on its own the moment nothing renews it. The rich planes do not: an adaptive-trigger effect and a lightbar colour are **latched in the controller's firmware** and outlive the stream, the app, and being unplugged. Ending a session while a game held a weapon's trigger resistance left the physical trigger stiff on the desktop afterwards, and the lightbar showing whatever the game last set — until another game happened to set one. Apple's client already reset on teardown (`playerIndex = .indexUnset`, `setModeOff()` on both triggers). The desktop and Android halves now do the same: triggers to mode `0x00`, lightbar dark, player indicator cleared. Android writes them EP0-direct like its rumble stop, because the reader thread is stopping and the interrupt-OUT queue would never drain. ### R1 — one lost datagram stranded the pad on the previous value The 0xCD plane is deduped **and** rides unreliable datagrams, which is a bad pairing. A change is forwarded exactly once, so when that datagram is dropped nothing re-derives it — the game keeps sending the same value and the dedup swallows every copy. The pad then holds the last weapon's trigger effect, or the last lightbar colour, for as long as the game keeps that setting. For a trigger effect that can be the rest of a level. The dedup already remembers the current state, so it can repair itself: `HidoutDedup::renewals` re-emits what it has latched, once a second. Deliberate choices: - **Slow (1 s).** This is a repair mechanism, not a transport. Steady-state cost is at most four small datagrams per second per pad, against a rumble plane that already resends at ~120 ms. - **Idempotent by construction.** A client that *did* receive the original simply re-applies it. - **A forward re-stamps the clock**, so a plane the game is actively driving never pays for a renewal it does not need. - **One-shots excluded.** Replaying a `TrackpadHaptic` would be a *new* pulse, not a repair. `HidRaw` is excluded too — the device's own refresh cadence already re-sends it verbatim. --- ### Verification Rust on amd64 Linux (`pf-lxcheck2`), after `cargo clean -p pf-inject -p pf-client-core`: - `cargo clippy --all-targets --locked -p pf-inject -p pf-client-core -- -D warnings` — **exit 0** - `cargo test` — `pf-client-core` **83 passed / 0 failed** (82 baseline + 1), `pf-inject` **89 passed / 0 failed** (86 baseline + 3) - `cargo fmt --all --check` — clean Android: `:kit:compileDebugKotlin` + `:kit:testDebugUnitTest` — **exit 0**. **Non-vacuity proven** for R1: making `renewals` a no-op fails 2 of its 3 tests. The third asserts renewals are *empty* with nothing latched and never replay a pulse — it passes under both, correctly, because it pins an invariant rather than the behaviour change. ### Not verified B17's *timing* has no automated cover — it acts on a live SDL handle / USB device that cannot be constructed in a test. What is pinned is the payload it sends (`reset_packet_tests`): a wrong enable flag or a non-zero mode byte would silently leave the effect latched, which is the exact bug. Confirming the trigger actually releases needs a physical DualSense. R1's repair is likewise only observable with real packet loss. ### Merge-order note B17's Android hunk sits in the same `DsCapture.stop()` block that **M5 (#35)** edits, so whichever lands second needs a one-line conflict resolved. I kept the change to a single call site (`resetRichFeedback(m)`) to keep that trivial. The desktop hunk is adjacent to **M6 (#38)**'s `close_all_slots` work but in a different function, so those merge cleanly.
enricobuehler added 1 commit 2026-08-04 18:37:57 +00:00
fix(feedback): the pad stops keeping a game's trigger effect after the stream ends
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m34s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 3m26s
android / android (pull_request) Successful in 4m9s
ci / rust (pull_request) Successful in 7m51s
a9a514dea0
Two faults in the rich-feedback plane — the lightbar, player LEDs and adaptive
triggers — both of which leave a controller physically wrong with nothing to
put it right.

Nothing reset the pad on teardown. Rumble stops on its own the moment nothing
renews it, but the rich planes are LATCHED in the controller's firmware: they
outlive the stream, the app, and being unplugged. Ending a session while a game
held a weapon's trigger resistance left the physical trigger stiff on the
desktop afterwards, and its lightbar showing whatever the game last set, until
another game happened to set one. The Apple client already reset on teardown;
the desktop and Android halves now do too — triggers to mode 0x00, lightbar
dark, player indicator cleared. Android writes them EP0-direct like its rumble
stop, because the reader thread is stopping and the queue would never drain.

A single lost datagram stranded the pad on the previous value. The plane is
deduped AND rides unreliable datagrams, which is a bad pairing: a change is
forwarded exactly once, so when that datagram is dropped nothing re-derives it
— the game keeps sending the same value and the dedup swallows every copy. The
pad then holds the last weapon's trigger effect, or the last lightbar colour,
for as long as the game keeps that setting, which can be the rest of a level.
The dedup already remembers the current state, so it can repair itself: it now
re-emits what it has latched once a second. Slow on purpose — this is a repair
mechanism, not a transport, and every value is idempotent, so a client that did
receive the original simply re-applies it. A forward re-stamps the clock, so a
plane the game is actively driving never pays for a renewal it does not need.

One-shot pulses are deliberately excluded from that renewal: replaying a
trackpad haptic would be a new pulse, not a repair. Raw passthrough reports are
excluded too — the device's own refresh cadence already re-sends them verbatim.
enricobuehler added 1 commit 2026-08-04 21:07:26 +00:00
Merge remote-tracking branch 'origin/main' into worktree-haptics-m9-richfb
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 2m10s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m45s
android / android (pull_request) Successful in 4m54s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m17s
ci / rust (pull_request) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m0s
fcf4076eb7
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt
#	crates/pf-client-core/src/gamepad.rs
enricobuehler marked the pull request as ready for review 2026-08-04 21:07:28 +00:00
enricobuehler merged commit 5d06ef26ac into main 2026-08-04 21:07:37 +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#44