fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn #43

Merged
enricobuehler merged 1 commits from worktree-haptics-m8-proto into main 2026-08-04 21:03:36 +00:00
Owner

Workstream M8 of the haptics sweep — device protocol encoders. Closes B21, B23, B26, R22; R7 is closed as no change, with reasoning.

B21 — a centred stick did not encode as centre

The mapper inverted the already-quantised byte. 0..=255 has no exact midpoint: the forward map puts centre at 0x80, so mirroring the output gives 0x7F — one below the 0x80 that DsState::neutral and the pad's own resting report use.

Games idle-poll a centred stick constantly, so a DualSense / Edge / DS4 sat under a permanent sub-deadzone tilt on Y. Inverting in i16 space instead maps centre to centre by construction and keeps both extremes exact. Only cost: i16::MIN and -32767 share a code — one LSB at the very end of travel.

B23 — force-feedback ignored replay.delay

Decoded on upload, never read. An effect started the moment it was played and ended replay.length later, so anything scheduling a delayed effect — DirectInput under Wine does this routinely — fired early and finished early by the same amount. The delay now shifts the whole window, with length measured from the end of the delay rather than eaten into by it.

Writing the test surfaced a second bug in the same path. A waiting effect was still a candidate for the abandoned-effect force-off — and since the play command is itself the last FF activity, an infinite effect with a delay longer than the idle window would be killed on its first contributing tick, after sitting silent the whole time it waited. Being "abandoned" now requires the effect to have been audible for the window too, not merely the plane quiet.

B26 — an empty serial panicked the service thread

clamp(1, 21) then slicing that many bytes asks a zero-byte slice for one byte. The kernel already has a graceful answer for a length it rejects (1 <= reply[1] <= 21 → the "XXXXXXXXXX" fallback), so report the true length and let it fall back.

R22 — Deck triggers could not reach full pull

u8 × 128 tops out at 32640 of a declared 32767, leaving the top 127 counts unreachable — no game could ever see the axis bottom out. One multiply gets both ends exact. The existing inverse (>> 7) still round-trips: 32767 >> 7 == 255.

R7 — no change, deliberately

The watchdog does cut finite multi-second effects that the uinput path exempts. But the uinput path can exempt them because evdev FF hands it an explicit replay.length; nothing equivalent reaches this layer. PadFeedback carries motor levels, and the protocols behind the watchdog (DualSense / DS4 / Deck / Switch Pro) are all level-triggered with no duration field anywhere in a report — so there is nothing there to exempt.

The real choice is between cutting a long finite effect and letting an abandoned residual drone forever, and only the residual has field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is unaffected either way — hid-nintendo re-sends continuously and a physical Pro's HD-rumble decays faster than the window regardless. The cost is now written at the constant so the next reader sees the trade rather than rediscovering it.


Verification — amd64 Linux (pf-lxcheck2), after cargo clean -p pf-inject

  • cargo clippy --all-targets --locked -p pf-inject -- -D warningsexit 0
  • cargo test -p pf-inject91 passed / 0 failed (87 baseline + 4 new)
  • cargo fmt --all --check — clean
  • Non-vacuity proven: reverting all four fixes fails exactly four tests, one per fix.

The first probe caught a vacuous test of my own. My initial B23 test hand-built the playback window, so it exercised the mixer's handling of a delay but not the code that computes the window from replay_delay — reverting that fix failed nothing. The computation is now split into Effect::window, which is what the new test pins; the EV_FF handler that calls it needs a live uinput fd and stays untestable.

Not verified

No on-glass. B21's payoff is a stick that rests dead-centre, B23's is a Wine/DirectInput title whose delayed effects line up — both want a physical pad and a real game. The encoders themselves are pure functions and fully covered.

Workstream **M8** of the haptics sweep — device protocol encoders. Closes **B21, B23, B26, R22**; **R7** is closed as *no change*, with reasoning. ### B21 — a centred stick did not encode as centre The mapper inverted the **already-quantised** byte. `0..=255` has no exact midpoint: the forward map puts centre at `0x80`, so mirroring the output gives `0x7F` — one below the `0x80` that `DsState::neutral` and the pad's own resting report use. Games idle-poll a centred stick constantly, so a DualSense / Edge / DS4 sat under a permanent sub-deadzone tilt on Y. Inverting in `i16` space instead maps centre to centre *by construction* and keeps both extremes exact. Only cost: `i16::MIN` and `-32767` share a code — one LSB at the very end of travel. ### B23 — force-feedback ignored `replay.delay` Decoded on upload, never read. An effect started the moment it was played and ended `replay.length` later, so anything scheduling a delayed effect — DirectInput under Wine does this routinely — fired early **and** finished early by the same amount. The delay now shifts the whole window, with length measured from the *end* of the delay rather than eaten into by it. **Writing the test surfaced a second bug in the same path.** A waiting effect was still a candidate for the abandoned-effect force-off — and since the play command is itself the last FF activity, an infinite effect with a delay longer than the idle window would be killed on its first contributing tick, after sitting silent the whole time it waited. Being "abandoned" now requires the effect to have been *audible* for the window too, not merely the plane quiet. ### B26 — an empty serial panicked the service thread `clamp(1, 21)` then slicing that many bytes asks a zero-byte slice for one byte. The kernel already has a graceful answer for a length it rejects (`1 <= reply[1] <= 21` → the `"XXXXXXXXXX"` fallback), so report the true length and let it fall back. ### R22 — Deck triggers could not reach full pull `u8 × 128` tops out at 32640 of a declared 32767, leaving the top 127 counts unreachable — no game could ever see the axis bottom out. One multiply gets both ends exact. The existing inverse (`>> 7`) still round-trips: `32767 >> 7 == 255`. ### R7 — no change, deliberately The watchdog *does* cut finite multi-second effects that the uinput path exempts. But the uinput path can exempt them because evdev FF hands it an explicit `replay.length`; nothing equivalent reaches this layer. `PadFeedback` carries motor levels, and the protocols behind the watchdog (DualSense / DS4 / Deck / Switch Pro) are all level-triggered with **no duration field anywhere in a report** — so there is nothing there to exempt. The real choice is between cutting a long finite effect and letting an abandoned residual drone forever, and only the residual has field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is unaffected either way — `hid-nintendo` re-sends continuously and a physical Pro's HD-rumble decays faster than the window regardless. The cost is now written at the constant so the next reader sees the trade rather than rediscovering it. --- ### Verification — amd64 Linux (`pf-lxcheck2`), after `cargo clean -p pf-inject` - `cargo clippy --all-targets --locked -p pf-inject -- -D warnings` — **exit 0** - `cargo test -p pf-inject` — **91 passed / 0 failed** (87 baseline + 4 new) - `cargo fmt --all --check` — clean - **Non-vacuity proven**: reverting all four fixes fails exactly four tests, one per fix. **The first probe caught a vacuous test of my own.** My initial B23 test hand-built the playback window, so it exercised the mixer's handling of a delay but *not* the code that computes the window from `replay_delay` — reverting that fix failed nothing. The computation is now split into `Effect::window`, which is what the new test pins; the `EV_FF` handler that calls it needs a live uinput fd and stays untestable. ### Not verified No on-glass. B21's payoff is a stick that rests dead-centre, B23's is a Wine/DirectInput title whose delayed effects line up — both want a physical pad and a real game. The encoders themselves are pure functions and fully covered.
enricobuehler added 1 commit 2026-08-04 18:09:31 +00:00
fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 2m8s
ci / rust-arm64 (pull_request) Successful in 4m2s
android / android (pull_request) Successful in 9m9s
ci / rust (pull_request) Successful in 11m18s
6e001e54b4
Four encoder faults, plus a note on a fifth that turned out not to be one.

A centred stick did not encode as centre on the Y axes. The mapper inverted the
already-quantised byte, and 0..255 has no exact midpoint: the forward map puts
centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that
DsState::neutral and the pad's own resting report use. Games idle-poll a
centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent
sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by
construction and keeps both extremes exact; the only cost is i16::MIN and
-32767 sharing a code, one LSB at the very end of the travel.

Force-feedback ignored replay.delay. It was decoded on upload and never read:
an effect started the moment it was played and ended replay.length later, so
anything scheduling a delayed effect — DirectInput under Wine does this
routinely — fired early AND finished early by the same amount. The delay now
shifts the whole window, with length measured from the end of the delay rather
than eaten into by it. Writing the test for that surfaced a second bug in the
same path: a waiting effect was still a candidate for the abandoned-effect
force-off, and since the play command is itself the last FF activity, an
infinite effect with a delay longer than the idle window would be killed on its
first contributing tick after sitting silent the whole time it waited. Being
abandoned now requires the effect to have been audible for the window too.

An empty serial panicked the service thread. The reply builder clamped the
length to at least 1 and then sliced that many bytes out of the string, which
asks a zero-byte slice for one byte. The kernel already has a graceful answer
for a length it rejects, so report the true one and let it fall back.

Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a
declared 32767, leaving the last 127 counts unreachable, so no game could ever
see the axis bottom out. One multiply gets both ends exact.

The idle watchdog is left alone. It does cut finite multi-second effects that
the uinput path exempts, but only the uinput path is handed an explicit
duration; the protocols behind the watchdog are level-triggered with no
duration field anywhere in a report, so there is nothing at that layer to
exempt. The choice is between cutting a long effect and letting an abandoned
one drone forever, and only the latter has field evidence behind it. Recorded
at the constant so the next reader sees the cost rather than rediscovering it.
enricobuehler marked the pull request as ready for review 2026-08-04 21:03:26 +00:00
enricobuehler merged commit 53eb592c43 into main 2026-08-04 21:03:36 +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#43