Motion controls: gyro aiming from SDL pads, with sensitivity and inverts #1114

Merged
wyatt-poole merged 7 commits from hid/motion-controls into main 2026-11-09 18:20:59 +00:00
wyatt-poole commented 2026-11-09 12:13:48 +00:00 (Migrated from github.com)

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. 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):
    • the horizontal aim is the yaw rate, in the angular velocity's y slot, in turns per 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, (0, 0, -1)), because the game folds them into an aim recenter;
    • the rates are measured about the world's axes from an up vector (complementary filter: the gyro's prediction pulled toward gravity while the accelerometer reads 0.65 to 1.35 G), so the pad works held flat or upright;
    • the gyroscope's resting bias is learned while the pad is still.
  • host/nn_hid.c: GetSixAxisSensorStates fills its states from that; GetSixAxisSensorHandles returns 3 (the game runs its motion path only with a third source).
  • host/hos_sdl.c, host/pad_sdl.c: SDL3/SDL2 HasSensor, 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.
  • Insert menu, Controls tab:
    • Motion controls (On by default), showing whether the connected pad has a gyroscope: On (gyro found), On (no gyro on this pad), On (no controller);
    • Motion sensitivity X and Motion sensitivity Y, each 10% to 300% (100% is the console's), so horizontal and vertical aim can be tuned apart;
    • Invert motion X, Invert motion Y.
      These are controls, so changing them does not reset the quality preset. MkwVkHost gets gyro_status (API version 8); rx_option 18 to 21 are the rows.
  • HOS_MOTION_DEBUG=1 logs what the game gets twice a second.
  • README: the rows in the menu table and a line under it.

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 to tests/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.
  • Full game-free suite on Linux: 120 passed, 0 failed, 15 skipped (tools not present here).
  • The changed 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 "Nintendo Switch Pro Controller" with both sensors.
  • In game: being tested now on rc3 (RTX 3060 Laptop, Windows 11, 8BitDo Ultimate 2 in Switch mode). Draft until that's done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW

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. 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): - the horizontal aim is the yaw rate, in the angular velocity's **y slot**, in turns per 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, (0, 0, -1)), because the game folds them into an aim recenter; - the rates are measured about the world's axes from an up vector (complementary filter: the gyro's prediction pulled toward gravity while the accelerometer reads 0.65 to 1.35 G), so the pad works held flat or upright; - the gyroscope's resting bias is learned while the pad is still. - **`host/nn_hid.c`**: `GetSixAxisSensorStates` fills its states from that; `GetSixAxisSensorHandles` returns **3** (the game runs its motion path only with a third source). - **`host/hos_sdl.c`, `host/pad_sdl.c`**: SDL3/SDL2 `HasSensor`, `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. - **Insert menu, Controls tab**: - **Motion controls** (On by default), showing whether the connected pad has a gyroscope: `On (gyro found)`, `On (no gyro on this pad)`, `On (no controller)`; - **Motion sensitivity X** and **Motion sensitivity Y**, each 10% to 300% (100% is the console's), so horizontal and vertical aim can be tuned apart; - **Invert motion X**, **Invert motion Y**. These are controls, so changing them does not reset the quality preset. `MkwVkHost` gets `gyro_status` (API version 8); `rx_option` 18 to 21 are the rows. - `HOS_MOTION_DEBUG=1` logs what the game gets twice a second. - README: the rows in the menu table and a line under it. 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 to `tests/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. - Full game-free suite on Linux: 120 passed, 0 failed, 15 skipped (tools not present here). - The changed 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 "Nintendo Switch Pro Controller" with both sensors. - **In game:** being tested now on rc3 (RTX 3060 Laptop, Windows 11, 8BitDo Ultimate 2 in Switch mode). Draft until that's done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW
Link-22 commented 2026-11-09 14:32:45 +00:00 (Migrated from github.com)

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. HOS_GYRO_FLIP=z did not fix it. My patch from #112 aims correctly with the same controller, so it isn't a controller or driver issue.

From what I found while fixing my port, OoT doesn't seem to read the six-axis state like a plain sensor:

  • Horizontal aim comes from the yaw rate. In my patch the gravity-corrected yaw is sent in the angular velocity's y slot, in turns per second. Sending the raw Switch-frame gyro made it inverted and too fast.
  • Vertical aim comes from the direction matrix, as a pure tilt (pitch) rotation integrated on its own. The game folds the angles and the pitch rate into an aim recenter, which cancels the matrix's tilt. So in my patch those go out as zero, and the acceleration stays at rest (0, 0, -1).
  • GetSixAxisSensorHandles returns 3 handles in my patch. The game gates part of its motion path on that count.

The relevant code is in GetSixAxisSensorStates in host/nn_hid.c of my v2.0.7 patch, attached in #112. I'm happy to test any change to this PR with this controller.

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. `HOS_GYRO_FLIP=z` did not fix it. My patch from #112 aims correctly with the same controller, so it isn't a controller or driver issue. From what I found while fixing my port, OoT doesn't seem to read the six-axis state like a plain sensor: - **Horizontal aim** comes from the yaw rate. In my patch the gravity-corrected yaw is sent in the **angular velocity's y slot**, in turns per second. Sending the raw Switch-frame gyro made it inverted and too fast. - **Vertical aim** comes from the **direction matrix**, as a pure tilt (pitch) rotation integrated on its own. The game folds the **angles** and the **pitch rate** into an aim recenter, which cancels the matrix's tilt. So in my patch those go out as zero, and the acceleration stays at rest (0, 0, -1). - `GetSixAxisSensorHandles` returns **3** handles in my patch. The game gates part of its motion path on that count. The relevant code is in `GetSixAxisSensorStates` in `host/nn_hid.c` of my v2.0.7 patch, attached in #112. I'm happy to test any change to this PR with this controller.
wyatt-poole commented 2026-11-09 14:51:52 +00:00 (Migrated from github.com)

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:

  • the gravity-corrected yaw rate in the angular velocity's y slot, in turns per second
  • the vertical as a pure Rx(pitch) direction matrix, integrated from the body-frame pitch rate
  • angles and pitch rate zero, acceleration at rest (0, 0, -1)
  • the up-vector complementary filter, so flat and upright holds both work
  • GetSixAxisSensorHandles returning 3

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

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.

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: - the gravity-corrected yaw rate in the angular velocity's y slot, in turns per second - the vertical as a pure Rx(pitch) direction matrix, integrated from the body-frame pitch rate - angles and pitch rate zero, acceleration at rest (0, 0, -1) - the up-vector complementary filter, so flat and upright holds both work - `GetSixAxisSensorHandles` returning 3 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 `HOS_GYRO_FLIP`. 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.
wyatt-poole commented 2026-11-09 14:54:47 +00:00 (Migrated from github.com)

@Link-22 pushed (5768280): the rework from my last comment. It's still a draft while I try it in game, but if you get a chance to run it on the T3 Pro, I'd love to know how it compares to your patch.

@Link-22 pushed (5768280): the rework from my last comment. It's still a draft while I try it in game, but if you get a chance to run it on the T3 Pro, I'd love to know how it compares to your patch.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 15:48:49 +00:00
hckrman101 (Migrated from github.com) left a comment

Gyro aiming is a great addition. The SDL2/SDL3 sensor wrappers, the units (rad/s ÷ 2π to revolutions/s, rest accel (0,0,-1)), the 0x60-byte state layout and the option wiring all look right to me. A few things I'd look at, the first three being the important ones:

Gyro aiming is a great addition. The SDL2/SDL3 sensor wrappers, the units (rad/s ÷ 2π to revolutions/s, rest accel `(0,0,-1)`), the 0x60-byte state layout and the option wiring all look right to me. A few things I'd look at, the first three being the important ones:
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:50 +00:00

nit: this "not part of the quality preset" pointer list keeps growing (now 14 terms), with parallel lists in is_slider and the clamp, and each new Controls row has to be added to all three by hand. A field on Row (or simply r.tab == TAB_CONTROLS) would replace them.

nit: this "not part of the quality preset" pointer list keeps growing (now 14 terms), with parallel lists in `is_slider` and the clamp, and each new Controls row has to be added to all three by hand. A field on `Row` (or simply `r.tab == TAB_CONTROLS`) would replace them.
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:50 +00:00

nit: options 18–22 are bare numbers repeated here, in pad_sdl.c and in the vk_api.h comment, in a mixed order (19 sens X, 20 inv X, 21 inv Y, 22 sens Y), so swapping 20/22 or 19/21 somewhere would silently cross the settings. vk_api.h already has an HOS_OPT_* enum; adding HOS_OPT_GYRO, HOS_OPT_GYRO_SENS_X, … there and using them on both sides avoids that.

nit: options 18–22 are bare numbers repeated here, in `pad_sdl.c` and in the `vk_api.h` comment, in a mixed order (19 sens X, 20 inv X, 21 inv Y, 22 sens Y), so swapping 20/22 or 19/21 somewhere would silently cross the settings. `vk_api.h` already has an `HOS_OPT_*` enum; adding `HOS_OPT_GYRO`, `HOS_OPT_GYRO_SENS_X`, … there and using them on both sides avoids that.
@ -0,0 +1,189 @@
/* (hookshot) A controller's motion sensors as OoT reads its six-axis state (host/pad_sdl.c fills it from SDL,
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:49 +00:00

Any dt ≤ 1 ms (or > 50 ms) is replaced with a full 4 ms. On Linux the pads are polled from the window thread's event loop, which wakes on every SDL event, and turning the sensors on adds controller-sensor events (a DualSense over USB at ~1 kHz, plus mouse motion). So polls can land ~1 ms apart, each counted as 4 ms, and vertical aim moves several times too fast. In the other direction, a 60-100 ms hitch counts as 4 ms and that tilt is lost. Using the real dt for short gaps (only skipping exact duplicates), and clamping long ones rather than replacing them, would keep the integration honest.

Any dt ≤ 1 ms (or > 50 ms) is replaced with a full 4 ms. On Linux the pads are polled from the window thread's event loop, which wakes on every SDL event, and turning the sensors on adds controller-sensor events (a DualSense over USB at ~1 kHz, plus mouse motion). So polls can land ~1 ms apart, each counted as 4 ms, and vertical aim moves several times too fast. In the other direction, a 60-100 ms hitch counts as 4 ms and that tilt is lost. Using the real dt for short gaps (only skipping exact duplicates), and clamping long ones rather than replacing them, would keep the integration honest.
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:49 +00:00

"Still" here means under 0.08 rad/s (~4.6°/s) relative to the current bias, so slow, precise aiming gets learned away as bias. At 3°/s the first 256 samples (k = 0.06) cancel the turn within ~70 ms, and later about 63% of a one-second sweep is eaten. When the player stops, the aim drifts back until the bias is relearned. A real offset above 0.08 rad/s is also never learned at all. Gating stillness on low gyro variance over a short window plus a steady ~1 G accelerometer, rather than a threshold on the rate itself, is the usual way to avoid both.

"Still" here means under 0.08 rad/s (~4.6°/s) *relative to the current bias*, so slow, precise aiming gets learned away as bias. At 3°/s the first 256 samples (k = 0.06) cancel the turn within ~70 ms, and later about 63% of a one-second sweep is eaten. When the player stops, the aim drifts back until the bias is relearned. A real offset above 0.08 rad/s is also never learned at all. Gating stillness on low gyro variance over a short window plus a steady ~1 G accelerometer, rather than a threshold on the rate itself, is the usual way to avoid both.
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:49 +00:00

The vertical aim is an open-loop integral of the bias-corrected pitch rate, so any leftover bias or dt error adds up without limit: 0.01 rad/s left over is ~0.6 rad of tilt per minute with the pad held level. The filter already tracks gravity in up, so pulling pitch gently toward the tilt measured from it (a complementary filter, as done for up itself) would stop the drift.

The vertical aim is an open-loop integral of the bias-corrected pitch rate, so any leftover bias or dt error adds up without limit: 0.01 rad/s left over is ~0.6 rad of tilt per minute with the pad held level. The filter already tracks gravity in `up`, so pulling `pitch` gently toward the tilt measured from it (a complementary filter, as done for `up` itself) would stop the drift.
@ -827,24 +828,57 @@ HLE(_ZN2nn3hid21ShowControllerSupportEPNS0_27ControllerSupportResultInfoERKNS0_2
/* Handles: six-axis ones are opaque u32s; a vibration handle is {u8 style type (3 = full key), u8 npad id, u8 device
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:49 +00:00

This always writes at least 3 handles, whatever count the caller asked for. I get why from the comment (OoT only runs its motion path when three come back), but if the caller's array is sized for the 2 it asks for, the third G32 write lands just past it and corrupts whatever guest data follows. Have you checked that OoT's buffer for this call has room for 3 (e.g. it's part of a larger struct)? If so, a note here saying where that's guaranteed would help. If not, returning 3 while writing only ARG(1) handles might already be enough to get the motion branch.

This always writes at least 3 handles, whatever count the caller asked for. I get why from the comment (OoT only runs its motion path when three come back), but if the caller's array is sized for the 2 it asks for, the third `G32` write lands just past it and corrupts whatever guest data follows. Have you checked that OoT's buffer for this call has room for 3 (e.g. it's part of a larger struct)? If so, a note here saying where that's guaranteed would help. If not, returning 3 while writing only `ARG(1)` handles might already be enough to get the motion branch.
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:49 +00:00

While live, the sampling number goes up by 1 per call however many new readings arrived, and every entry reports the last poll's interval as its delta time (line 864). With ~4 polls per 16.7 ms frame, anything that counts samples or sums rate × delta_time over new samples sees about a quarter of the real time. Older entries (s - i) also repeat the current values. Driving the sampling number from samples, and giving delta time as the time since the last sample the game was handed, would match how the hardware reports it.

While live, the sampling number goes up by 1 per call however many new readings arrived, and every entry reports the last poll's interval as its delta time (line 864). With ~4 polls per 16.7 ms frame, anything that counts samples or sums rate × delta_time over new samples sees about a quarter of the real time. Older entries (`s - i`) also repeat the current values. Driving the sampling number from `samples`, and giving delta time as the time since the last sample the game was handed, would match how the hardware reports it.
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:49 +00:00

Motion always comes from the lowest-numbered pad that has a gyro, not the one being played with. A DualSense charging over USB in slot 0 would take the gyro from the Pro Controller in slot 1. Picking the pad with the latest button/stick input (or the one the npad state already comes from) would fit better. Also, the filter is only reset when the slot number changes, so a different pad that reconnects into the same slot within one scan keeps the old bias/up/pitch.

Motion always comes from the lowest-numbered pad that has a gyro, not the one being played with. A DualSense charging over USB in slot 0 would take the gyro from the Pro Controller in slot 1. Picking the pad with the latest button/stick input (or the one the npad state already comes from) would fit better. Also, the filter is only reset when the slot number changes, so a different pad that reconnects into the same slot within one scan keeps the old bias/up/pitch.
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:50 +00:00

minor: getenv("HOS_MOTION_DEBUG") runs on every poll, and this whole update, including the log line's file I/O, happens while holding state_lock exclusively, the lock the game's input HLEs take. Reading the env var once into a static, and running the filter outside the lock (taking it only to copy motion.out, the samples and dt), would keep the game thread from waiting on it.

minor: `getenv("HOS_MOTION_DEBUG")` runs on every poll, and this whole update, including the log line's file I/O, happens while holding `state_lock` exclusively, the lock the game's input HLEs take. Reading the env var once into a static, and running the filter outside the lock (taking it only to copy `motion.out`, the samples and dt), would keep the game thread from waiting on it.
@ -90,0 +153,4 @@
const int read = pick >= 0 && hos_sdl_pad_sensor(pads[pick].gc, HOS_SDL_SENSOR_ACCEL, a, 3) &&
hos_sdl_pad_sensor(pads[pick].gc, HOS_SDL_SENSOR_GYRO, g, 3);
/* (the pad that had motion keeps what its filter learned of its gyroscope's bias, for when it has motion again) */
if (motion_pad >= 0 && (!read || pick != motion_pad) && pads[motion_pad].gc && pads[motion_pad].serial == motion_serial &&
hckrman101 (Migrated from github.com) commented 2026-11-09 15:48:49 +00:00

If one of the two enables fails, motion = 0 marks the pad as having no gyro for good. That means the continue above skips it forever, so the accelerometer that did turn on is never turned off, and a temporary failure (e.g. the HIDAPI driver still starting on Bluetooth) is never retried, even after toggling Motion controls. Disabling both on a partial failure and retrying later would avoid that.

If one of the two enables fails, `motion = 0` marks the pad as having no gyro for good. That means the `continue` above skips it forever, so the accelerometer that did turn on is never turned off, and a temporary failure (e.g. the HIDAPI driver still starting on Bluetooth) is never retried, even after toggling Motion controls. Disabling both on a partial failure and retrying later would avoid that.
wyatt-poole commented 2026-11-09 16:04:23 +00:00 (Migrated from github.com)

@hckrman101 thanks for such a careful review. All ten are addressed in 66828d2:

  1. Handle count: GetSixAxisSensorHandles now writes only the handles the caller asked for and still returns 3, so a two-handle array is never written past. The handles are opaque and GetSixAxisSensorStates doesn't read them, so whatever the game finds in a third slot doesn't matter. It also logs the count the game asked for once, so we can confirm what OoT passes.
  2. dt: the filter now integrates the real time between readings. An exact repeat of the last reading counts as no reading, and its time carries into the next one. A long gap is capped at 100 ms instead of being replaced with 4 ms. The tests cover 1 ms polls, 60 ms polls and a 1 s stall.
  3. Bias gate: stillness now means the gyro's spread over a short window (0.2 s) is below 0.01 rad/s, the accelerometer's spread is below 0.01 G, and the magnitude is about 1 G, held for 0.5 s. The bias then moves toward the window's mean (fast for the first 2 s of stillness, then slowly). A slow, steady turn in the hand is no longer learned away, and an offset of any size is learned while the pad rests. There are tests for a 3°/s turn with hand tremor and a 0.2 rad/s offset.
  4. Pitch drift: pitch is now pulled toward the tilt that up measures (the pad's tilt about its x axis since the filter started, scaled by the same sensitivity and invert), with a 1 s time constant. The pull pauses while the pad is shaken or lying on its side. In the test, a 0.01 rad/s leftover bias over 60 s now stays within 0.03 rad.
  5. Sampling: the sampling number counts readings, moving on past the last number handed out. Each entry is its own reading from a 32-deep history, with the time since the reading before it. One question here: spluton's and Link-22's patches reported a fixed 5 ms, and the feel was tuned with that. If OoT uses delta_time for the yaw, a 1 kHz pad would now report about 1 ms. I'll ask Link-22 to compare on the T3 Pro.
  6. Which pad: motion now follows the pad with the latest button or stick change. Each pad opening has a serial, so a different pad that reconnects into the same slot gets a fresh filter.
  7. Enable failure: if either sensor fails to enable, both are turned off and the pad is retried every 2 s. Toggling Motion controls works again too.
  8. Lock: HOS_MOTION_DEBUG is read once. The filter and the log run outside state_lock, which is taken only to publish the new reading.
  9. Option ids: there's now HOS_OPT_GYRO, _SENS_X, _INV_X, _INV_Y and _SENS_Y in vk_api.h, used by both pad_sdl.c and vk_replay.cpp.
  10. Row lists: Row has a scale flag (drawn as a slider, stops at its ends) that replaces both slider lists, and the preset check is now r.tab != TAB_CONTROLS plus fullscreen, FPS counter and GPU device. One side effect: "Controller in background" no longer turns the preset to Custom, which I think was a bug anyway.

tests/motion_test.c now simulates the pad: gravity turns with the true rotation, and the gyro adds a bias plus either sensor noise or hand tremor. It passes with gcc and clang, and the full run_all.py passes. I'm testing in game on rc3 next.

@hckrman101 thanks for such a careful review. All ten are addressed in 66828d2: 1. **Handle count:** `GetSixAxisSensorHandles` now writes only the handles the caller asked for and still returns 3, so a two-handle array is never written past. The handles are opaque and `GetSixAxisSensorStates` doesn't read them, so whatever the game finds in a third slot doesn't matter. It also logs the count the game asked for once, so we can confirm what OoT passes. 2. **dt:** the filter now integrates the real time between readings. An exact repeat of the last reading counts as no reading, and its time carries into the next one. A long gap is capped at 100 ms instead of being replaced with 4 ms. The tests cover 1 ms polls, 60 ms polls and a 1 s stall. 3. **Bias gate:** stillness now means the gyro's spread over a short window (0.2 s) is below 0.01 rad/s, the accelerometer's spread is below 0.01 G, and the magnitude is about 1 G, held for 0.5 s. The bias then moves toward the window's mean (fast for the first 2 s of stillness, then slowly). A slow, steady turn in the hand is no longer learned away, and an offset of any size is learned while the pad rests. There are tests for a 3°/s turn with hand tremor and a 0.2 rad/s offset. 4. **Pitch drift:** `pitch` is now pulled toward the tilt that `up` measures (the pad's tilt about its x axis since the filter started, scaled by the same sensitivity and invert), with a 1 s time constant. The pull pauses while the pad is shaken or lying on its side. In the test, a 0.01 rad/s leftover bias over 60 s now stays within 0.03 rad. 5. **Sampling:** the sampling number counts readings, moving on past the last number handed out. Each entry is its own reading from a 32-deep history, with the time since the reading before it. One question here: spluton's and Link-22's patches reported a fixed 5 ms, and the feel was tuned with that. If OoT uses `delta_time` for the yaw, a 1 kHz pad would now report about 1 ms. I'll ask Link-22 to compare on the T3 Pro. 6. **Which pad:** motion now follows the pad with the latest button or stick change. Each pad opening has a serial, so a different pad that reconnects into the same slot gets a fresh filter. 7. **Enable failure:** if either sensor fails to enable, both are turned off and the pad is retried every 2 s. Toggling Motion controls works again too. 8. **Lock:** `HOS_MOTION_DEBUG` is read once. The filter and the log run outside `state_lock`, which is taken only to publish the new reading. 9. **Option ids:** there's now `HOS_OPT_GYRO`, `_SENS_X`, `_INV_X`, `_INV_Y` and `_SENS_Y` in `vk_api.h`, used by both `pad_sdl.c` and `vk_replay.cpp`. 10. **Row lists:** `Row` has a `scale` flag (drawn as a slider, stops at its ends) that replaces both slider lists, and the preset check is now `r.tab != TAB_CONTROLS` plus fullscreen, FPS counter and GPU device. One side effect: "Controller in background" no longer turns the preset to Custom, which I think was a bug anyway. `tests/motion_test.c` now simulates the pad: gravity turns with the true rotation, and the gyro adds a bias plus either sensor noise or hand tremor. It passes with gcc and clang, and the full `run_all.py` passes. I'm testing in game on rc3 next.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 16:56:26 +00:00
@ -827,24 +828,57 @@ HLE(_ZN2nn3hid21ShowControllerSupportEPNS0_27ControllerSupportResultInfoERKNS0_2
/* Handles: six-axis ones are opaque u32s; a vibration handle is {u8 style type (3 = full key), u8 npad id, u8 device
hckrman101 (Migrated from github.com) commented 2026-11-09 16:56:26 +00:00

Looks good now: numbers only move on real readings and each entry carries its own gap.

On the delta_time question: one thing to keep in mind is that a "reading" here is a poll that saw new data, not a raw sensor report, so the gap follows the poll rate rather than the pad's report rate (about 4 ms on the Windows thread, or whatever the window loop runs at on Linux). If OoT does sum rate × delta_time over the new samples, the real gap is what keeps the yaw speed right however often we poll. If it just uses the newest rate, delta_time doesn't matter at all. The old fixed 5 ms came with one sample per call, so the testers' tuning should hold either way. A quick check for Link-22: if the yaw speed stays the same while the poll rate changes (sensors on vs mouse wiggling on Linux, or Windows vs Linux), the game isn't using it.

Looks good now: numbers only move on real readings and each entry carries its own gap. On the `delta_time` question: one thing to keep in mind is that a "reading" here is a poll that saw new data, not a raw sensor report, so the gap follows the poll rate rather than the pad's report rate (about 4 ms on the Windows thread, or whatever the window loop runs at on Linux). If OoT does sum rate × `delta_time` over the new samples, the real gap is what keeps the yaw speed right however often we poll. If it just uses the newest rate, `delta_time` doesn't matter at all. The old fixed 5 ms came with one sample per call, so the testers' tuning should hold either way. A quick check for Link-22: if the yaw speed stays the same while the poll rate changes (sensors on vs mouse wiggling on Linux, or Windows vs Linux), the game isn't using it.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 16:56:39 +00:00
hckrman101 (Migrated from github.com) left a comment

Small follow-up on the new tilt pull (not blocking).

Small follow-up on the new tilt pull (not blocking).
@ -0,0 +176,4 @@
pitch about the levelled x (by 1 / sqrt(1 - up[0]^2)), and the pull would drag the aim past each move
(hckrman101); what builds up while rolled is corrected once it is level again. Not while shaken either. */
if (m->up_ready && valid && fabsf(up[0]) < HOS_MOTION_TILT_ROLL) {
const float tilt = atan2f(-up[2], up[1]); /* (it grows at the x rate: d/dt = w[0]) */
hckrman101 (Migrated from github.com) commented 2026-11-09 16:56:40 +00:00

The pull's target and the integral it corrects don't quite measure the same angle once the pad is rolled. pitch integrates rates[0] = w·right (about the levelled x), while atan2(-up[2], up[1]) is the tilt about the pad's own x. Working it through, rates[0] = yz · d(tilt)/dt for any rotation, so the two only agree when up[0] ≈ 0.

Example: held flat and rolled 45°, tilting the nose up by δ moves pitch by δ but the target by δ/0.71 ≈ 1.41δ, and the pull then drags the aim the rest of the way over the next second or so. At the 0.5 gate (60° of roll) it's 2×. A slight unconscious roll of 15–25° gives a creep of a few to 10% of each vertical move, more noticeable at high sensitivity.

The simplest fix is probably a tighter gate for the pull, e.g. only when fabsf(up[0]) < 0.25f (or weight k by how level x is). Whatever builds up while rolled then gets corrected once the pad comes back level. It'd be worth a test case: roll 30°, tilt up, hold, and check pitch doesn't creep.

The pull's target and the integral it corrects don't quite measure the same angle once the pad is rolled. `pitch` integrates `rates[0] = w·right` (about the levelled x), while `atan2(-up[2], up[1])` is the tilt about the pad's own x. Working it through, `rates[0] = yz · d(tilt)/dt` for any rotation, so the two only agree when `up[0] ≈ 0`. Example: held flat and rolled 45°, tilting the nose up by δ moves `pitch` by δ but the target by δ/0.71 ≈ 1.41δ, and the pull then drags the aim the rest of the way over the next second or so. At the 0.5 gate (60° of roll) it's 2×. A slight unconscious roll of 15–25° gives a creep of a few to 10% of each vertical move, more noticeable at high sensitivity. The simplest fix is probably a tighter gate for the pull, e.g. only when `fabsf(up[0]) < 0.25f` (or weight `k` by how level x is). Whatever builds up while rolled then gets corrected once the pad comes back level. It'd be worth a test case: roll 30°, tilt up, hold, and check `pitch` doesn't creep.
hckrman101 (Migrated from github.com) approved these changes 2026-11-09 16:56:56 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for working through all of these. I went over 66828d2 and all ten are sorted: the handle count no longer writes past a two-entry array, the filter uses real dt with the hitch cap, bias learning only kicks in when the pad is actually still, the vertical aim is pulled toward gravity, and sampling numbers and delta_time now follow real readings. The pad pick, the sensor retry, the lock scope and the option enum all look right too.

Leftovers, none blocking:

  • the tilt pull's target runs ahead of the integrated pitch when the pad is rolled (inline comment in hos_motion.h); a tighter up[0] gate would fix it
  • the stillness thresholds (0.01 rad/s, 0.01 G) are fine for DualSense/Pro-class sensors, but it's worth checking with HOS_MOTION_DEBUG that the bias actually gets learned on noisier third-party pads like the 8BitDo and the T3 Pro
  • switching to another gyro pad resets the filter, so its learned bias goes too (fine for now)

Approving. A retest from Link-22 on the T3 Pro would be nice before merge.

Thanks for working through all of these. I went over 66828d2 and all ten are sorted: the handle count no longer writes past a two-entry array, the filter uses real dt with the hitch cap, bias learning only kicks in when the pad is actually still, the vertical aim is pulled toward gravity, and sampling numbers and `delta_time` now follow real readings. The pad pick, the sensor retry, the lock scope and the option enum all look right too. Leftovers, none blocking: - the tilt pull's target runs ahead of the integrated pitch when the pad is rolled (inline comment in `hos_motion.h`); a tighter `up[0]` gate would fix it - the stillness thresholds (0.01 rad/s, 0.01 G) are fine for DualSense/Pro-class sensors, but it's worth checking with `HOS_MOTION_DEBUG` that the bias actually gets learned on noisier third-party pads like the 8BitDo and the T3 Pro - switching to another gyro pad resets the filter, so its learned bias goes too (fine for now) Approving. A retest from Link-22 on the T3 Pro would be nice before merge.
Link-22 commented 2026-11-09 17:32:37 +00:00 (Migrated from github.com)

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:

  • Direction: left/right and up/down both aim the right way, with no invert needed.
  • Speed: feels right at 100%, on par with my patch from #112.
  • Drift: holding the pad still on a target, the aim stays put.
  • Rolled pad: rolled about 30°, aimed up and held, the aim doesn't creep (the case from @hckrman101's leftover note).
  • Slow, precise turns: the aim follows them and doesn't drift back when I stop.

Great work @wyatt-poole, thanks for picking this up. From my side it's ready to merge.

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: - **Direction:** left/right and up/down both aim the right way, with no invert needed. - **Speed:** feels right at 100%, on par with my patch from #112. - **Drift:** holding the pad still on a target, the aim stays put. - **Rolled pad:** rolled about 30°, aimed up and held, the aim doesn't creep (the case from @hckrman101's leftover note). - **Slow, precise turns:** the aim follows them and doesn't drift back when I stop. Great work @wyatt-poole, thanks for picking this up. From my side it's ready to merge.
wyatt-poole commented 2026-11-09 17:34:23 +00:00 (Migrated from github.com)

@hckrman101 thanks for the approval and the follow-ups. Two of them are in 8a5c178:

  • Tilt pull while rolled: the pull now runs only while fabsf(up[0]) < 0.25f (about 15° of roll), as you suggested. Anything that builds up while rolled is corrected once the pad is level again. I added the test case: rolled 30°, nose tilted up 0.5 rad, held for 3 s, and pitch stays at -0.5. With the old 0.5 gate it creeps to -0.56.
  • Switching pads: each pad now keeps the bias it learned while another pad has motion, or while Motion controls is off, and gets it back when it has motion again. A pad that reconnects (new serial) starts fresh.
  • Noisier pads: I'll check with HOS_MOTION_DEBUG that the bias gets learned on my 8BitDo Ultimate 2 resting on a table, and loosen the thresholds if it doesn't.

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

@hckrman101 thanks for the approval and the follow-ups. Two of them are in 8a5c178: - **Tilt pull while rolled:** the pull now runs only while `fabsf(up[0]) < 0.25f` (about 15° of roll), as you suggested. Anything that builds up while rolled is corrected once the pad is level again. I added the test case: rolled 30°, nose tilted up 0.5 rad, held for 3 s, and `pitch` stays at -0.5. With the old 0.5 gate it creeps to -0.56. - **Switching pads:** each pad now keeps the bias it learned while another pad has motion, or while Motion controls is off, and gets it back when it has motion again. A pad that reconnects (new serial) starts fresh. - **Noisier pads:** I'll check with `HOS_MOTION_DEBUG` that the bias gets learned on my 8BitDo Ultimate 2 resting on a table, and loosen the thresholds if it doesn't. @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?
hckrman101 (Migrated from github.com) reviewed 2026-11-09 17:41:16 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for the quick follow-ups. Per-pad bias looks right to me: keyed on the slot plus the serial, so a reconnect starts fresh, it's only touched on the poll thread, and turning Motion controls off and on again gets it back. Nice to see the T3 Pro retest too, though that was on 66828d2, before this commit.

One thing in the roll gate though (inline): tilt0 is still anchored on the first reading that passes the gate, and now that the gate is 15° instead of about 60°, a filter that starts rolled anchors late, after some pitch has already built up. Easy fix, and a test that rolls back to level would catch it.

The branch also conflicts with main now: gpu/vk/vk_api.h, gpu/vk/vk_menu.cpp, gpu/vk/vk_replay.cpp, host/pad_sdl.c, host/rx_backend.c, readme.md and tests/test_mouse_settings.py. That's mostly #937 (mic) and the Rumble strength row (option 9, rumble code in pad_sdl.c), plus the menu work. The option ids don't collide (main uses 9, you use 18 to 22), but both branches bump MKWVK_API_VERSION from 7 to 8 with different struct layouts. After the merge it needs to be 9, with gyro_status after the mic_* pointers on both sides, otherwise an old vk dll would pass the version check.

Thanks for the quick follow-ups. Per-pad bias looks right to me: keyed on the slot plus the serial, so a reconnect starts fresh, it's only touched on the poll thread, and turning Motion controls off and on again gets it back. Nice to see the T3 Pro retest too, though that was on 66828d2, before this commit. One thing in the roll gate though (inline): `tilt0` is still anchored on the first reading that passes the gate, and now that the gate is 15° instead of about 60°, a filter that starts rolled anchors late, after some pitch has already built up. Easy fix, and a test that rolls back to level would catch it. The branch also conflicts with main now: `gpu/vk/vk_api.h`, `gpu/vk/vk_menu.cpp`, `gpu/vk/vk_replay.cpp`, `host/pad_sdl.c`, `host/rx_backend.c`, `readme.md` and `tests/test_mouse_settings.py`. That's mostly #937 (mic) and the Rumble strength row (option 9, rumble code in pad_sdl.c), plus the menu work. The option ids don't collide (main uses 9, you use 18 to 22), but both branches bump `MKWVK_API_VERSION` from 7 to 8 with different struct layouts. After the merge it needs to be 9, with `gyro_status` after the mic_* pointers on both sides, otherwise an old vk dll would pass the version check.
@ -0,0 +1,189 @@
/* (hookshot) A controller's motion sensors as OoT reads its six-axis state (host/pad_sdl.c fills it from SDL,
hckrman101 (Migrated from github.com) commented 2026-11-09 17:41:16 +00:00

The anchor here assumes pitch is 0 when the gate first passes. Before this commit that happened on almost the first reading. Now, if the filter starts with the pad rolled more than 15° (it resets on startup, when Motion controls is turned on, and when switching pads or reconnecting), pitch integrates while rolled, and when the pad first comes level tilt0 is taken from wherever the pad is pointing then. target comes out as 0 there, so the pull drags the aim back by however far it moved while rolled, and from then on the level is off by that much.

Anchoring to the current pitch fixes it (sv can't be 0, the menu's minimum is 10%):

if (!m->tilt_ready) m->tilt0 = hos_motion_wrap(tilt - m->pitch / sv), m->tilt_ready = 1;
The anchor here assumes `pitch` is 0 when the gate first passes. Before this commit that happened on almost the first reading. Now, if the filter starts with the pad rolled more than 15° (it resets on startup, when Motion controls is turned on, and when switching pads or reconnecting), `pitch` integrates while rolled, and when the pad first comes level `tilt0` is taken from wherever the pad is pointing then. `target` comes out as 0 there, so the pull drags the aim back by however far it moved while rolled, and from then on the level is off by that much. Anchoring to the current pitch fixes it (sv can't be 0, the menu's minimum is 10%): ```c if (!m->tilt_ready) m->tilt0 = hos_motion_wrap(tilt - m->pitch / sv), m->tilt_ready = 1; ```
@ -0,0 +130,4 @@
}
NEAR(m.pitch, -0.5, 0.03);
run(&m, 0, 0, 0, 3, 1, 1, 0, 0);
NEAR(m.pitch, -0.5, 0.03);
hckrman101 (Migrated from github.com) commented 2026-11-09 17:41:16 +00:00

This case starts rolled and stays rolled for the whole run, so the gate never passes and the pull never runs. It shows the pull stays off while rolled, but not that the aim stays right once it's level again. Could you add a step that rolls back to level with the nose still up (or one that starts level, rolls, aims, and comes back) and check pitch stays at about -0.5 for a few seconds? Without the anchor fix above, the case that starts rolled should go to 0.

This case starts rolled and stays rolled for the whole run, so the gate never passes and the pull never runs. It shows the pull stays off while rolled, but not that the aim stays right once it's level again. Could you add a step that rolls back to level with the nose still up (or one that starts level, rolls, aims, and comes back) and check `pitch` stays at about -0.5 for a few seconds? Without the anchor fix above, the case that starts rolled should go to 0.
Link-22 commented 2026-11-09 18:00:19 +00:00 (Migrated from github.com)

@wyatt-poole quick answer on 8a5c178, same setup as before (T3 Pro, Linux, defaults):

  • Yaw speed matches my patch.
  • Sensors only vs mouse wiggling over the window: I see no difference in yaw speed.

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.

@wyatt-poole quick answer on **8a5c178**, same setup as before (T3 Pro, Linux, defaults): - **Yaw speed** matches my patch. - **Sensors only vs mouse wiggling over the window:** I see no difference in yaw speed. 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.
wyatt-poole commented 2026-11-09 18:11:27 +00:00 (Migrated from github.com)

@hckrman101 @Link-22 both points are in, and the branch is merged with main:

  • Late anchor (76fc99b): tilt0 is now anchored to the aim as it is (tilt - pitch / sv), as you suggested, so the pull's target equals the current pitch when the pad first comes level. The rolled test now finishes the job: it starts the filter rolled 30°, aims up, rolls back to level (about the pad's z, which keeps the nose's height), and checks that the aim holds for 3 s. Without the fix it slides from -0.30 to 0. This should also be Link-22's "wrong after turning Motion controls on while not level" case.
  • Merge with main (33d39bc):
    • MKWVK_API_VERSION is 9, with gyro_status after the mic_* pointers in both vk_api.h and rx_backend.c's host table.
    • In the menu, I dropped this branch's scale field in favour of main's preset_exempt and stops_at_ends. The motion rows are preset-exempt, and both sensitivities are sliders that stop at their ends.
    • Option 9 (rumble) and 18 to 22 (motion) sit side by side in the option table.
    • pad_sdl.c keeps both rumble_strength and motion_poll.
    • tests/test_rumble_menu.py now reads every HOS_OPT_* enum when it checks the option ids for collisions (before, it read only the cheats').

run_all.py passes on Linux (140 passed, 0 failed), and test_motion.py passes with both gcc and clang. Taking it out of draft.

@hckrman101 @Link-22 both points are in, and the branch is merged with main: - **Late anchor** (76fc99b): `tilt0` is now anchored to the aim as it is (`tilt - pitch / sv`), as you suggested, so the pull's target equals the current pitch when the pad first comes level. The rolled test now finishes the job: it starts the filter rolled 30°, aims up, rolls back to level (about the pad's z, which keeps the nose's height), and checks that the aim holds for 3 s. Without the fix it slides from -0.30 to 0. This should also be Link-22's "wrong after turning Motion controls on while not level" case. - **Merge with main** (33d39bc): - `MKWVK_API_VERSION` is 9, with `gyro_status` after the `mic_*` pointers in both `vk_api.h` and `rx_backend.c`'s host table. - In the menu, I dropped this branch's `scale` field in favour of main's `preset_exempt` and `stops_at_ends`. The motion rows are preset-exempt, and both sensitivities are sliders that stop at their ends. - Option 9 (rumble) and 18 to 22 (motion) sit side by side in the option table. - `pad_sdl.c` keeps both `rumble_strength` and `motion_poll`. - `tests/test_rumble_menu.py` now reads every `HOS_OPT_*` enum when it checks the option ids for collisions (before, it read only the cheats'). `run_all.py` passes on Linux (140 passed, 0 failed), and `test_motion.py` passes with both gcc and clang. Taking it out of draft.
hckrman101 (Migrated from github.com) approved these changes 2026-11-09 18:19:34 +00:00
hckrman101 (Migrated from github.com) left a comment

Re-checked at 76fc99b, looks good.

  • Late anchor: fixed. With tilt0 = tilt - pitch / sv, the target on the first level reading is (pitch / sv) * sv = pitch, so the pull starts at the aim as it is. That holds for either sign of sv (inverted or not), and the target keeps moving at the same rate as the integrated pitch after that. The new step (start rolled, aim up, roll back to level about z, hold 3 s) is the case I was after.
  • Merge with main: MKWVK_API_VERSION is 9. gyro_status comes after mic_select in both vk_api.h and rx_backend.c's table, and nothing else moved. Ids 18-22 don't collide with main's 0-9/16/17/32-35, and test_rumble_menu.py now checks every HOS_OPT_* enum. pad_sdl.c keeps rumble_strength next to motion_poll. The sensitivities are sliders that stop at their ends, the same as mouse sens and rumble.

One small nit, not blocking: the Motion controls on/off row has no preset_exempt, so toggling it still sets the Quality preset to Custom. The four rows under it are exempt. Probably just a missing , true.

GitHub shows it as mergeable now. No CI checks ran on the branch.

Re-checked at 76fc99b, looks good. - **Late anchor:** fixed. With `tilt0 = tilt - pitch / sv`, the target on the first level reading is `(pitch / sv) * sv = pitch`, so the pull starts at the aim as it is. That holds for either sign of `sv` (inverted or not), and the target keeps moving at the same rate as the integrated pitch after that. The new step (start rolled, aim up, roll back to level about z, hold 3 s) is the case I was after. - **Merge with main:** `MKWVK_API_VERSION` is 9. `gyro_status` comes after `mic_select` in both `vk_api.h` and `rx_backend.c`'s table, and nothing else moved. Ids 18-22 don't collide with main's 0-9/16/17/32-35, and `test_rumble_menu.py` now checks every `HOS_OPT_*` enum. `pad_sdl.c` keeps `rumble_strength` next to `motion_poll`. The sensitivities are sliders that stop at their ends, the same as mouse sens and rumble. One small nit, not blocking: the **Motion controls** on/off row has no `preset_exempt`, so toggling it still sets the Quality preset to Custom. The four rows under it are exempt. Probably just a missing `, true`. GitHub shows it as mergeable now. No CI checks ran on the branch.
Link-22 commented 2026-11-09 18:42:10 +00:00 (Migrated from github.com)

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

  • Direction and calibration: aiming at a fixed point, turning away and turning back brings the aim back to exactly the same point.
  • Holding still: the aim feels steadier than before.
  • Sensors only vs mouse wiggling: no change in yaw speed.
  • Rumble still works after the merge with main.

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 HOS_MOTION_DEBUG=1 and attached the hid: lines. There are four filter starts, one per motion controls from line:

Start Pad at start (up) Later, same hold: up aim tilt Aim
1 flat (0, 1, 0), game start — — —
2 rolled, little pitch (0.55, 0.78, -0.30) (0.02, 0.76, -0.65) -0.78 seemed fine
3 rolled and pitched (0.75, 0.36, -0.55) (0.01, 0.67, -0.74) +0.09 wrong
4 flat on the desk (0.05, 1.00, 0.06) (-0.03, 0.70, -0.72) -0.91 fine

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 pitch starts at 0 in whatever pose the pad is in. While the pad is rolled the tilt pull is off, so nothing brings it back to the gravity-level reference. When it comes level, the anchor (tilt - pitch / sv) then keeps that offset for good.

A guess at a fix: start pitch from the measured tilt when up is first set, e.g. sv * asin(-up[2]), the nose's angle above level, which also holds when rolled. Then anchor the pull to the absolute tilt (tilt0 = 0). The vertical would always be relative to level, whatever the pose at start. I'm happy to retest.
hookshot-motion-debug-76fc99b.txt

@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: - **Direction and calibration:** aiming at a fixed point, turning away and turning back brings the aim back to exactly the same point. - **Holding still:** the aim feels steadier than before. - **Sensors only vs mouse wiggling:** no change in yaw speed. - **Rumble** still works after the merge with main. 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 `HOS_MOTION_DEBUG=1` and attached the `hid:` lines. There are four filter starts, one per `motion controls from` line: | Start | Pad at start (`up`) | Later, same hold: `up` | `aim tilt` | Aim | |---|---|---|---|---| | 1 | flat (0, 1, 0), game start | — | — | — | | 2 | rolled, little pitch (0.55, 0.78, -0.30) | (0.02, 0.76, -0.65) | -0.78 | seemed fine | | 3 | rolled **and pitched** (0.75, 0.36, -0.55) | (0.01, 0.67, -0.74) | **+0.09** | wrong | | 4 | flat on the desk (0.05, 1.00, 0.06) | (-0.03, 0.70, -0.72) | -0.91 | fine | 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 `pitch` starts at 0 in whatever pose the pad is in. While the pad is rolled the tilt pull is off, so nothing brings it back to the gravity-level reference. When it comes level, the anchor (`tilt - pitch / sv`) then keeps that offset for good. A guess at a fix: start `pitch` from the measured tilt when `up` is first set, e.g. `sv * asin(-up[2])`, the nose's angle above level, which also holds when rolled. Then anchor the pull to the absolute tilt (`tilt0 = 0`). The vertical would always be relative to level, whatever the pose at start. I'm happy to retest. [hookshot-motion-debug-76fc99b.txt](https://github.com/user-attachments/files/34593488/hookshot-motion-debug-76fc99b.txt)
Commenting is not possible because the repository is archived.
No description provided.