Skip to content

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

Open
wyatt-poole wants to merge 12 commits into
NotRexed:mainfrom
wyatt-poole:hid/motion-followups
Open

wyatt-poole wants to merge 12 commits into
NotRexed:mainfrom
wyatt-poole:hid/motion-followups

Conversation

@wyatt-poole

Copy link
Copy Markdown
Contributor

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

…ampling)

- The tilt pull follows the tilt unwrapped since its anchor and scales
  that, so at a sensitivity that is not a whole number the target no
  longer jumps by about pi once the pad turns past half a turn.
- Changing Motion sensitivity Y or Invert motion Y re-anchors the pull:
  the aim stays where it is rather than sliding to the new scale with
  the pad held still.
- Stillness reads the accelerometer's direction as well as its size: a
  pad turning at a steady rate is no longer still, so the turn is not
  learned as bias. The gyro threshold is 0.015 rad/s over the three
  axes (about 0.009 on each), room for noisier third-party sensors, and
  a pad that has not read still for 30 s says so in the log once.
- The delta time handed to the game is capped at 100 ms, as the filter
  caps its own: a stalled pad's next reading is no jump.
- Sampling numbers move a whole history on at each switch between live
  and at rest, so no two entries ever share one.
- Tests: a sensitivity and an invert change while held tilted, a
  half-turn and more at 50%, a 0.0067 rad/s noisy sensor learning its
  bias on a table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread host/hos_motion.h Outdated
m->avec[i] += d * k;
av += d * d;
}
m->avar += (av - m->avar) * k;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread host/pad_sdl.c
rx_option(HOS_OPT_GYRO_SENS_Y, 100) / 100.0f, rx_option(HOS_OPT_GYRO_INV_X, 0),
rx_option(HOS_OPT_GYRO_INV_Y, 0)) && !fresh)
return;
if (!motion_warned && motion.learned_t <= 0 && now - motion_start_ns > 30000000000LL) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reads much better, thanks.

@Link-22

Link-22 commented Nov 9, 2026

Copy link
Copy Markdown

@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.

…xed#1237

- The vertical aim is absolute: it starts at the pad's tilt from flat
  (at the nose's angle above level while rolled), and the tilt pull's
  anchor is that tilt itself, so how the pad is held when the filter
  starts no longer offsets the aim for good (Link-22: a start held
  rolled and pitched aimed about 0.9 rad off). A new sensitivity or
  invert rescales the aim to match the tilt at the new scale.
- HOS_MOTION_STILL_ACCEL is 0.0173 G summed over the three axes (0.01 G
  on each), like the gyro's, so the accelerometer side is no stricter
  than before; the noisy-sensor test adds 5 mG of accelerometer noise.
- The stillness comment says what the accelerometer's vector catches
  (a steady pitch or roll past about 5 degrees a second, not a turn
  about gravity), with a test for a steady, noiseless pitch.
- The 30 s log line leads with setting the pad down.
- Motion controls' row is preset-exempt like the four under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
@wyatt-poole

wyatt-poole commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor Author

@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 Motion controls: gyro aiming from SDL pads, with sensitivity and inverts #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 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread host/hos_motion.h Outdated
m->pitch = hos_motion_wrap(sv * (fabsf(up[0]) < HOS_MOTION_TILT_ROLL ? atan2f(-up[2], up[1]) : asinf(e)));
m->tilt_sv = sv;
} else if (sv != m->tilt_sv && m->tilt_sv != 0) {
m->pitch = hos_motion_wrap(m->tilt_ready ? m->tilt_acc * sv : m->pitch / m->tilt_sv * sv);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread readme.md Outdated
@@ -273,7 +273,8 @@ and the right one closes the menu. Your choices are saved in `hookshot_options.i
- **Motion controls:** the controller's gyroscope aims where the game uses motion, as on the Switch. It works with
Switch Pro controllers and pads in Switch mode, DualSense and DualShock 4 (through SDL), held flat or upright. Its row

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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).

…its angle

The vertical aim is the pad's real tilt from flat, as a Switch reports
it. Motion sensitivity Y and Invert motion Y now scale only the tilt's
change from where the filter started (tilt_ref): at rest in that hold
the aim is the real tilt at any setting, and with the defaults it is
the real tilt everywhere. Scaling the whole angle from flat moved a
normal 0.7 rad hold to about vertical at 200% and mirrored it with the
invert (hckrman101). Test: a 0.7 rad hold at 200% and inverted.

The readme and the header no longer say the vertical aim works held
upright: the turn does, the tilt is from flat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
@wyatt-poole

Copy link
Copy Markdown
Contributor Author

@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 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread host/hos_motion.h Outdated
setting rescales the change. With the default (sv = -1) it is the real tilt everywhere. */
if (started) {
const float e = -up[2] > 1 ? 1 : -up[2] < -1 ? -1 : -up[2];
m->tilt_ref = fabsf(up[0]) < HOS_MOTION_TILT_ROLL ? atan2f(-up[2], up[1]) : asinf(e);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread host/hos_motion.h Outdated
if (!m->tilt_ready) m->tilt0 = hos_motion_wrap(tilt - m->pitch / sv), m->tilt_ready = 1;
const float target = hos_motion_wrap(hos_motion_wrap(tilt - m->tilt0) * sv);
if (!m->tilt_ready) { /* (the tilt from flat itself: what the aim is measured from) */
m->tilt_acc = tilt, m->tilt_ready = 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

tilt_ref (the hold Motion sensitivity Y and Invert motion Y scale the
tilt's change from) was taken only when the filter started, so a start
with the pad lying on the desk left them measured from flat: lifted to
a 0.7 rad hold, the aim rested at -1.4 at 200% and mirrored with the
invert (hckrman101). A pad that was set down (still on a table) and is
then held steady in the hand (the gyro's spread a hand's, not turning)
for 0.5 s has that hold eased in as tilt_ref over about 0.3 s. At the
defaults this moves nothing.

Also: the pull's tilt_acc starts on tilt_ref's branch (near face-down
the wrapped atan2 could be a turn away), and the stillness window
starts from a small variance, so a pad set down is still in about
1.3 s instead of 2.2 s. Test: a flat start lifted to 0.7 rad at 200%
and with the invert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
@wyatt-poole

Copy link
Copy Markdown
Contributor Author

@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

Link-22 commented Nov 9, 2026

Copy link
Copy Markdown

@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.

Replaces the partial state (a yaw rate in the y slot and a tilt matrix,
the rest neutral) and the cases it needed for how the pad is held when
motion starts. The game now gets what Eden, the emulator it aims right
with, gives it: acceleration (G) and angular velocity (turns a second)
in the Switch's frame, the rotation summed, and the orientation matrix
from Mahony's filter, following Eden's motion_input.cpp and
sdl_driver.cpp (yuzu GPL-2.0-or-later, Eden GPL-3.0-or-later). The game
does its own aiming and recentering, so the start pose does not matter.

Kept: the bias learned on a table (stillness of both sensors), the
console's 0.007 turns/s zero-drift threshold (Eden's standard), the
per-reading history and sampling numbers, three six-axis handles, and
Motion sensitivity X/Y and Invert motion X/Y, which scale the angular
velocity's part about the world's up (turn) and the rest (tilt) before
it is used; at 100% the state is Eden's.

Tests: the frame and units, the rotation sum, the same orientation for
a start flat-then-tilted and a start in that pose (also rolled and
pitched), bias learning (small, large, noisy) and what is not learned,
the threshold, the sensitivities and inverts, repeats and stalls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
@wyatt-poole wyatt-poole changed the title Motion controls: follow-ups to #1114 (tilt pull, stillness, sampling) Motion controls: the full six-axis state as Eden builds it (follow-ups to #1114) Nov 9, 2026
@wyatt-poole

Copy link
Copy Markdown
Contributor Author

@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 (Support for gyro aiming? #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 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread host/hos_motion.h Outdated
{
const float sh = sens_h * (inv_h ? -1.0f : 1.0f), sv = sens_v * (inv_v ? -1.0f : 1.0f);
const float t = g[0] * m->up[0] + g[1] * m->up[1] + g[2] * m->up[2];
for (int i = 0; i < 3; i++) g[i] = sh * t * m->up[i] + sv * (g[i] - t * m->up[i]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

wyatt-poole and others added 2 commits November 9, 2026 15:14
…ntation

Scaling the rate before Mahony's filter let its gravity correction undo
Motion sensitivity Y and Invert motion Y once the pad was held still: at
200% a 0.5 rad tilt sagged back to 0.5, and inverted it swung over to
the non-inverted side (hckrman101). The filter now runs on the real
rates (Eden's own), and the orientation handed out is a second
quaternion integrated from the scaled rates plus the real filter's
correction term, so it loses the gyro's drift but keeps a scaled or
inverted move. At 100% the two are the same. Test: 0.5 rad at 200% held
3 s matches a real 1.0 rad tilt, and inverted a real -0.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
Conflict in gpu/vk/vk_menu.cpp: main reworked Row (help, hint, restart,
preset_exempt); the Motion controls row keeps main's hn_gyro and is
preset-exempt (hn_gyro, false, true).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
@wyatt-poole

Copy link
Copy Markdown
Contributor Author

@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

Link-22 commented Nov 9, 2026

Copy link
Copy Markdown

@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.

…s tab

The five motion rows (Motion controls, Motion sensitivity X and Y,
Invert motion X and Y) move off the Controls tab onto a page of their
own, opened by a Motion row just above Controls, the way the Controls
row opens the bindings page. The page is drawn and driven like a tab
(TAB_MOTION, not on the strip): the tab strip shows Controls, 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
@wyatt-poole

Copy link
Copy Markdown
Contributor Author

@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.

…t modes)

The rate under which the pad counts as still was fixed at Eden's
standard 0.007 turns a second. The Motion page now has a Motion deadzone
row with the console's three zero-drift modes: Low (tight, 0.002),
Standard (0.007, the default and the console's own) and High (loose,
0.01), for a pad that jitters when held still on a target (Link-22) or,
the other way, to keep the smallest movements. Option 23
(HOS_OPT_GYRO_DEADZONE); the filter takes it as HosMotion.threshold.
Test: Low lets a 0.005 turns/s turn through, High drops 0.008.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
@wyatt-poole

Copy link
Copy Markdown
Contributor Author

@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 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread host/hos_motion.h
} else {
hos_motion_rate(m, gs, rs);
for (int i = 0; i < 3; i++) rs[i] += c[i];
hos_motion_integrate(m->quat_out, rs, dt);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread gpu/vk/vk_menu.cpp Outdated
TAB_CONTROLS = 2,
TAB_COUNT = 3
TAB_COUNT = 3,
TAB_MOTION = 3 // (hookshot: not a tab of the strip, the Motion page that the Controls tab's Motion row opens)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

wyatt-poole and others added 2 commits November 9, 2026 19:15
Conflicts in gpu/vk/vk_menu.cpp with main's Audio output row (NotRexed#1240) and
the Restart/Quit confirm (NotRexed#1266, NotRexed#1255): tx_deadzone and tx_audio_output
both kept; Esc on the Motion page goes back before the pending confirm
is cancelled; the Motion row opens its page after the Cheats row's test
in main's confirm chain. TAB_MOTION is now TAB_COUNT itself (one past
the strip), and tab_selected is sized by it, so a tab added before
TAB_COUNT (NotRexed#1270) moves the Motion page along instead of colliding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
…ments

The handed-out orientation took the rate split by the accelerometer's
up in the pad's frame, so at a sensitivity or invert other than 100%
a turn while tilted turned quat_out about an axis off its own vertical
and tipped it (hckrman101). The real rate is now taken to the world by
the real orientation, its vertical part scaled by sh and the rest by
sv, and brought back to the pad by quat_out, so quat_out turns about
its own vertical. At 100% the two are still the same. Test: 0.5 rad
nose up at 200%, then a quarter turn at 200%: the world's up seen from
the pad still matches a real 1.0 rad tilt (0.54 before).

vk_api.h: the motion options' comments name all six (18 to 23).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
@wyatt-poole

Copy link
Copy Markdown
Contributor Author

@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 (feat: Audio tab in the Insert menu with Volume and Mute in background #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.

Conflicts with NotRexed#1298 (stick dead zone row) and the wider Motion
sensitivity X (10% to 600%) on main: tx_deadzone and tx_stick_dead_zone
both kept; the motion rows stay on the Motion page with X's new 600%
top; the readme's Motion row lists X's range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants