fix(input): preserve stick direction outside the dead zone #1600

Closed
bronze-ff wants to merge 2 commits from bronze-ff/fix/radial-stick-dead-zone into main
bronze-ff commented 2026-11-10 10:43:56 +00:00 (Migrated from github.com)

Summary

Addresses #1480. The host currently applies the stick dead zone to X and Y independently. For example, with the default cutoff SDL input (12000, -4000) reaches the game as (12000, 0), snapping a diagonal toward horizontal.

Use the same circular cutoff for both sticks in SDL and XInput. Inside the circle both components are zero. Outside it both components pass through with their original magnitude and the existing axis conventions. The six thresholds and the saved option stay the same.

Validation

  • Reproduced the SDL snap on main with synthetic input. The regression now exercises all 65,536 values on each axis at each threshold, diagonals in every quadrant, the exact cutoff, and extreme inputs.
  • On Windows with LLVM-MinGW, passed the stick dead-zone, input-background, menu-keys, rumble-menu, rumble-mapping and HD-rumble tests. Also syntax-checked both host input translation units.
  • The same six tests passed with GCC/G++ in a Linux container.
  • Input was synthetic. A physical DualSense and gameplay were outside this run.
## Summary Addresses #1480. The host currently applies the stick dead zone to X and Y independently. For example, with the default cutoff SDL input `(12000, -4000)` reaches the game as `(12000, 0)`, snapping a diagonal toward horizontal. Use the same circular cutoff for both sticks in SDL and XInput. Inside the circle both components are zero. Outside it both components pass through with their original magnitude and the existing axis conventions. The six thresholds and the saved option stay the same. ## Validation - Reproduced the SDL snap on `main` with synthetic input. The regression now exercises all 65,536 values on each axis at each threshold, diagonals in every quadrant, the exact cutoff, and extreme inputs. - On Windows with LLVM-MinGW, passed the stick dead-zone, input-background, menu-keys, rumble-menu, rumble-mapping and HD-rumble tests. Also syntax-checked both host input translation units. - The same six tests passed with GCC/G++ in a Linux container. - Input was synthetic. A physical DualSense and gameplay were outside this run.
hckrman101 (Migrated from github.com) approved these changes 2026-11-10 16:39:28 +00:00
hckrman101 (Migrated from github.com) left a comment

Looks good. The circular cutoff is applied per stick in both SDL and XInput, and outside the circle both components pass through unchanged, so diagonals keep their direction. I checked the details: the squared sum can't overflow int32 once both components are clamped to -32767 (2*32767^2 is about 2.147e9, just under INT32_MAX), the SDL Y flip and the invert X/Y handling match the old per-axis behaviour, and on-axis input gives the same result as before. hos_stick_axis has no other callers. The test, menu text and readme are updated to match, and CI is green.

Nit: the Default zone (0) is now a circle of radius 7849 rather than a per-axis cut, so a stick that drifts just past 24% on one axis only will behave slightly differently. That's the intended fix and the readme says it.

Looks good. The circular cutoff is applied per stick in both SDL and XInput, and outside the circle both components pass through unchanged, so diagonals keep their direction. I checked the details: the squared sum can't overflow int32 once both components are clamped to -32767 (2*32767^2 is about 2.147e9, just under INT32_MAX), the SDL Y flip and the invert X/Y handling match the old per-axis behaviour, and on-axis input gives the same result as before. hos_stick_axis has no other callers. The test, menu text and readme are updated to match, and CI is green. Nit: the Default zone (0) is now a circle of radius 7849 rather than a per-axis cut, so a stick that drifts just past 24% on one axis only will behave slightly differently. That's the intended fix and the readme says it.
hckrman101 commented 2026-11-10 17:49:52 +00:00 (Migrated from github.com)

Heads-up: this conflicts with main now (a lot landed today). Could you merge main into the branch (or rebase) and resolve the conflicts? Thanks!

Heads-up: this conflicts with main now (a lot landed today). Could you merge main into the branch (or rebase) and resolve the conflicts? Thanks!
hckrman101 (Migrated from github.com) approved these changes 2026-11-11 16:49:35 +00:00
hckrman101 (Migrated from github.com) left a comment

Re-checked at the new head. The push is only a merge of main: the circular filter is unchanged, and the SDL path keeps main's rest-before-steer check (#1673/#1661) right after the filter, as the merge message says. The resolved hunks, the test, menu text and readme still read right, and CI is green (the two skipped jobs are the release ones). It merges cleanly now.

For the related PRs: #2286 (camera inversion out of rest detection) edits the same SDL block, where the invert flips now sit just above the rest loop, so whichever lands second needs a small rebase. #1945 (camera speed row) should not overlap beyond the right-stick values in pads_poll.

Re-checked at the new head. The push is only a merge of main: the circular filter is unchanged, and the SDL path keeps main's rest-before-steer check (#1673/#1661) right after the filter, as the merge message says. The resolved hunks, the test, menu text and readme still read right, and CI is green (the two skipped jobs are the release ones). It merges cleanly now. For the related PRs: #2286 (camera inversion out of rest detection) edits the same SDL block, where the invert flips now sit just above the rest loop, so whichever lands second needs a small rebase. #1945 (camera speed row) should not overlap beyond the right-stick values in pads_poll.
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 21:46:01 +00:00
Joey-Magnus (Migrated from github.com) left a comment

The circular cutoff in hos_filter_stick (nn_hid.c) still isn't on main. Main still applies hos_stick_axis per axis, so the fix is still wanted. The conflict is the one predicted in the last review: #2286 moved the invert-X/Y flips in pads_poll (host/pad_sdl.c) below the rest-detection loop, so a flipped axis doesn't count as rested. When you rebase, keep main's order: run the filter and the SDL Y flip, then the rested loop, and only then if (inv_x) v[2] = -v[2]; if (inv_y) v[3] = -v[3];. Don't apply the PR's if (!inv_y) before the loop. The readme conflict is the dead-zone line.

One thing to think about, not blocking. With the circle, a phantom axis that reads as held (the #1661/#1673 devices) keeps its partner axis "active", so the partner's drift now passes without its own dead zone once the rest loop admits it. The game's own dead zone still follows, so it's probably fine. Please re-run tests/test_stick_dead_zone.py after the rebase.

The circular cutoff in `hos_filter_stick` (nn_hid.c) still isn't on main. Main still applies `hos_stick_axis` per axis, so the fix is still wanted. The conflict is the one predicted in the last review: #2286 moved the invert-X/Y flips in `pads_poll` (host/pad_sdl.c) below the rest-detection loop, so a flipped axis doesn't count as rested. When you rebase, keep main's order: run the filter and the SDL Y flip, then the `rested` loop, and only then `if (inv_x) v[2] = -v[2]; if (inv_y) v[3] = -v[3];`. Don't apply the PR's `if (!inv_y)` before the loop. The readme conflict is the dead-zone line. One thing to think about, not blocking. With the circle, a phantom axis that reads as held (the #1661/#1673 devices) keeps its partner axis "active", so the partner's drift now passes without its own dead zone once the rest loop admits it. The game's own dead zone still follows, so it's probably fine. Please re-run `tests/test_stick_dead_zone.py` after the rebase.
github-sync closed this pull request 2026-11-12 17:57:39 +00:00
Owner

@hckrman101 approved on GitHub · 2026-11-12 17:00 UTC · original

I merged main into the branch (047c5f9) and added a fix on top (cbf59bb).

Merge: the SDL block uses main's #2286 order, as @Joey-Magnus asked: the circular filter and both Y flips first, then the rested loop, and the invert X/Y flips only after it. I dropped the if (!inv_y) from before the loop. The readme keeps main's new Camera speed entry next to your dead zone text. Main had also added a new caller of stick_value (the motion-only pad's activity check) and test_stick_inversion_rest.py, which reads hos_stick_axis/stick_value, so both now use hos_filter_stick.

Joey's phantom-axis point: I fixed it rather than leave it. With the circle, an axis held from connection (the #1661/#1673 devices) kept its stick outside the circle. That did two things: the partner axis never read as rested at its own center, and once it was admitted its drift got through without a dead zone. Now the rest loop judges each axis on its own against the dead zone, as before the circle, and each stick goes through the circle again after the loop, so an axis that's held back doesn't count toward it. For a normal stick nothing changes, because the second pass keeps values that are already outside the circle. test_stick_inversion_rest.py now covers that case (it fails without the fix) and checks that diagonals keep their full magnitude in every quadrant and dead zone. test_stick_rest.py is updated for the reshaped loop.

Also fixed: the menu translations were still keyed to the old per-axis help text, so test_menu_i18n.py failed after the merge. All 13 languages are retranslated for the new wording.

The stick, menu, input, rumble and option-id tests and the full suite pass locally (the only failures were two checks that need Python modules this PC doesn't have), and the renderer builds. Thanks for the fix, @bronze-ff, and for the careful tests!

> [@hckrman101](https://github.com/hckrman101) approved on GitHub · 2026-11-12 17:00 UTC · [original](https://github.com/NotRexed/Hookshot/pull/1600#pullrequestreview-5878630063) I merged main into the branch (047c5f9) and added a fix on top (cbf59bb). **Merge:** the SDL block uses main's #2286 order, as @Joey-Magnus asked: the circular filter and both Y flips first, then the `rested` loop, and the invert X/Y flips only after it. I dropped the `if (!inv_y)` from before the loop. The readme keeps main's new Camera speed entry next to your dead zone text. Main had also added a new caller of `stick_value` (the motion-only pad's activity check) and `test_stick_inversion_rest.py`, which reads `hos_stick_axis`/`stick_value`, so both now use `hos_filter_stick`. **Joey's phantom-axis point:** I fixed it rather than leave it. With the circle, an axis held from connection (the #1661/#1673 devices) kept its stick outside the circle. That did two things: the partner axis never read as rested at its own center, and once it was admitted its drift got through without a dead zone. Now the rest loop judges each axis on its own against the dead zone, as before the circle, and each stick goes through the circle again after the loop, so an axis that's held back doesn't count toward it. For a normal stick nothing changes, because the second pass keeps values that are already outside the circle. `test_stick_inversion_rest.py` now covers that case (it fails without the fix) and checks that diagonals keep their full magnitude in every quadrant and dead zone. `test_stick_rest.py` is updated for the reshaped loop. **Also fixed:** the menu translations were still keyed to the old per-axis help text, so `test_menu_i18n.py` failed after the merge. All 13 languages are retranslated for the new wording. The stick, menu, input, rumble and option-id tests and the full suite pass locally (the only failures were two checks that need Python modules this PC doesn't have), and the renderer builds. Thanks for the fix, @bronze-ff, and for the careful tests! <!-- github-sync:r:5878630063 -->
Commenting is not possible because the repository is archived.
No description provided.