Repository navigation
Motion controls: gyro aiming from SDL pads, with sensitivity and inverts - #1114
Conversation
…sensor
The game starts the six-axis sensor and reads GetSixAxisSensorStates, but nn_hid.c always answered with a pad lying
still, so gyro aiming did nothing. With the new Controls row Motion controls on (the default), the first SDL pad with
a gyroscope and an accelerometer (Switch Pro and pads in Switch mode, DualSense, DualShock 4) has them read on the pad
thread every poll and turned into the Switch's state:
- host/hos_motion.h (header-only, no SDL or Windows): SDL's gamepad axes to the Switch's (the change of axes the yuzu
family makes), m/s^2 to G and rad/s to revolutions a second, the rotation summed, and the orientation as a
quaternion with a gentle pull toward gravity (Mahony, proportional term). The gyroscope's resting bias is learned
while the pad lies still and taken off, so aiming does not creep. HOS_GYRO_FLIP=<axes> reverses an axis for a pad
whose driver reports one the other way.
- host/hos_sdl.c: SDL3/SDL2 HasSensor, SetSensorEnabled and GetSensorData, looked up optionally (an SDL without them
still plays, with no gyro).
- host/pad_sdl.c: sensors on while the row is on and off otherwise; hos_sdl_motion for nn_hid.c and
hos_sdl_gyro_status for the menu.
- host/nn_hid.c: GetSixAxisSensorStates fills the states from it; the sampling number grows only with new readings.
- Insert menu: the row shows whether the connected pad has a gyroscope ("On (gyro found)", "no gyro on this pad", "no
controller"); either choice can be made whatever is connected. It is a control, so it does not reset the quality
preset to Custom. MkwVkHost gets gyro_status (API version 8); rx_option 18 is the row.
Tested: tests/test_motion.py (new, game-free: axes and units, a quarter turn at 1 rev/s, bias learning, the tilt
filter settling on the accelerometer's gravity, HOS_GYRO_FLIP, the stall clamp) with gcc and clang; the game-free
suite passes (120 passed, 15 skipped for tools). The four host files compile with mingw-w64 against the Windows
headers. SDL 3.4.20 (the bundled SDL3.dll) reads an 8BitDo Ultimate 2 in Switch mode as a Nintendo Switch Pro
Controller with both sensors: face up 10.1 m/s^2 on +Y, as the axis change assumes, and a resting gyro bias of about
0.8 deg/s. Not yet tried in game.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
|
Tested this PR (d43ea5c) on Linux (CachyOS, RTX 4070 Super) with a GameSir T3 Pro in Switch Pro Controller mode, over SDL. The PR's tree was built on top of the v2.0.7 AppImage. Result: the gyro is detected and the aim moves, but left/right is inverted and much too fast. This is the same behaviour I saw with my first port of spluton's patch. From what I found while fixing my port, OoT doesn't seem to read the six-axis state like a plain sensor:
The relevant code is in |
|
Thanks a lot for testing, and for the detailed write-up. You're right: this PR delivered the six-axis state as a plain sensor, which isn't how OoT reads it. That also explains what I saw on my 8BitDo Ultimate 2 (Switch mode): roll moved the aim left/right because it landed in the y slot, and up/down came out mirrored. I'm reworking it to use the approach @spluton found and you corrected in #112, with credit to both of you in the code and the commit:
It keeps this PR's SDL2/SDL3 plumbing, the Motion controls row (with its gyro found / no gyro status) and the game-free test, and adds Motion sensitivity (10 to 300%) plus Invert motion X / Invert motion Y toggles in the Controls tab, in place of I'm testing it locally on rc3 with my 8BitDo first, then I'll push it here and ping you. A retest on the T3 Pro would be great. |
…dings), sensitivity and inverts The first version delivered the six-axis state as a plain sensor. OoT does not read it that way: Link-22 tested it on a GameSir T3 Pro in Switch Pro mode and left/right came out inverted and much too fast, and on an 8BitDo Ultimate 2 in Switch mode roll moved the aim left/right and up/down was mirrored. What the game aims with was found by spluton in issue 112 (tested on a DualSense) and corrected by Link-22 (the yaw rate's unit; the same feel as Eden on the T3 Pro): - the horizontal aim is the yaw rate, in the angular velocity's y slot, in turns a second; - the vertical aim is the direction matrix as a pure tilt, Rx(pitch), integrated from the pitch rate on its own; - the angles, the pitch rate and the acceleration go out neutral (zero, zero, at rest), since the game folds them into an aim recenter that cancels the tilt otherwise; - GetSixAxisSensorHandles returns 3: the game runs its motion path only with a third source. host/hos_motion.h now computes that state: an up vector from a complementary filter (the gyro's prediction pulled toward gravity while the accelerometer reads 0.65 to 1.35 G), the gyro projected onto right/up/forward for the pitch, yaw and roll rates about the world's axes (so the pad works held flat or upright), and the resting gyro bias learned while still. The SDL plumbing, the Motion controls row and its gyro status stay. New Controls rows: Motion sensitivity (10% to 300%, 100% the console's), Invert motion X and Invert motion Y, in place of HOS_GYRO_FLIP. HOS_MOTION_DEBUG=1 logs what the game gets twice a second. tests/test_motion.py: the bias, a left turn at one turn a second in the yaw slot, a front-up tilt as -0.5 rad in the matrix, the same turn held upright, sensitivity and the inverts, the up vector settling on gravity, the stall clamp, and the neutral fields throughout. Co-Authored-By: spluton <spluton@mail.ru> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
Motion sensitivity X scales the horizontal aim (yaw) and Motion sensitivity Y the vertical (pitch), each 10 to 300 percent, 100 by default, so a controller whose axes feel uneven can be evened out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
- GetSixAxisSensorHandles writes only the handles asked for and still reports three, so a two-handle array is never written past; logs the count asked for once. - The filter integrates the real time between readings: a repeat of the last reading is no reading (its time goes into the next), and a long gap is capped at 100 ms rather than replaced with 4 ms. - The bias is learned only while the pad is still: the gyro's and the accelerometer's spread over a short window both under a sensor's noise, about 1 G, for half a second. A slow, steady turn in the hand is no longer learned away, and an offset of any size is learned. - The vertical aim is pulled gently (1 s) toward the tilt gravity measures, scaled like the aim, so leftover bias cannot drift it. - GetSixAxisSensorStates hands out one entry per reading: the sampling number counts readings, each entry has its own state and the time since the reading before (the last 32 kept). - Motion follows the pad used last; a pad reconnecting into a slot gets a fresh filter; a sensor enable that fails turns both off and is retried every 2 s. - HOS_MOTION_DEBUG is read once; the filter runs outside state_lock, which is taken only to publish the reading. - The motion options are HOS_OPT_GYRO* in vk_api.h on both sides. - Menu rows have a scale flag (slider, clamped), and the Controls tab as a whole stays out of the quality preset. - tests/motion_test.c simulates the pad (gravity turns with the true rotation, sensor noise or a hand's tremor) and covers the above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
|
@hckrman101 thanks for such a careful review. All ten are addressed in 66828d2:
|
|
Retested 66828d2 on Linux (CachyOS, RTX 4070 Super) with the GameSir T3 Pro in Switch Pro Controller mode, over SDL. The PR's tree was built on top of the v2.0.7 AppImage. Menu defaults: Motion controls on ("On (gyro found)"), both sensitivities at 100%, both inverts off. Everything works:
Great work @wyatt-poole, thanks for picking this up. From my side it's ready to merge. |
- The vertical aim's pull toward the measured tilt runs only while the pad's x is about level (|up[0]| < 0.25, about 15 degrees of roll). Rolled, the tilt about the pad's own x moves faster than the pitch about the levelled x, and the pull dragged each vertical move past where the pad went (hckrman101). New test: rolled 30 degrees, a tilt up holds. - A pad keeps its gyroscope's learned bias while another pad has motion (or Motion controls is off), and gets it back with the filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
|
@hckrman101 thanks for the approval and the follow-ups. Two of them are in 8a5c178:
@Link-22 when you have a moment, a retest on the T3 Pro would be the last thing before this leaves draft. In particular, does the yaw speed match your patch, and does it stay the same between sensors-only and mouse-wiggling polls? |
|
@wyatt-poole quick answer on 8a5c178, same setup as before (T3 Pro, Linux, defaults):
One thing I hit in game, which may be the late-anchor case @hckrman101 described. If I turn Motion controls on while the pad isn't held level, the gyro aim comes out wrong afterwards: it feels inverted. Turning it off and on again with the pad lying flat on the desk fixes it. So it seems to depend on how the pad is held at the moment the filter starts. I'll do a full retest on the T3 Pro once the anchor fix and the merge with main are in, and check this case again. |
Conflicts with NotRexed#937 (microphone) and the Rumble strength row: - vk_api.h: MKWVK_API_VERSION 9 (main and this branch each took 8 with different layouts); gyro_status after the mic_* pointers, and rx_backend.c's host table in the same order. - vk_menu.cpp: main's preset_exempt and stops_at_ends instead of this branch's scale flag; the motion rows are preset-exempt, the two sensitivities sliders that stop at their ends. - vk_replay.cpp: option 9 (rumble) and 18 to 22 (motion) side by side. - pad_sdl.c: rumble_strength and motion_poll both kept. - tests/test_rumble_menu.py reads every HOS_OPT_* enum for the option ids, not only the cheats'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
The pull's level (tilt0) was taken as pitch 0 on the first reading that passed the roll gate. With the gate at 15 degrees, a filter that starts rolled (at start, when Motion controls is turned on, on a pad switch or reconnect) integrates pitch before the pad first comes level, and the pull then dragged the aim back by that much and kept the level off (hckrman101; Link-22 saw it as a wrong aim after turning Motion controls on with the pad not level). tilt0 is now anchored so the target equals the current pitch at that moment. Test: started rolled 30 degrees, aimed up, rolled back to level; the aim holds (without the fix it slid from -0.30 to 0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
|
@hckrman101 @Link-22 both points are in, and the branch is merged with main:
|
|
@wyatt-poole @hckrman101 retested 76fc99b (merged with main, built on the v2.0.10 AppImage), same setup: T3 Pro in Switch Pro mode, Linux, menu defaults. Working well:
Still there: the vertical aim depends on how the pad is held when the filter starts. It happens at game start, and when I turn Motion controls off and on while holding the pad tilted. The aim is then off and feels almost inverted. Toggling it off and on with the pad flat on the desk fixes it every time. I ran it with
So with the same hold, start 3 gives a vertical aim about 0.9 rad away from starts 2 and 4. That's roughly the pitch the pad had when the filter started. It looks like A guess at a fix: start |
…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
The game starts the six-axis sensor and reads
GetSixAxisSensorStates, butnn_hid.calways answered with a pad lying still, so gyro aiming did nothing. This reads the controller's gyroscope and accelerometer through SDL and gives the game the state it actually aims with.The game-specific part is the approach spluton found in #112 (tested on a DualSense) and Link-22 corrected (the yaw rate's unit; tested on a GameSir T3 Pro in Switch Pro mode, same feel as Eden). Thanks to both. The first version of this PR sent a plain sensor state, which OoT does not read that way (see the review comment below).
What changed
host/hos_motion.h(new, header-only, no SDL or Windows):host/nn_hid.c:GetSixAxisSensorStatesfills its states from that;GetSixAxisSensorHandlesreturns 3 (the game runs its motion path only with a third source).host/hos_sdl.c,host/pad_sdl.c: SDL3/SDL2HasSensor,SetSensorEnabled,GetSensorData, looked up optionally (an SDL without them still plays, without gyro). The first pad with both sensors is read on the pad thread every poll; sensors are on only while the option is on.On (gyro found),On (no gyro on this pad),On (no controller);These are controls, so changing them does not reset the quality preset.
MkwVkHostgetsgyro_status(API version 8);rx_option18 to 21 are the rows.HOS_MOTION_DEBUG=1logs what the game gets twice a second.On by default because the game expects motion on Switch; happy to flip the default if you prefer it off.
Testing
tests/test_motion.py(game-free, added totests/run_all.py): bias learning, a left turn at 1 turn/s in the yaw slot, a front-up tilt as -0.5 rad in the matrix, the same turn with the pad held upright, each sensitivity scaling only its own axis, the inverts, the up vector settling on gravity, the stall clamp, and the neutral fields throughout. Passes with gcc and clang.SDL3.dll) reads an 8BitDo Ultimate 2 in Switch mode as "Nintendo Switch Pro Controller" with both sensors.🤖 Generated with Claude Code
https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW