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:
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 mode0x00, 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::renewalsre-emits what it has latched, once a second.Deliberate choices:
TrackpadHapticwould be a new pulse, not a repair.HidRawis excluded too — the device's own refresh cadence already re-sends it verbatim.Verification
Rust on amd64 Linux (
pf-lxcheck2), aftercargo clean -p pf-inject -p pf-client-core:cargo clippy --all-targets --locked -p pf-inject -p pf-client-core -- -D warnings— exit 0cargo test—pf-client-core83 passed / 0 failed (82 baseline + 1),pf-inject89 passed / 0 failed (86 baseline + 3)cargo fmt --all --check— cleanAndroid:
:kit:compileDebugKotlin+:kit:testDebugUnitTest— exit 0.Non-vacuity proven for R1: making
renewalsa 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)'sclose_all_slotswork but in a different function, so those merge cleanly.