Motion controls: the full six-axis state as Eden builds it (follow-ups to #1114) #1237

Merged
wyatt-poole merged 12 commits from hid/motion-followups into main 2026-11-10 03:56:58 +00:00
wyatt-poole commented 2026-11-09 18:34:33 +00:00 (Migrated from github.com)

Follow-ups to #1114, found in a second review of the merged code (none of them reported in game yet):

  • Tilt pull past half a turn: at a sensitivity that isn't a whole number (most of the menu's 10% steps), the pull's target was wrap(wrap(tilt - tilt0) * sv). That target jumps by about pi once the pad turns past half a turn from its anchor. The pull now accumulates the tilt unwrapped since the anchor and scales that.
  • Settings changed mid-aim: changing Motion sensitivity Y or Invert motion Y while holding the pad tilted made the aim slide to the new scale, or swing to the mirrored side for the invert, with the pad held still. A change of sv now re-anchors the pull.
  • Stillness and steady turns: stillness looked only at the accelerometer's magnitude, so a pad turning at a steady rate counted as still and its turn was learned as bias. It now uses the variance of the accelerometer's vector.
  • Noisier sensors: the gyro threshold is raised to 0.015 rad/s summed over the three axes (about 0.009 per axis), leaving room for noisier third-party sensors. A pad that hasn't read still for 30 s now says so once in the log.
  • Delta time cap: the delta_time handed to the game is capped at 100 ms, the same cap the filter uses for its own dt.
  • Sampling numbers: they now move a whole history (32) forward at each switch between live and at-rest data, so no two entries ever share a number.

Tests cover each of these: a sensitivity change and an invert change while held tilted, a pitch of 3.5 rad at 50%, and a 0.0067 rad/s noisy sensor learning its bias on a table. test_motion.py passes with gcc and clang. run_all.py passes apart from test_output_resolution.py, which already fails on main before #1114, and nx2/test_nup_read_name.py, which needs zstandard.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW

Follow-ups to #1114, found in a second review of the merged code (none of them reported in game yet): - **Tilt pull past half a turn:** at a sensitivity that isn't a whole number (most of the menu's 10% steps), the pull's target was `wrap(wrap(tilt - tilt0) * sv)`. That target jumps by about pi once the pad turns past half a turn from its anchor. The pull now accumulates the tilt unwrapped since the anchor and scales that. - **Settings changed mid-aim:** changing Motion sensitivity Y or Invert motion Y while holding the pad tilted made the aim slide to the new scale, or swing to the mirrored side for the invert, with the pad held still. A change of `sv` now re-anchors the pull. - **Stillness and steady turns:** stillness looked only at the accelerometer's magnitude, so a pad turning at a steady rate counted as still and its turn was learned as bias. It now uses the variance of the accelerometer's vector. - **Noisier sensors:** the gyro threshold is raised to 0.015 rad/s summed over the three axes (about 0.009 per axis), leaving room for noisier third-party sensors. A pad that hasn't read still for 30 s now says so once in the log. - **Delta time cap:** the `delta_time` handed to the game is capped at 100 ms, the same cap the filter uses for its own dt. - **Sampling numbers:** they now move a whole history (32) forward at each switch between live and at-rest data, so no two entries ever share a number. Tests cover each of these: a sensitivity change and an invert change while held tilted, a pitch of 3.5 rad at 50%, and a 0.0067 rad/s noisy sensor learning its bias on a table. `test_motion.py` passes with gcc and clang. `run_all.py` passes apart from `test_output_resolution.py`, which already fails on main before #1114, and `nx2/test_nup_read_name.py`, which needs `zstandard`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
hckrman101 (Migrated from github.com) reviewed 2026-11-09 18:42:55 +00:00
hckrman101 (Migrated from github.com) left a comment

Nice set of follow-ups. Here's what I checked and found to hold up:

  • Tilt pull: accumulating wrap(tilt - tilt_last) from an anchor of pitch / sv makes the target agree with the gyro integral at any sensitivity, so the jump at half a turn is gone. While the pad is rolled or shaken, tilt_last isn't updated, and the first level reading afterwards picks up the whole change, so the old "corrected once it's level again" behaviour is kept.
  • Re-anchoring on a new sv: this covers both sensitivity Y and the invert, since the invert flips the sign of sv. The new tests fail on main as they should: at 2x the aim would slide to -1.0, and at 3.5 rad / 50% the wrapped target lands well away from -1.75.
  • Sampling numbers: I walked through live -> rest -> live. With += HOS_MOTION_HISTORY on the way to rest and six_sampling + HOS_MOTION_HISTORY - h[0].n on the way back, the oldest entry of the new run is always above the newest of the old one. motion_readings stays continuous across a pad switch, so h[] is consecutive and the reasoning holds.
  • dt cap and threshold: the dt_ns cap matches HOS_MOTION_DT_MAX. The gyro threshold numbers check out (0.015/sqrt(3) is about 0.0087 per axis, and the old 0.01 is about 0.0058). The noisy-sensor test fails stillness on main and passes here, and the 0.03 tremor in the hand test still stays well above the new threshold.
  • CI: test_motion.py passes on both runners. The only red check is test_output_resolution.py, which fails on main too, so it isn't this PR's fault. No conflicts with main, and no overlap with other open PRs (#1213 touches pad_sdl.c/nn_hid.c, but only in the rumble code).

There's one thing I'd like looked at before merging. The accelerometer test now uses the variance of the whole vector, summed over three axes, but HOS_MOTION_STILL_ACCEL is still 0.01. For isotropic noise, that makes the accelerometer side about sqrt(3) stricter: roughly 5.8 mG per axis now, against 10 mG for the radial component before. So the PR loosens the gyro threshold for noisier third-party pads and tightens the accelerometer one, which works against that goal. The tests can't show it, because the simulated accelerometer noise is 1e-5 m/s^2, which is effectively zero. Details inline.

There's also a smaller point on how much the vector variance actually catches, and a nit on the new 30 s log line.

Nice set of follow-ups. Here's what I checked and found to hold up: - **Tilt pull:** accumulating `wrap(tilt - tilt_last)` from an anchor of `pitch / sv` makes the target agree with the gyro integral at any sensitivity, so the jump at half a turn is gone. While the pad is rolled or shaken, `tilt_last` isn't updated, and the first level reading afterwards picks up the whole change, so the old "corrected once it's level again" behaviour is kept. - **Re-anchoring on a new `sv`:** this covers both sensitivity Y and the invert, since the invert flips the sign of `sv`. The new tests fail on main as they should: at 2x the aim would slide to -1.0, and at 3.5 rad / 50% the wrapped target lands well away from -1.75. - **Sampling numbers:** I walked through live -> rest -> live. With `+= HOS_MOTION_HISTORY` on the way to rest and `six_sampling + HOS_MOTION_HISTORY - h[0].n` on the way back, the oldest entry of the new run is always above the newest of the old one. `motion_readings` stays continuous across a pad switch, so `h[]` is consecutive and the reasoning holds. - **dt cap and threshold:** the `dt_ns` cap matches `HOS_MOTION_DT_MAX`. The gyro threshold numbers check out (0.015/sqrt(3) is about 0.0087 per axis, and the old 0.01 is about 0.0058). The noisy-sensor test fails stillness on main and passes here, and the 0.03 tremor in the hand test still stays well above the new threshold. - **CI:** `test_motion.py` passes on both runners. The only red check is `test_output_resolution.py`, which fails on main too, so it isn't this PR's fault. No conflicts with main, and no overlap with other open PRs (#1213 touches `pad_sdl.c`/`nn_hid.c`, but only in the rumble code). There's one thing I'd like looked at before merging. The accelerometer test now uses the variance of the whole vector, summed over three axes, but `HOS_MOTION_STILL_ACCEL` is still 0.01. For isotropic noise, that makes the accelerometer side about sqrt(3) stricter: roughly 5.8 mG per axis now, against 10 mG for the radial component before. So the PR loosens the gyro threshold for noisier third-party pads and tightens the accelerometer one, which works against that goal. The tests can't show it, because the simulated accelerometer noise is 1e-5 m/s^2, which is effectively zero. Details inline. There's also a smaller point on how much the vector variance actually catches, and a nit on the new 30 s log line.
@ -90,0 +123,4 @@
const float vz = q1 * q1 - q2 * q2 - q3 * q3 + q4 * q4;
const float e[3] = {az * vx - ax * vz, ay * vz - az * vy, ax * vy - ay * vx};
memcpy(m->real_error, e, sizeof e);
if (gain <= 1 && hos_motion_len(e) >= 0.05f)
hckrman101 (Migrated from github.com) commented 2026-11-09 18:42:55 +00:00

Since this now sums the variance over three axes, isotropic accelerometer noise reads about sqrt(3) bigger than it did on the magnitude. Combined with HOS_MOTION_STILL_ACCEL staying at 0.01, a pad needs under about 5.8 mG per axis to count as still, where it used to need under 10 mG radial. That runs against the noisier-sensor motivation. Two options: scale the threshold by sqrt(3) to match the gyro side's convention, or add realistic accelerometer noise (a few mG) to the noisy-sensor test so this case is covered at all. Right now run_dt adds 1e-5 m/s^2.

Separately, about how much this catches: a steady turn about gravity (yaw) doesn't change the accelerometer vector at all, so it still reads as still. A steady pitch or roll only shows up once the EMA lag omega * 0.2 s passes 0.01, which is around 3 deg/s. So the header comment "a pad turning at a steady rate is not still" overstates it a bit; hand tremor on the gyro is still what keeps aiming from being learned as bias. There's also no test for the steady-turn case this change is meant to fix. Not a blocker, but it would be good to make the comment match and/or add that test.

Since this now sums the variance over three axes, isotropic accelerometer noise reads about sqrt(3) bigger than it did on the magnitude. Combined with `HOS_MOTION_STILL_ACCEL` staying at 0.01, a pad needs under about 5.8 mG per axis to count as still, where it used to need under 10 mG radial. That runs against the noisier-sensor motivation. Two options: scale the threshold by sqrt(3) to match the gyro side's convention, or add realistic accelerometer noise (a few mG) to the noisy-sensor test so this case is covered at all. Right now `run_dt` adds 1e-5 m/s^2. Separately, about how much this catches: a steady turn about gravity (yaw) doesn't change the accelerometer vector at all, so it still reads as still. A steady pitch or roll only shows up once the EMA lag `omega * 0.2 s` passes 0.01, which is around 3 deg/s. So the header comment "a pad turning at a steady rate is not still" overstates it a bit; hand tremor on the gyro is still what keeps aiming from being learned as bias. There's also no test for the steady-turn case this change is meant to fix. Not a blocker, but it would be good to make the comment match and/or add that test.
hckrman101 (Migrated from github.com) commented 2026-11-09 18:42:55 +00:00

Nit: anyone who picks up the pad at launch and plays for 30 s without ever setting it down will hit this, because a hand never reads still by design. In that common case the "its sensor is noisier than expected" part points people the wrong way when they read logs. Maybe reword it so the table hint comes first and the noisy-sensor explanation is only offered as a possibility.

Nit: anyone who picks up the pad at launch and plays for 30 s without ever setting it down will hit this, because a hand never reads still by design. In that common case the "its sensor is noisier than expected" part points people the wrong way when they read logs. Maybe reword it so the table hint comes first and the noisy-sensor explanation is only offered as a possibility.
Link-22 commented 2026-11-09 18:45:05 +00:00 (Migrated from github.com)

@wyatt-poole one more for this list. I posted it on #1114 just after it was merged, so it may have been missed: my retest of 76fc99b, with a HOS_MOTION_DEBUG log attached.

The vertical aim depends on the pad's pose when the filter starts. If Motion controls starts while the pad is rolled and pitched, the vertical aim stays about 0.9 rad off from a level start, with the same hold afterwards. That happens at game start, on toggling the setting, and on switching pads. It feels almost inverted in game. Starting with the pad flat on the desk fixes it.

From the log, pitch starts at 0 in whatever pose the pad is in. The tilt pull is off while rolled, and the anchor (tilt - pitch / sv) then keeps that offset once the pad comes level. A possible fix: start pitch from the measured tilt when up is first set (e.g. sv * asin(-up[2])), and anchor the pull to the absolute tilt. I'm happy to retest on the T3 Pro.

@wyatt-poole one more for this list. I posted it on #1114 just after it was merged, so it may have been missed: [my retest of 76fc99b](https://git.magicbean.dev/MagicBeanDev/Hookshot/pulls/1114), with a `HOS_MOTION_DEBUG` log attached. **The vertical aim depends on the pad's pose when the filter starts.** If Motion controls starts while the pad is rolled *and* pitched, the vertical aim stays about 0.9 rad off from a level start, with the same hold afterwards. That happens at game start, on toggling the setting, and on switching pads. It feels almost inverted in game. Starting with the pad flat on the desk fixes it. From the log, `pitch` starts at 0 in whatever pose the pad is in. The tilt pull is off while rolled, and the anchor (`tilt - pitch / sv`) then keeps that offset once the pad comes level. A possible fix: start `pitch` from the measured tilt when `up` is first set (e.g. `sv * asin(-up[2])`), and anchor the pull to the absolute tilt. I'm happy to retest on the T3 Pro.
wyatt-poole commented 2026-11-09 18:51:44 +00:00 (Migrated from github.com)

@Link-22 @hckrman101 thanks, both of you. Everything is in the latest commit on this PR:

  • Start pose (Link-22): the vertical aim is now absolute, measured from the pad lying flat. When the filter starts, pitch starts at the pad's tilt from flat, sv * atan2(-up[2], up[1]). If the pad is rolled at that moment, it starts at the nose's angle above level, sv * asin(-up[2]), as you suggested, and the tilt pull settles it once the pad is level. The pull's anchor is that tilt itself (tilt_acc = tilt), not the current pitch, so a start held tilted no longer offsets the aim for good. A new test reproduces your start 3: a pose with the nose up 0.6 rad, reached rolled 40° before the filter starts, then rolled level. It now aims the same as reaching that pose from a flat start (-0.6). On main it lands near 0 (+0.12).
    • Because the aim stays measured from flat, a change of Motion sensitivity Y or Invert motion Y now rescales the aim to the pad's tilt at the new scale straight away (the menu is open at that point), instead of re-anchoring.
    • One trade-off to flag: a pad held upright (portrait, face to the player) now aims about 90° up, because flat is level. Before, the level was whatever pose the filter started in. If upright holds matter, a "level" choice (flat or upright) could follow.
  • Accelerometer threshold (hckrman101): HOS_MOTION_STILL_ACCEL is now 0.0173 G summed over the three axes, 0.01 G per axis, the same convention as the gyro's. The noisy-sensor test now adds 5 mG of accelerometer noise per axis on top of the 0.0067 rad/s gyro noise.
  • What the vector catches: I reworded the comment. It now says the accelerometer vector catches a steady pitch or roll faster than about 5°/s but not a turn about gravity, and that the hand's tremor is what keeps aiming from being learned as bias. There's a test for a steady, noiseless 0.2 rad/s pitch, which is not learned as bias here (on main it learns 0.18).
  • 30 s log line: it now leads with setting the pad down on a table. A noisy sensor is mentioned only as a possibility.
  • Motion controls row: it's preset-exempt now (the missing , true from your #1114 note).

test_motion.py passes with gcc and clang. run_all.py passes apart from the same two failures main has (test_output_resolution.py, and test_nup_read_name.py, which needs zstandard). Link-22, a retest of the start-tilted case would be great.

@Link-22 @hckrman101 thanks, both of you. Everything is in the latest commit on this PR: - **Start pose (Link-22):** the vertical aim is now absolute, measured from the pad lying flat. When the filter starts, `pitch` starts at the pad's tilt from flat, `sv * atan2(-up[2], up[1])`. If the pad is rolled at that moment, it starts at the nose's angle above level, `sv * asin(-up[2])`, as you suggested, and the tilt pull settles it once the pad is level. The pull's anchor is that tilt itself (`tilt_acc = tilt`), not the current pitch, so a start held tilted no longer offsets the aim for good. A new test reproduces your start 3: a pose with the nose up 0.6 rad, reached rolled 40° before the filter starts, then rolled level. It now aims the same as reaching that pose from a flat start (-0.6). On main it lands near 0 (+0.12). - Because the aim stays measured from flat, a change of Motion sensitivity Y or Invert motion Y now rescales the aim to the pad's tilt at the new scale straight away (the menu is open at that point), instead of re-anchoring. - One trade-off to flag: a pad held upright (portrait, face to the player) now aims about 90° up, because flat is level. Before, the level was whatever pose the filter started in. If upright holds matter, a "level" choice (flat or upright) could follow. - **Accelerometer threshold (hckrman101):** `HOS_MOTION_STILL_ACCEL` is now 0.0173 G summed over the three axes, 0.01 G per axis, the same convention as the gyro's. The noisy-sensor test now adds 5 mG of accelerometer noise per axis on top of the 0.0067 rad/s gyro noise. - **What the vector catches:** I reworded the comment. It now says the accelerometer vector catches a steady pitch or roll faster than about 5°/s but not a turn about gravity, and that the hand's tremor is what keeps aiming from being learned as bias. There's a test for a steady, noiseless 0.2 rad/s pitch, which is not learned as bias here (on main it learns 0.18). - **30 s log line:** it now leads with setting the pad down on a table. A noisy sensor is mentioned only as a possibility. - **Motion controls row:** it's preset-exempt now (the missing `, true` from your #1114 note). `test_motion.py` passes with gcc and clang. `run_all.py` passes apart from the same two failures main has (`test_output_resolution.py`, and `test_nup_read_name.py`, which needs `zstandard`). Link-22, a retest of the start-tilted case would be great.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 18:56:59 +00:00
@ -90,0 +123,4 @@
const float vz = q1 * q1 - q2 * q2 - q3 * q3 + q4 * q4;
const float e[3] = {az * vx - ax * vz, ay * vz - az * vy, ax * vy - ay * vx};
memcpy(m->real_error, e, sizeof e);
if (gain <= 1 && hos_motion_len(e) >= 0.05f)
hckrman101 (Migrated from github.com) commented 2026-11-09 18:56:59 +00:00

Thanks, both parts are covered now: 0.0173 matches the gyro side's convention, the noisy-sensor test runs at 5 mG RMS per axis (8.7 mG summed, well under the threshold), and the comment and the steady-pitch test match what the vector actually catches. The ~5 deg/s figure checks out with the new threshold (0.0173 / 0.2 s).

Thanks, both parts are covered now: 0.0173 matches the gyro side's convention, the noisy-sensor test runs at 5 mG RMS per axis (8.7 mG summed, well under the threshold), and the comment and the steady-pitch test match what the vector actually catches. The ~5 deg/s figure checks out with the new threshold (0.0173 / 0.2 s).
hckrman101 (Migrated from github.com) reviewed 2026-11-09 18:57:00 +00:00
hckrman101 (Migrated from github.com) commented 2026-11-09 18:57:00 +00:00

Reads much better, thanks.

Reads much better, thanks.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 18:57:15 +00:00
hckrman101 (Migrated from github.com) left a comment

Re-checked 73a34a6. My three earlier points are all fixed (replied in the threads). test_motion.py passes on the Linux CI job; the only failure is test_output_resolution.py, the same as on main. Windows is still running.

The "measured from flat" change fixes Link-22's start-pose bug, and the mechanics look right to me. Starting pitch at sv * tilt and taking tilt_acc = tilt on the first level reading lines the pull target up with the aim, so nothing jumps after the start. The rolled start via asin is continuous, and the atan2 is only evaluated under the roll gate, so it never sees the degenerate case. Resetting on a toggle or pad switch now lands on the same aim as continuing would. The new start-3 test is a good reproduction.

One thing I'd like settled before approving: Motion sensitivity Y and Invert motion Y now scale an absolute angle, so they move the resting aim as well as the speed. Link-22's normal hold is about 0.7 rad from flat (up ≈ (0.02, 0.76, -0.65)). That gives:

  • 100%: aim ≈ 0.7 rad, the "fine" case
  • 200%: ≈ 1.4 rad, about 81°, right at vertical
  • 300%: ≈ 2.1 rad, past vertical
  • Invert Y at 100%: ≈ -0.7 rad, the mirror of the normal hold

His table suggests the game does care where the matrix sits in absolute terms (+0.09 felt "almost inverted" while -0.8/-0.9 felt fine). If so, high Y sensitivity or Invert Y may feel as wrong as the start-pose bug did. Before #1237 that couldn't happen, because the aim started at 0. Could Link-22 try 200% and Invert Y on this head? If those feel off, one option is to apply sensitivity and invert only to the deviation from a fixed reference hold, rather than to the angle from flat.

Smaller: the readme and the header (hos_motion.h:14) still say "held flat or upright". By your own note, upright now aims about 90° up, so either drop "upright" for the vertical aim or mention the trade-off there.

Re-checked 73a34a6. My three earlier points are all fixed (replied in the threads). `test_motion.py` passes on the Linux CI job; the only failure is `test_output_resolution.py`, the same as on main. Windows is still running. The "measured from flat" change fixes Link-22's start-pose bug, and the mechanics look right to me. Starting `pitch` at `sv * tilt` and taking `tilt_acc = tilt` on the first level reading lines the pull target up with the aim, so nothing jumps after the start. The rolled start via `asin` is continuous, and the `atan2` is only evaluated under the roll gate, so it never sees the degenerate case. Resetting on a toggle or pad switch now lands on the same aim as continuing would. The new start-3 test is a good reproduction. One thing I'd like settled before approving: Motion sensitivity Y and Invert motion Y now scale an absolute angle, so they move the resting aim as well as the speed. Link-22's normal hold is about 0.7 rad from flat (`up` ≈ (0.02, 0.76, -0.65)). That gives: - 100%: aim ≈ 0.7 rad, the "fine" case - 200%: ≈ 1.4 rad, about 81°, right at vertical - 300%: ≈ 2.1 rad, past vertical - Invert Y at 100%: ≈ -0.7 rad, the mirror of the normal hold His table suggests the game does care where the matrix sits in absolute terms (+0.09 felt "almost inverted" while -0.8/-0.9 felt fine). If so, high Y sensitivity or Invert Y may feel as wrong as the start-pose bug did. Before #1237 that couldn't happen, because the aim started at 0. Could Link-22 try 200% and Invert Y on this head? If those feel off, one option is to apply sensitivity and invert only to the deviation from a fixed reference hold, rather than to the angle from flat. Smaller: the readme and the header (hos_motion.h:14) still say "held flat or upright". By your own note, upright now aims about 90° up, so either drop "upright" for the vertical aim or mention the trade-off there.
@ -124,4 +182,4 @@
}
const int still = m->gvar < HOS_MOTION_STILL_GYRO * HOS_MOTION_STILL_GYRO &&
m->avar < HOS_MOTION_STILL_ACCEL * HOS_MOTION_STILL_ACCEL && m->amean > 0.9f && m->amean < 1.1f;
m->still_t = still ? m->still_t + dt : 0;
hckrman101 (Migrated from github.com) commented 2026-11-09 18:57:16 +00:00

Here sensitivity and invert stop being a speed and become an offset at rest: tilt_acc * sv puts a normal ~0.7 rad hold at ~1.4 rad at 200% and at -0.7 with Invert Y. That might be fine, but given Link-22 saw +0.09 feel inverted, it is worth a retest at 200% and with Invert Y on. If it is off, scale only the deviation from a reference hold.

Here sensitivity and invert stop being a speed and become an offset at rest: `tilt_acc * sv` puts a normal ~0.7 rad hold at ~1.4 rad at 200% and at -0.7 with Invert Y. That might be fine, but given Link-22 saw +0.09 feel inverted, it is worth a retest at 200% and with Invert Y on. If it is off, scale only the deviation from a reference hold.
hckrman101 (Migrated from github.com) commented 2026-11-09 18:57:16 +00:00

"held flat or upright" no longer holds for the vertical aim: upright is about 90° up now. Maybe drop it or note the trade-off (same in the header at hos_motion.h:14).

"held flat or upright" no longer holds for the vertical aim: upright is about 90° up now. Maybe drop it or note the trade-off (same in the header at hos_motion.h:14).
wyatt-poole commented 2026-11-09 19:03:23 +00:00 (Migrated from github.com)

@hckrman101 good catch, thanks. Fixed in the latest commit:

  • Sensitivity and invert as an offset: Motion sensitivity Y and Invert motion Y now scale only the tilt's change from where the filter started (tilt_ref, the pad's tilt from flat at that moment). The aim is -tilt_ref + (tilt - tilt_ref) * sv. At rest in that hold, the aim is the pad's real tilt at any setting. With the defaults, it's the real tilt everywhere, as a Switch reports it. A new test checks a 0.7 rad hold: at 200% it aims at 0.7, not near vertical, and a further 0.1 rad moves the aim by 0.2. With Invert Y it also aims at 0.7, not mirrored, and the same 0.1 rad moves the aim 0.1 the other way.
  • Readme and header: I dropped "held flat or upright" for the vertical aim. The turn still works in either hold, and the tilt is measured from flat.

A correction to my upright note above: since the game recenters the aim from the matrix each time it aims, an upright hold should behave like a real Switch controller held upright, not aim at the sky. That's my reading, though, and I haven't tested it.

@Link-22 when you retest, could you try 200% and Invert Y as well as the start-tilted case?

test_motion.py passes with gcc and clang. run_all.py shows the same two failures as main and nothing else.

@hckrman101 good catch, thanks. Fixed in the latest commit: - **Sensitivity and invert as an offset:** Motion sensitivity Y and Invert motion Y now scale only the tilt's *change* from where the filter started (`tilt_ref`, the pad's tilt from flat at that moment). The aim is `-tilt_ref + (tilt - tilt_ref) * sv`. At rest in that hold, the aim is the pad's real tilt at any setting. With the defaults, it's the real tilt everywhere, as a Switch reports it. A new test checks a 0.7 rad hold: at 200% it aims at 0.7, not near vertical, and a further 0.1 rad moves the aim by 0.2. With Invert Y it also aims at 0.7, not mirrored, and the same 0.1 rad moves the aim 0.1 the other way. - **Readme and header:** I dropped "held flat or upright" for the vertical aim. The turn still works in either hold, and the tilt is measured from flat. A correction to my upright note above: since the game recenters the aim from the matrix each time it aims, an upright hold should behave like a real Switch controller held upright, not aim at the sky. That's my reading, though, and I haven't tested it. @Link-22 when you retest, could you try 200% and Invert Y as well as the start-tilted case? `test_motion.py` passes with gcc and clang. `run_all.py` shows the same two failures as main and nothing else.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 19:13:37 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, the maths checks out. With the defaults the aim is exactly -tilt, the gyro term (rates[0] * sv) matches the pull target's slope, Invert Y flips only the change around the start hold, and a settings change rescales consistently in both branches. The readme and header wording is fixed too.

There's one case left, inline: tilt_ref is whatever pose the filter starts in, so the fix only holds if motion starts in the playing hold. There's also a small wrap nit. CI is the same two failures main has (test_output_resolution.py, test_program_cache_cleanup.py).

Thanks, the maths checks out. With the defaults the aim is exactly `-tilt`, the gyro term (`rates[0] * sv`) matches the pull target's slope, Invert Y flips only the change around the start hold, and a settings change rescales consistently in both branches. The readme and header wording is fixed too. There's one case left, inline: `tilt_ref` is whatever pose the filter starts in, so the fix only holds if motion starts in the playing hold. There's also a small wrap nit. CI is the same two failures main has (`test_output_resolution.py`, `test_program_cache_cleanup.py`).
@ -127,3 +185,3 @@
m->still_t = still ? m->still_t + dt : 0;
if (m->still_t >= HOS_MOTION_STILL_S && dt > 0) {
const float k = dt / ((m->learned_t < 2 ? 0.25f : 3.0f) + dt); /* (fast for the first 2 s still, then slowly) */
const float k = dt / ((m->learned_t < 2 ? 0.25f : 3.0f) + dt);
hckrman101 (Migrated from github.com) commented 2026-11-09 19:13:37 +00:00

This fixes the 200% and Invert Y rest only when the filter starts in the hold you play in. When it starts with the pad lying flat on the desk (at launch, or after a reconnect while it's set down, which the 30 s log line now tells people to do), tilt_ref is 0 and we're back to the old numbers: picking it up to a 0.7 rad hold aims -(0.7 * 2) = -1.4 at 200%, and +0.7 (mirrored) with Invert Y. So at any setting other than 100%, the resting aim depends on the start pose again, which is the same class of bug as Link-22's.

Could you add a test for a flat start, then a lift to 0.7 at 200% and with Invert Y? One fix could be to re-take tilt_ref (blended in, so the aim doesn't jump) once the pad has been held steady for a moment after leaving a still/on-the-table state; the bias learner already tells those apart. Otherwise the readme should at least say that the Y sensitivity and invert assume motion starts in your playing hold.

This fixes the 200% and Invert Y rest only when the filter starts in the hold you play in. When it starts with the pad lying flat on the desk (at launch, or after a reconnect while it's set down, which the 30 s log line now tells people to do), `tilt_ref` is 0 and we're back to the old numbers: picking it up to a 0.7 rad hold aims `-(0.7 * 2)` = -1.4 at 200%, and +0.7 (mirrored) with Invert Y. So at any setting other than 100%, the resting aim depends on the start pose again, which is the same class of bug as Link-22's. Could you add a test for a flat start, then a lift to 0.7 at 200% and with Invert Y? One fix could be to re-take `tilt_ref` (blended in, so the aim doesn't jump) once the pad has been held steady for a moment after leaving a still/on-the-table state; the bias learner already tells those apart. Otherwise the readme should at least say that the Y sensitivity and invert assume motion starts in your playing hold.
hckrman101 (Migrated from github.com) commented 2026-11-09 19:13:37 +00:00

Nit: tilt_acc starts from the wrapped atan2, but tilt_ref may sit on the other branch. Near face-down (around ±π) they can differ by 2π, and at a non-integer scale (150%, 50%) (tilt_acc - tilt_ref) * sv is then off by π or so. Starting it as m->tilt_ref + hos_motion_wrap(tilt - m->tilt_ref) keeps it on the same branch. (A rolled start held past vertical has a similar mismatch, asin vs atan2, but that's rare enough to ignore.)

Nit: `tilt_acc` starts from the wrapped `atan2`, but `tilt_ref` may sit on the other branch. Near face-down (around ±π) they can differ by 2π, and at a non-integer scale (150%, 50%) `(tilt_acc - tilt_ref) * sv` is then off by π or so. Starting it as `m->tilt_ref + hos_motion_wrap(tilt - m->tilt_ref)` keeps it on the same branch. (A rolled start held past vertical has a similar mismatch, `asin` vs `atan2`, but that's rare enough to ignore.)
wyatt-poole commented 2026-11-09 19:24:21 +00:00 (Migrated from github.com)

@hckrman101 both fixed in the latest commit:

  • Start on the desk: tilt_ref is now taken again once the pad has been set down and then picked up. The pad counts as set down while it's still on a table. It counts as picked up once it's held steady in the hand for 0.5 s, meaning the gyro's spread is under a hand's (0.05 rad/s) and it isn't turning, so a smooth sweep doesn't count as a hold. The new hold is eased in over about 0.3 s. At the defaults the aim doesn't move (it changes by -(1 + sv) per radian of ref). At other settings the resting aim eases to the real tilt in the new hold. I added the test you asked for: a flat start on the desk, then lifted to 0.7 rad with a hand's tremor and held. At 200% it rests at 0.7 (on main this gives -1.4), and with Invert Y it rests at 0.7 too (main gives a mirrored +0.7). A further 0.1 rad then moves the aim 0.2, or 0.1 the other way with the invert.
  • Branch nit: tilt_acc now starts as tilt_ref + wrap(tilt - tilt_ref).
  • Also: the stillness window now starts from a small variance instead of 1, so a pad that has just been set down counts as still after about 1.3 s instead of 2.2 s.
  • Readme: it now says that Y sensitivity and the invert scale the tilt's change from your playing hold, and that the hold is taken again a moment after the pad is picked up off a table.

test_motion.py passes with gcc and clang. run_all.py shows only the two failures main also has.

@hckrman101 both fixed in the latest commit: - **Start on the desk:** `tilt_ref` is now taken again once the pad has been set down and then picked up. The pad counts as set down while it's still on a table. It counts as picked up once it's held steady in the hand for 0.5 s, meaning the gyro's spread is under a hand's (0.05 rad/s) and it isn't turning, so a smooth sweep doesn't count as a hold. The new hold is eased in over about 0.3 s. At the defaults the aim doesn't move (it changes by `-(1 + sv)` per radian of ref). At other settings the resting aim eases to the real tilt in the new hold. I added the test you asked for: a flat start on the desk, then lifted to 0.7 rad with a hand's tremor and held. At 200% it rests at 0.7 (on main this gives -1.4), and with Invert Y it rests at 0.7 too (main gives a mirrored +0.7). A further 0.1 rad then moves the aim 0.2, or 0.1 the other way with the invert. - **Branch nit:** `tilt_acc` now starts as `tilt_ref + wrap(tilt - tilt_ref)`. - **Also:** the stillness window now starts from a small variance instead of 1, so a pad that has just been set down counts as still after about 1.3 s instead of 2.2 s. - **Readme:** it now says that Y sensitivity and the invert scale the tilt's change from your playing hold, and that the hold is taken again a moment after the pad is picked up off a table. `test_motion.py` passes with gcc and clang. `run_all.py` shows only the two failures main also has.
Link-22 commented 2026-11-09 19:45:24 +00:00 (Migrated from github.com)

@wyatt-poole @hckrman101 retested 775ca01 on the T3 Pro (Switch Pro mode, Linux, built on the v2.0.10 AppImage). Everything works:

  • Start pose (my bug): turning Motion controls off and on with the pad rolled and pitched now aims correctly. It's the same as starting flat on the desk, so the toggle-on-the-desk workaround is no longer needed.
  • Motion sensitivity Y 200%: the resting aim is normal, both when motion starts with the pad already in my hand and when it starts on the desk and I pick it up.
  • Invert motion Y: up/down is inverted as expected and the resting aim is not mirrored, again for both starts.
  • Defaults: direction, speed and calibration are as before. Turning away and back returns the aim to the same point.

Thanks for the quick turnaround on this. From my side #1237 is good to merge.

@wyatt-poole @hckrman101 retested **775ca01** on the T3 Pro (Switch Pro mode, Linux, built on the v2.0.10 AppImage). Everything works: - **Start pose (my bug):** turning Motion controls off and on with the pad rolled and pitched now aims correctly. It's the same as starting flat on the desk, so the toggle-on-the-desk workaround is no longer needed. - **Motion sensitivity Y 200%:** the resting aim is normal, both when motion starts with the pad already in my hand and when it starts on the desk and I pick it up. - **Invert motion Y:** up/down is inverted as expected and the resting aim is not mirrored, again for both starts. - **Defaults:** direction, speed and calibration are as before. Turning away and back returns the aim to the same point. Thanks for the quick turnaround on this. From my side #1237 is good to merge.
wyatt-poole commented 2026-11-09 19:46:37 +00:00 (Migrated from github.com)

@Link-22 @hckrman101 I've changed approach here. Instead of adding another case for each way the pad can be held, this PR now sends the game the full six-axis state, built the way Eden builds it. Link-22 said Eden aims right with this game, and a real Switch works however you hold the pad (in bed, at a desk, anywhere), because the game recenters on the camera every time it aims.

What changed:

  • Frame and units: acceleration in G and angular velocity in turns per second, in the Switch's frame, mapped from SDL exactly as Eden's sdl_driver.cpp maps them.
  • Rotation: the angular velocity summed.
  • Direction: the orientation matrix from Mahony's filter, following Eden's motion_input.cpp: kp 0.3, ki 0.005, the same quaternion integration and the same GetOrientation signs. It also includes the reset from the accelerometer when the filter loses track.
  • Kept from before: bias learning on a table, Eden's standard zero-drift threshold (0.007 turns/s), the per-reading history and sampling numbers, and the three six-axis handles.
  • Sensitivity and invert: Motion sensitivity X/Y and Invert motion X/Y scale the angular velocity's part about the world's up (X, the turn) and the rest (Y, the tilt) before the filter uses it. At 100% and not inverted, the state matches Eden's.

The tests now check:

  • the frame and units
  • the rotation sum
  • that a start flat followed by a tilt gives the same orientation as a start in that pose, including a start that's rolled and pitched (Link-22's case)
  • bias learning: small, large and noisy, plus what shouldn't be learned
  • the threshold
  • the sensitivities and inverts
  • repeated readings and stalls

test_motion.py passes with gcc and clang. run_all.py shows only main's two failures.

The code is attributed in hos_motion.h (yuzu GPL-2.0-or-later, Eden GPL-3.0-or-later; this project is GPL-3.0).

Two things I can't check from here:

  1. The real test is the game against Eden. @Link-22, could you compare this head with Eden on the T3 Pro: aim feel, starting in different poses, lying back, and 200% / Invert Y?
  2. spluton's earlier note (#112) said a "naive" full state looked dead. I think that was because of the frame (axes and signs) not matching, since Eden sends a full state and works, but only the game can confirm it.
@Link-22 @hckrman101 I've changed approach here. Instead of adding another case for each way the pad can be held, this PR now sends the game the full six-axis state, built the way Eden builds it. Link-22 said Eden aims right with this game, and a real Switch works however you hold the pad (in bed, at a desk, anywhere), because the game recenters on the camera every time it aims. What changed: - **Frame and units:** acceleration in G and angular velocity in turns per second, in the Switch's frame, mapped from SDL exactly as Eden's `sdl_driver.cpp` maps them. - **Rotation:** the angular velocity summed. - **Direction:** the orientation matrix from Mahony's filter, following Eden's `motion_input.cpp`: kp 0.3, ki 0.005, the same quaternion integration and the same `GetOrientation` signs. It also includes the reset from the accelerometer when the filter loses track. - **Kept from before:** bias learning on a table, Eden's standard zero-drift threshold (0.007 turns/s), the per-reading history and sampling numbers, and the three six-axis handles. - **Sensitivity and invert:** Motion sensitivity X/Y and Invert motion X/Y scale the angular velocity's part about the world's up (X, the turn) and the rest (Y, the tilt) before the filter uses it. At 100% and not inverted, the state matches Eden's. The tests now check: - the frame and units - the rotation sum - that a start flat followed by a tilt gives the same orientation as a start in that pose, including a start that's rolled and pitched (Link-22's case) - bias learning: small, large and noisy, plus what shouldn't be learned - the threshold - the sensitivities and inverts - repeated readings and stalls `test_motion.py` passes with gcc and clang. `run_all.py` shows only main's two failures. The code is attributed in `hos_motion.h` (yuzu GPL-2.0-or-later, Eden GPL-3.0-or-later; this project is GPL-3.0). Two things I can't check from here: 1. **The real test is the game against Eden.** @Link-22, could you compare this head with Eden on the T3 Pro: aim feel, starting in different poses, lying back, and 200% / Invert Y? 2. **spluton's earlier note** (#112) said a "naive" full state looked dead. I think that was because of the frame (axes and signs) not matching, since Eden sends a full state and works, but only the game can confirm it.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 20:09:56 +00:00
hckrman101 (Migrated from github.com) left a comment

Went through the new approach against Eden's motion_input.cpp / sdl_driver.cpp. The port looks faithful: the SDL to Switch mapping (accel -X, Z, -Y, gyro X, -Z, Y, G and turns/s), the clamps (7 G, 5 turns/s), the Mahony step (rate swap, kp 0.3 / ki 0.005, the 35/10 accelerometer-only weights, windup at 0.05), the 0.1 s stall skip, ResetOrientation / SetOrientationFromAccelerometer, and GetOrientation's quaternion swap and row signs all match, and the rows land in direction[3][3] in the order Eden's SixAxisSensorState::orientation uses. Rest state and initial quaternion match too. The old tilt_ref / pull / rolled-gate code is gone cleanly, and the bias learning and stillness test carried over. Starting accelerometer-only with the 35x weight is what makes the start pose irrelevant, so that part makes sense to me.

One real problem, inline: Motion sensitivity Y and Invert Y are applied to the rate before the filter, so the gravity correction undoes them once the pad is held still. A 1D run of the same loop (kp 0.3, ki 0.005 per reading, 200 Hz), tilting 0.5 rad and then holding still: at 200% the matrix tilt reaches ~0.95 and then settles back to ~0.54 within about a second. With Invert Y it goes to -0.39 and then swings over to +0.46, the non-inverted side, within 1-2 s. If the game reads the tilt from the matrix (the old partial state suggests it does), a held aim drifts at any Y setting other than 100%, and Invert Y ends up the wrong way round at rest. X is fine since gravity doesn't correct heading. The sensitivity tests only check out.gyro on the moving frame, so they don't catch this.

Also:

  • Not mergeable: the conflict is gpu/vk/vk_menu.cpp. Main reworked Row (help + hint, restart, preset_exempt), and the Motion controls row is now ..., hn_gyro},. The preset-exempt fix needs to become hn_gyro, false, true}, after a rebase. The readme paragraph didn't change on main, so it should merge as is.
  • Link-22's retest was on 775ca01, before this change. @Link-22, once it's rebased, could you run the comparison with Eden on the T3 Pro? Especially 200% and Invert Y with the pad held still on a target for a few seconds, plus the start poses again.

Not approving yet, because of the conflict and the Y sensitivity/invert drift.

Went through the new approach against Eden's `motion_input.cpp` / `sdl_driver.cpp`. The port looks faithful: the SDL to Switch mapping (accel `-X, Z, -Y`, gyro `X, -Z, Y`, G and turns/s), the clamps (7 G, 5 turns/s), the Mahony step (rate swap, kp 0.3 / ki 0.005, the 35/10 accelerometer-only weights, windup at 0.05), the 0.1 s stall skip, `ResetOrientation` / `SetOrientationFromAccelerometer`, and `GetOrientation`'s quaternion swap and row signs all match, and the rows land in `direction[3][3]` in the order Eden's `SixAxisSensorState::orientation` uses. Rest state and initial quaternion match too. The old tilt_ref / pull / rolled-gate code is gone cleanly, and the bias learning and stillness test carried over. Starting accelerometer-only with the 35x weight is what makes the start pose irrelevant, so that part makes sense to me. One real problem, inline: Motion sensitivity Y and Invert Y are applied to the rate *before* the filter, so the gravity correction undoes them once the pad is held still. A 1D run of the same loop (kp 0.3, ki 0.005 per reading, 200 Hz), tilting 0.5 rad and then holding still: at 200% the matrix tilt reaches ~0.95 and then settles back to ~0.54 within about a second. With Invert Y it goes to -0.39 and then swings over to +0.46, the non-inverted side, within 1-2 s. If the game reads the tilt from the matrix (the old partial state suggests it does), a held aim drifts at any Y setting other than 100%, and Invert Y ends up the wrong way round at rest. X is fine since gravity doesn't correct heading. The sensitivity tests only check `out.gyro` on the moving frame, so they don't catch this. Also: - **Not mergeable:** the conflict is `gpu/vk/vk_menu.cpp`. Main reworked `Row` (help + `hint`, `restart`, `preset_exempt`), and the Motion controls row is now `..., hn_gyro},`. The preset-exempt fix needs to become `hn_gyro, false, true},` after a rebase. The readme paragraph didn't change on main, so it should merge as is. - Link-22's retest was on 775ca01, before this change. @Link-22, once it's rebased, could you run the comparison with Eden on the T3 Pro? Especially 200% and Invert Y with the pad held still on a target for a few seconds, plus the start poses again. Not approving yet, because of the conflict and the Y sensitivity/invert drift.
hckrman101 (Migrated from github.com) commented 2026-11-09 20:09:56 +00:00

This scales the rate that goes into Mahony, so the integrated tilt no longer agrees with the accelerometer at any sv != 1. The kp * e + ki * I feedback then pulls the matrix back to the real tilt: at 200% a 0.5 rad tilt peaks near 1.0 and sags back to ~0.5 within a second or so of holding still. With Invert Y it crosses over to +0.5 (not inverted) in 1-2 s. The integral term, summed per reading, speeds this up and makes it depend on the pad's report rate.

One way out: run the filter on the unscaled rate (so it stays Eden's), and build what you hand out from a second quaternion. Integrate that one from the scaled rate plus the real filter's correction term, so it still loses gyro drift but keeps the scaled or inverted movement. Scale out.gyro/rotation as now. It would be good to have a test that tilts 0.5 rad at 200% and with Invert Y, holds 3 s, and checks the matrix's tilt from the start pose stays near 1.0 and -0.5.

This scales the rate that goes into Mahony, so the integrated tilt no longer agrees with the accelerometer at any `sv != 1`. The `kp * e + ki * I` feedback then pulls the matrix back to the real tilt: at 200% a 0.5 rad tilt peaks near 1.0 and sags back to ~0.5 within a second or so of holding still. With Invert Y it crosses over to +0.5 (not inverted) in 1-2 s. The integral term, summed per reading, speeds this up and makes it depend on the pad's report rate. One way out: run the filter on the unscaled rate (so it stays Eden's), and build what you hand out from a second quaternion. Integrate that one from the scaled rate plus the real filter's correction term, so it still loses gyro drift but keeps the scaled or inverted movement. Scale `out.gyro`/`rotation` as now. It would be good to have a test that tilts 0.5 rad at 200% and with Invert Y, holds 3 s, and checks the matrix's tilt from the start pose stays near 1.0 and -0.5.
wyatt-poole commented 2026-11-09 20:15:23 +00:00 (Migrated from github.com)

@hckrman101 thanks for checking the port against Eden. Both points are fixed:

  • Y sensitivity / invert undone by gravity (d68db5b): the filter now runs on the real, unscaled rates, exactly as Eden does. The orientation that's handed out is a second quaternion, integrated from the scaled rates plus the real filter's correction term. It loses the gyro's drift but keeps a scaled or inverted move. At 100% the two quaternions are identical. out.gyro and rotation are scaled as before. New test, as you suggested: 0.5 rad nose up, held 3 s. At 200% it matches the matrix of a real 1.0 rad tilt, and with Invert Y it matches a real -0.5. Both fail on the previous head.
  • Conflict (8f9b8e1, merged main): the Motion controls row keeps main's hn_gyro and is preset-exempt as hn_gyro, false, true}.

run_all.py on Linux: 161 passed. The only failure is nx2/test_nup_read_name.py (needs zstandard). test_output_resolution.py passes again on main.

@Link-22 this head is ready for the comparison with Eden on the T3 Pro: 200% and Invert Y with the pad held still on a target for a few seconds, plus the start poses.

@hckrman101 thanks for checking the port against Eden. Both points are fixed: - **Y sensitivity / invert undone by gravity** (d68db5b): the filter now runs on the real, unscaled rates, exactly as Eden does. The orientation that's handed out is a second quaternion, integrated from the scaled rates plus the real filter's correction term. It loses the gyro's drift but keeps a scaled or inverted move. At 100% the two quaternions are identical. `out.gyro` and `rotation` are scaled as before. New test, as you suggested: 0.5 rad nose up, held 3 s. At 200% it matches the matrix of a real 1.0 rad tilt, and with Invert Y it matches a real -0.5. Both fail on the previous head. - **Conflict** (8f9b8e1, merged main): the Motion controls row keeps main's `hn_gyro` and is preset-exempt as `hn_gyro, false, true}`. `run_all.py` on Linux: 161 passed. The only failure is `nx2/test_nup_read_name.py` (needs `zstandard`). `test_output_resolution.py` passes again on main. @Link-22 this head is ready for the comparison with Eden on the T3 Pro: 200% and Invert Y with the pad held still on a target for a few seconds, plus the start poses.
Link-22 commented 2026-11-09 20:19:44 +00:00 (Migrated from github.com)

@wyatt-poole @hckrman101 a first, short result on the Eden-style approach. I only had time for a quick try of 5e2f904 (before d68db5b), on the T3 Pro, Linux, menu defaults:

  • Holding still: the aim jitters slightly with the pad held still on a target. I checked the same thing in Eden (Lichii build 125, an Eden fork) and it jitters slightly there too, so this looks like the same behaviour as Eden, not a regression.

I haven't run the full comparison yet. I'll build 8f9b8e1 and come back with the rest: start poses, lying back, and 200% / Invert Y held still on a target for a few seconds.

@wyatt-poole @hckrman101 a first, short result on the Eden-style approach. I only had time for a quick try of **5e2f904** (before d68db5b), on the T3 Pro, Linux, menu defaults: - **Holding still:** the aim jitters slightly with the pad held still on a target. I checked the same thing in Eden (Lichii build 125, an Eden fork) and it jitters slightly there too, so this looks like the same behaviour as Eden, not a regression. I haven't run the full comparison yet. I'll build **8f9b8e1** and come back with the rest: start poses, lying back, and 200% / Invert Y held still on a target for a few seconds.
wyatt-poole commented 2026-11-09 20:28:28 +00:00 (Migrated from github.com)

@hckrman101 @Link-22 one more change, in 0b221fc, and the in-game result:

  • Motion page: the five motion rows (Motion controls, Motion sensitivity X/Y, Invert motion X/Y) moved off the Controls tab onto a page of their own. A Motion row just above Controls opens it, the same way the Controls row opens the bindings page. The Controls tab had grown to 14 rows. The page uses the normal row drawing and input (TAB_MOTION, a hidden tab), so the sliders, mouse and wheel all work as before. The tab strip keeps Controls highlighted, Esc or a right click goes back to the Controls tab, and Q/E or the bumpers move on from Controls. The options file's keys are unchanged. The readme table now lists the page.
  • In game: I've tested this head on Windows with an 8BitDo Ultimate 2 in Switch mode (the Eden-style state, d68db5b's Y fix included). The defaults aim right, holding the pad in different poses doesn't matter, and the inverts and both sensitivities behave as intended.

run_all.py on Linux passes apart from nx2/test_nup_read_name.py, which needs zstandard.

@hckrman101 @Link-22 one more change, in 0b221fc, and the in-game result: - **Motion page:** the five motion rows (Motion controls, Motion sensitivity X/Y, Invert motion X/Y) moved off the Controls tab onto a page of their own. A **Motion** row just above **Controls** opens it, the same way the Controls row opens the bindings page. The Controls tab had grown to 14 rows. The page uses the normal row drawing and input (`TAB_MOTION`, a hidden tab), so the sliders, mouse and wheel all work as before. The tab strip keeps Controls highlighted, Esc or a right click goes back to the Controls tab, and Q/E or the bumpers move on from Controls. The options file's keys are unchanged. The readme table now lists the page. - **In game:** I've tested this head on Windows with an 8BitDo Ultimate 2 in Switch mode (the Eden-style state, d68db5b's Y fix included). The defaults aim right, holding the pad in different poses doesn't matter, and the inverts and both sensitivities behave as intended. `run_all.py` on Linux passes apart from `nx2/test_nup_read_name.py`, which needs `zstandard`.
wyatt-poole commented 2026-11-09 20:35:52 +00:00 (Migrated from github.com)

@Link-22 thanks for the quick check, and for comparing against Eden. A note from my side: I got around to testing the Eden-style build on my 8BitDo Ultimate 2 (Switch mode, Windows). I didn't notice any jitter, but I was mostly checking the motion itself and looking for drift, so I didn't spend long holding it dead still on a target. Your report is probably the better data point there.

What I changed for it (latest commit):

  • Motion deadzone: there's a new row on the Motion page with the console's three zero-drift modes:

    • Low: the tight mode, 0.002 turns/s.
    • Standard: 0.007 turns/s. This is the default and what Eden and the console use, so nothing changes unless you pick another setting.
    • High: the loose mode, 0.01 turns/s.

    Any rate under the threshold counts as no movement. If the pad jitters when held still, High should steady it. Low goes the other way and keeps the smallest movements. It's option 23 (HOS_OPT_GYRO_DEADZONE), and there's a test that Low lets a 0.005 turns/s turn through and High drops 0.008.

  • What I ruled out: I also checked whether the accelerometer feeding the gravity correction could cause the jitter. In the simulation, with 10 mG of accelerometer noise per axis and the gyro in use, the matrix moves by about 0.0003 rad, which is far too small to see. So I didn't add any smoothing there and kept Eden's filter as it is. The jitter you saw (and Eden's) is more likely the gyro's own noise, or a hand's tremor, crossing the deadzone threshold now and then. The deadzone is what addresses that.

When you do the full run on 8f9b8e1 or later, it would be great to try High on the target hold too.

@Link-22 thanks for the quick check, and for comparing against Eden. A note from my side: I got around to testing the Eden-style build on my 8BitDo Ultimate 2 (Switch mode, Windows). I didn't notice any jitter, but I was mostly checking the motion itself and looking for drift, so I didn't spend long holding it dead still on a target. Your report is probably the better data point there. What I changed for it (latest commit): - **Motion deadzone:** there's a new row on the Motion page with the console's three zero-drift modes: - **Low:** the tight mode, 0.002 turns/s. - **Standard:** 0.007 turns/s. This is the default and what Eden and the console use, so nothing changes unless you pick another setting. - **High:** the loose mode, 0.01 turns/s. Any rate under the threshold counts as no movement. If the pad jitters when held still, High should steady it. Low goes the other way and keeps the smallest movements. It's option 23 (`HOS_OPT_GYRO_DEADZONE`), and there's a test that Low lets a 0.005 turns/s turn through and High drops 0.008. - **What I ruled out:** I also checked whether the accelerometer feeding the gravity correction could cause the jitter. In the simulation, with 10 mG of accelerometer noise per axis and the gyro in use, the matrix moves by about 0.0003 rad, which is far too small to see. So I didn't add any smoothing there and kept Eden's filter as it is. The jitter you saw (and Eden's) is more likely the gyro's own noise, or a hand's tremor, crossing the deadzone threshold now and then. The deadzone is what addresses that. When you do the full run on 8f9b8e1 or later, it would be great to try High on the target hold too.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 22:45:29 +00:00
hckrman101 (Migrated from github.com) commented 2026-11-09 22:45:29 +00:00

Fixed in d68db5b: the filter runs on the real rates and the handed-out matrix comes from the second quaternion, with a test for the 3 s hold at 200% and Invert Y. Thanks. One follow-up on the second quaternion in the new review.

Fixed in d68db5b: the filter runs on the real rates and the handed-out matrix comes from the second quaternion, with a test for the 3 s hold at 200% and Invert Y. Thanks. One follow-up on the second quaternion in the new review.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 22:45:31 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, the two points from my last review are done: the filter now runs on the real rates and only the handed-out quaternion carries the scaling (d68db5b), and the hn_gyro, false, true} row is right. The Motion page, the deadzone row and the option id also look good: 23 is free on main, HOS_OPT_GYRO_DEADZONE is answered in vk_replay.cpp, and the row is saved with the others. #1270's 27/28 don't collide with it. The deadzone is a good place to take Link-22's jitter report.

Two things are still open:

  1. Conflict with main. The branch is 25 commits behind and gpu/vk/vk_menu.cpp conflicts in three places (checked with a trial merge; every other file merges cleanly): tx_audio_output next to tx_deadzone, the Esc handling (main's if (press[5] && pending) goes first, then your Motion back, then restart_ask()), and the Enter handling (keep main's confirm_selected block, then add your is_motion_row branch after the Cheats one). Heads-up: #1270 also changes the tab count to four, which will hit TAB_MOTION = 3 and tab_selected[TAB_COUNT + 1], so whichever lands second needs to adjust.
  2. The handed-out quaternion and turning while tilted (inline). I think it is a real problem at sens/invert other than 100%, but I haven't run it.

Nit: the comment in vk_api.h above HOS_OPT_DLSS_JITTER_* still says "18 to 22 are the motion rows'"; it is 18 to 23 now. The readme describes the Motion row but has no bullet saying what the deadzone is for.

CI is green on 1ece02e. Nothing blocks except the conflict and point 2.

Thanks, the two points from my last review are done: the filter now runs on the real rates and only the handed-out quaternion carries the scaling (d68db5b), and the `hn_gyro, false, true}` row is right. The Motion page, the deadzone row and the option id also look good: 23 is free on main, `HOS_OPT_GYRO_DEADZONE` is answered in vk_replay.cpp, and the row is saved with the others. #1270's 27/28 don't collide with it. The deadzone is a good place to take Link-22's jitter report. Two things are still open: 1. **Conflict with main.** The branch is 25 commits behind and `gpu/vk/vk_menu.cpp` conflicts in three places (checked with a trial merge; every other file merges cleanly): `tx_audio_output` next to `tx_deadzone`, the Esc handling (main's `if (press[5] && pending)` goes first, then your Motion back, then `restart_ask()`), and the Enter handling (keep main's `confirm_selected` block, then add your `is_motion_row` branch after the Cheats one). Heads-up: #1270 also changes the tab count to four, which will hit `TAB_MOTION = 3` and `tab_selected[TAB_COUNT + 1]`, so whichever lands second needs to adjust. 2. **The handed-out quaternion and turning while tilted** (inline). I think it is a real problem at sens/invert other than 100%, but I haven't run it. Nit: the comment in vk_api.h above `HOS_OPT_DLSS_JITTER_*` still says "18 to 22 are the motion rows'"; it is 18 to 23 now. The readme describes the Motion row but has no bullet saying what the deadzone is for. CI is green on 1ece02e. Nothing blocks except the conflict and point 2.
@ -49,7 +49,9 @@ enum MenuTab {
TAB_GRAPHICS = 0,
hckrman101 (Migrated from github.com) commented 2026-11-09 22:45:31 +00:00

Nit: TAB_MOTION = 3 equals TAB_COUNT, which is what makes tab_selected[TAB_COUNT + 1] and the t < TAB_COUNT loops work. #1270 makes it four tabs, and then they would be the same index as its fourth tab. Defining it as TAB_MOTION = TAB_COUNT (after the enum) would keep it one past the strip.

Nit: `TAB_MOTION = 3` equals `TAB_COUNT`, which is what makes `tab_selected[TAB_COUNT + 1]` and the `t < TAB_COUNT` loops work. #1270 makes it four tabs, and then they would be the same index as its fourth tab. Defining it as `TAB_MOTION = TAB_COUNT` (after the enum) would keep it one past the strip.
hckrman101 (Migrated from github.com) commented 2026-11-09 22:45:31 +00:00

rs is the scaled rate in the pad's own frame, where the split into turn and tilt uses the real up. quat_out is not at the real pose once sv != 1 (0.5 rad real tilt is 1.0 at 200%, or -0.5 inverted), so a pure turn about the real up is applied to quat_out as a rotation about an axis that is off its vertical by that extra tilt. By my reading, turning left or right while holding a tilted aim at 200% (or Invert Y) makes the vertical aim bob by up to about that offset, and nothing pulls it back, since c is zero while the real filter agrees with gravity. At 100% the two are equal, so the current tests and Link-22's hold-still runs wouldn't show it.

One way: scale in the world frame. Take the rate in the world frame with the real orientation (w = R_real * g), split it into the vertical part (turn, sh) and the horizontal part (tilt, sv), and apply that to quat_out by left-multiplying (a world-frame rotation), plus c as now. A test would be: tilt 0.5 rad at 200%, then turn 90 degrees about the vertical at the same hold, and check the matrix's tilt from the start pose stays at 1.0 (and -0.5 with Invert Y).

`rs` is the scaled rate in the pad's own frame, where the split into turn and tilt uses the *real* `up`. `quat_out` is not at the real pose once sv != 1 (0.5 rad real tilt is 1.0 at 200%, or -0.5 inverted), so a pure turn about the real up is applied to `quat_out` as a rotation about an axis that is off its vertical by that extra tilt. By my reading, turning left or right while holding a tilted aim at 200% (or Invert Y) makes the vertical aim bob by up to about that offset, and nothing pulls it back, since `c` is zero while the real filter agrees with gravity. At 100% the two are equal, so the current tests and Link-22's hold-still runs wouldn't show it. One way: scale in the world frame. Take the rate in the world frame with the real orientation (`w = R_real * g`), split it into the vertical part (turn, sh) and the horizontal part (tilt, sv), and apply that to `quat_out` by left-multiplying (a world-frame rotation), plus `c` as now. A test would be: tilt 0.5 rad at 200%, then turn 90 degrees about the vertical at the same hold, and check the matrix's tilt from the start pose stays at 1.0 (and -0.5 with Invert Y).
wyatt-poole commented 2026-11-10 00:15:26 +00:00 (Migrated from github.com)

@hckrman101 thanks, all three are fixed:

  • World-frame scaling (11d915c): this works as you suggested. The real rate goes to the world through the real orientation. There, the vertical part (the turn) is scaled by sh and the rest (the tilt) by sv. It then comes back to the pad through quat_out, so a scaled turn while tilted turns quat_out about its own vertical. At 100%, quat and quat_out stay identical. Your test is in: 0.5 rad nose up at 200%, then a quarter turn about the vertical at 200%. The world's up seen from the pad (the third column) still matches a real 1.0 rad tilt, with a dot product of 1 within 2e-3. Before the fix it was 0.54.
  • Tab index (cf9c774): TAB_MOTION = TAB_COUNT, and tab_selected is sized TAB_MOTION + 1, so a tab added before TAB_COUNT (#1270) moves the Motion page along with it.
  • Conflict with main (cf9c774, main at bbdc370): tx_deadzone and main's tx_audio_output are both kept. On the Motion page, Esc goes back to Controls before it touches main's pending Restart/Quit confirm. The Motion row opens its page right after the Cheats row's test in main's confirm chain.
  • vk_api.h: the motion options' comments now cover all six (18 to 23), including the deadzone.

run_all.py on Linux: 165 passed. The only failure is nx2/test_nup_read_name.py, which needs zstandard. test_motion.py passes with gcc and clang.

@hckrman101 thanks, all three are fixed: - **World-frame scaling** (11d915c): this works as you suggested. The real rate goes to the world through the real orientation. There, the vertical part (the turn) is scaled by `sh` and the rest (the tilt) by `sv`. It then comes back to the pad through `quat_out`, so a scaled turn while tilted turns `quat_out` about its own vertical. At 100%, `quat` and `quat_out` stay identical. Your test is in: 0.5 rad nose up at 200%, then a quarter turn about the vertical at 200%. The world's up seen from the pad (the third column) still matches a real 1.0 rad tilt, with a dot product of 1 within 2e-3. Before the fix it was 0.54. - **Tab index** (cf9c774): `TAB_MOTION = TAB_COUNT`, and `tab_selected` is sized `TAB_MOTION + 1`, so a tab added before `TAB_COUNT` (#1270) moves the Motion page along with it. - **Conflict with main** (cf9c774, main at bbdc370): `tx_deadzone` and main's `tx_audio_output` are both kept. On the Motion page, Esc goes back to Controls before it touches main's pending Restart/Quit confirm. The Motion row opens its page right after the Cheats row's test in main's confirm chain. - **vk_api.h:** the motion options' comments now cover all six (18 to 23), including the deadzone. `run_all.py` on Linux: 165 passed. The only failure is `nx2/test_nup_read_name.py`, which needs `zstandard`. `test_motion.py` passes with gcc and clang.
hckrman101 (Migrated from github.com) reviewed 2026-11-10 03:38:20 +00:00
hckrman101 (Migrated from github.com) commented 2026-11-10 03:38:20 +00:00

Fixed in 11d915c. I read the new block: dr and dq are both world to pad, so dr^T g is the real rate in the world, the vertical part gets sh, the horizontal part gets sv, and dq * w takes it into the handed-out pad frame, so a scaled turn goes about quat_out's own vertical. At 100% it reduces to g. Thanks.

Fixed in 11d915c. I read the new block: `dr` and `dq` are both world to pad, so `dr^T g` is the real rate in the world, the vertical part gets `sh`, the horizontal part gets `sv`, and `dq * w` takes it into the handed-out pad frame, so a scaled turn goes about `quat_out`'s own vertical. At 100% it reduces to `g`. Thanks.
hckrman101 (Migrated from github.com) reviewed 2026-11-10 03:38:22 +00:00
@ -49,7 +49,9 @@ enum MenuTab {
TAB_GRAPHICS = 0,
hckrman101 (Migrated from github.com) commented 2026-11-10 03:38:22 +00:00

Fixed in cf9c774, TAB_MOTION = TAB_COUNT with tab_selected[TAB_MOTION + 1]. Thanks.

Fixed in cf9c774, `TAB_MOTION = TAB_COUNT` with `tab_selected[TAB_MOTION + 1]`. Thanks.
hckrman101 (Migrated from github.com) approved these changes 2026-11-10 03:38:38 +00:00
hckrman101 (Migrated from github.com) left a comment

Re-checked 1ad8124. Everything from my last review is fixed:

  • Turning while tilted: the world-frame scaling in 11d915c is right. dr and dq both map world to pad, so dr^T g is the real rate in the world, the vertical part gets sh, the rest gets sv, and dq * w hands it to quat_out. At 100% it reduces to g, so nothing changes by default. The new test (0.5 rad at 200%, then a quarter turn about the vertical) checks the third column against a real 1.0 rad tilt.
  • Conflict: mergeable_state is clean now, and main's Restart/Quit confirm comes before the Motion back handling as asked.
  • Tab index and vk_api.h comment: both done.
  • Option 23: no collision. HOS_OPT_GYRO_DEADZONE is answered in vk_replay.cpp, and nothing else on main uses 23 (main's stick dead zone is 11).

CI is green on every job, Windows included, and test_motion.py is covered. One nit, not blocking: the new test only runs 200% and not Invert Y (the -0.5 case I mentioned), though the code path is the same sign flip on sv and sh. Good to merge from my side.

Re-checked 1ad8124. Everything from my last review is fixed: - **Turning while tilted:** the world-frame scaling in 11d915c is right. `dr` and `dq` both map world to pad, so `dr^T g` is the real rate in the world, the vertical part gets `sh`, the rest gets `sv`, and `dq * w` hands it to `quat_out`. At 100% it reduces to `g`, so nothing changes by default. The new test (0.5 rad at 200%, then a quarter turn about the vertical) checks the third column against a real 1.0 rad tilt. - **Conflict:** `mergeable_state` is clean now, and main's Restart/Quit confirm comes before the Motion back handling as asked. - **Tab index and vk_api.h comment:** both done. - **Option 23:** no collision. `HOS_OPT_GYRO_DEADZONE` is answered in vk_replay.cpp, and nothing else on main uses 23 (main's stick dead zone is 11). CI is green on every job, Windows included, and `test_motion.py` is covered. One nit, not blocking: the new test only runs 200% and not Invert Y (the -0.5 case I mentioned), though the code path is the same sign flip on `sv` and `sh`. Good to merge from my side.
NotRexed commented 2026-11-10 03:49:29 +00:00 (Migrated from github.com)

This landed in the v2.0.12 branch (merged there for testing; it reaches main with that release), so closing. Thanks!

This landed in the v2.0.12 branch (merged there for testing; it reaches `main` with that release), so closing. Thanks!
NotRexed commented 2026-11-10 03:54:12 +00:00 (Migrated from github.com)

Reopened: closing this was a mistake on my side. It was tested in the v2.0.12 branch, and it will be merged into main here, so it shows as merged. Sorry for the noise.

Reopened: closing this was a mistake on my side. It was tested in the v2.0.12 branch, and it will be merged into main here, so it shows as merged. Sorry for the noise.
Commenting is not possible because the repository is archived.
No description provided.