From 043e53fae49bcca0a63160b329c8176c8d3692ae Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:33:55 -0500 Subject: [PATCH 1/9] Motion controls: follow-ups to #1114 (tilt pull, stillness, sampling) - 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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- host/hos_motion.h | 41 ++++++++++++++++++++++++++++++----------- host/nn_hid.c | 6 ++++-- host/pad_sdl.c | 14 ++++++++++++-- tests/motion_test.c | 28 ++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/host/hos_motion.h b/host/hos_motion.h index 370bb2bd5..e665af357 100644 --- a/host/hos_motion.h +++ b/host/hos_motion.h @@ -45,23 +45,30 @@ typedef struct { float still_t; /* seconds the pad has been still (bias learning starts after HOS_MOTION_STILL_S) */ float learned_t; /* seconds of stillness learned from so far (fast at first, then slowly) */ float gmean[3], gvar; /* the gyro's short-window mean (rad/s) and variance (summed over its axes) */ - float amean, avar; /* the accelerometer's magnitude, the same way (G) */ + float amean, avar; /* the accelerometer's magnitude's mean, and its vector's variance summed over the axes (G): + a pad turning at a steady rate keeps its magnitude but not its direction */ + float avec[3]; /* (the accelerometer's vector's mean, G) */ int stats_ready; float last_accel[3], last_gyro[3]; /* (the last reading: a repeat of it is no new reading) */ float pending_dt; /* (time from repeats, carried into the next new reading) */ float up[3]; /* the world's up in the pad's frame (unit) */ int up_ready; float pitch; /* radians, the vertical aim's tilt */ - float tilt0; /* radians, the pad's tilt about its x axis when the filter started (the aim's level) */ + float tilt_last; /* radians, the pad's tilt about its x axis at the last level reading */ + float tilt_acc; /* radians, that tilt unwrapped since the anchor, the anchor's own value pitch / sv (so it scales + by any sensitivity without a jump at a half turn) */ + float tilt_sv; /* the vertical scale the anchor was taken with: a new one re-anchors */ int tilt_ready; float rates[3]; /* (for tests and logs) pitch, yaw, roll about the world's axes, rad/s */ HosMotionState out; } HosMotion; -/* stillness: the gyro's spread under HOS_MOTION_STILL_GYRO rad/s and the accelerometer's under HOS_MOTION_STILL_ACCEL - G, about 1 G, for HOS_MOTION_STILL_S. A hand never holds that steady (its tremor alone is several times more), so a - slow, steady turn is aim, not bias, however slow; and an offset of any size is learned on a table. */ -#define HOS_MOTION_STILL_GYRO 0.01f +/* stillness: the gyro's spread under HOS_MOTION_STILL_GYRO rad/s and the accelerometer's (its direction too, so a pad + turning at a steady rate is not still) under HOS_MOTION_STILL_ACCEL G, about 1 G, for HOS_MOTION_STILL_S. Both are + summed over the three axes: 0.015 rad/s is about 0.009 on each, room for a noisier third-party sensor. A hand never + holds that steady (its tremor alone is several times more), so a slow, steady turn is aim, not bias, however slow; + and an offset of any size is learned on a table. */ +#define HOS_MOTION_STILL_GYRO 0.015f #define HOS_MOTION_STILL_ACCEL 0.01f #define HOS_MOTION_STILL_S 0.5f #define HOS_MOTION_WINDOW_S 0.2f /* (the short window's time constant) */ @@ -108,6 +115,7 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl /* the bias: learned from the short window's mean while the pad is still (both sensors steady, about 1 G) */ if (!m->stats_ready) { memcpy(m->gmean, gyro, sizeof m->gmean), m->gvar = 1, m->amean = ag, m->avar = 1; + for (int i = 0; i < 3; i++) m->avec[i] = accel[i] / HOS_MOTION_G; m->stats_ready = 1; } else if (dt > 0) { const float k = dt / (HOS_MOTION_WINDOW_S + dt); @@ -118,9 +126,14 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl gv += d * d; } m->gvar += (gv - m->gvar) * k; - const float da = ag - m->amean; - m->amean += da * k; - m->avar += (da * da - m->avar) * k; + m->amean += (ag - m->amean) * k; + float av = 0; + for (int i = 0; i < 3; i++) { + const float d = accel[i] / HOS_MOTION_G - m->avec[i]; + m->avec[i] += d * k; + av += d * d; + } + m->avar += (av - m->avar) * k; } const int still = m->gvar < HOS_MOTION_STILL_GYRO * HOS_MOTION_STILL_GYRO && m->avar < HOS_MOTION_STILL_ACCEL * HOS_MOTION_STILL_ACCEL && m->amean > 0.9f && m->amean < 1.1f; @@ -179,8 +192,14 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl const float tilt = atan2f(-up[2], up[1]); /* (it grows at the x rate: d/dt = w[0]) */ /* (anchored so the aim stays where it is: the first time the pad is level may be after it moved while rolled, or held tilted when the filter started; sv is never 0, the menu's least sensitivity is 10%) */ - 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 || sv != m->tilt_sv) { /* (a new Motion sensitivity Y or Invert motion Y re-anchors too: + the aim stays put rather than sliding to the new scale) */ + m->tilt_acc = m->pitch / sv, m->tilt_sv = sv, m->tilt_ready = 1; + } else { + m->tilt_acc += hos_motion_wrap(tilt - m->tilt_last); + } + m->tilt_last = tilt; + const float target = hos_motion_wrap(m->tilt_acc * sv); m->pitch = hos_motion_wrap(m->pitch + hos_motion_wrap(target - m->pitch) * (dt / (HOS_MOTION_TILT_S + dt))); } hos_motion_rest(&m->out, m->pitch); diff --git a/host/nn_hid.c b/host/nn_hid.c index 36bd0b109..7bb4bde22 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -853,11 +853,13 @@ HLE(_ZN2nn3hid22GetSixAxisSensorStatesEPNS0_18SixAxisSensorStateEiRKNS0_19SixAxi int hos_sdl_motion_history(HosMotionSample *out, int max); /* pad_sdl.c */ HosMotionSample h[HOS_MOTION_HISTORY]; const int live = count ? hos_sdl_motion_history(h, count) : 0; + /* (each switch between live and at rest moves the numbers a whole history past the last ones given out, so no entry + of one ever has a number an entry of the other had) */ if (live) { - if (!was_live || six_base + h[0].n < six_sampling) six_base = six_sampling + 1 - h[0].n; + if (!was_live || six_base + h[0].n < six_sampling) six_base = six_sampling + HOS_MOTION_HISTORY - h[0].n; six_sampling = six_base + h[0].n; } else { - ++six_sampling; + six_sampling += was_live ? HOS_MOTION_HISTORY : 1; } was_live = live > 0; const int n = live ? live : count; diff --git a/host/pad_sdl.c b/host/pad_sdl.c index 338ec2e8e..f17adf53a 100644 --- a/host/pad_sdl.c +++ b/host/pad_sdl.c @@ -55,7 +55,8 @@ static volatile LONG rumble_low, rumble_high; /* 0..65535, set by the vibratio /* (motion) the pad read and its filter (the polling thread's own), then what the game is handed (under state_lock): the last HOS_MOTION_HISTORY readings, newest at motion_hist[motion_head]; gyro_status for the menu */ static HosMotion motion; -static int64_t motion_ns, motion_reading_ns, motion_log_ns, motion_readings; +static int64_t motion_ns, motion_reading_ns, motion_log_ns, motion_readings, motion_start_ns; +static int motion_warned; static int motion_pad = -1; static uint32_t motion_serial; static HosMotionSample motion_hist[HOS_MOTION_HISTORY]; @@ -172,6 +173,7 @@ static void motion_poll(int64_t now, int count) { memcpy(motion.bias, pads[pick].bias, sizeof motion.bias), motion.learned_t = pads[pick].bias_t; motion_pad = pick, motion_serial = pads[pick].serial, motion_ns = now; fresh = 1; + motion_start_ns = now, motion_warned = 0; hos_log("hid: motion controls from \"%s\"", hos_sdl_pad_name(pads[pick].gc)); } /* the filter runs outside the lock (the game's input calls take it); a repeat of the last reading is no reading */ @@ -181,6 +183,12 @@ static void motion_poll(int64_t now, int count) { 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) { + motion_warned = 1; /* (a pad noisier than the stillness test allows: its drift is never taken off) */ + hos_log("hid: motion: \"%s\" has not read still for 30 s, so its gyroscope's drift is not learned yet (set it " + "down on a table for a second; if that does not help, its sensor is noisier than expected)", + hos_sdl_pad_name(pads[pick].gc)); + } if (debug && now >= motion_log_ns) { /* (twice a second: what the game gets) */ motion_log_ns = now + 500000000LL; hos_log("hid: motion: up (%+.2f, %+.2f, %+.2f), pitch/yaw/roll %+.2f %+.2f %+.2f rad/s, aim tilt %+.3f, yaw " @@ -191,7 +199,9 @@ static void motion_poll(int64_t now, int count) { HosMotionSample r; r.st = motion.out; r.n = ++motion_readings; - r.dt_ns = fresh ? 5000000 : now - motion_reading_ns; /* (the time since the reading before) */ + /* (the time since the reading before, capped as the filter caps it: a stalled pad's next reading is no jump) */ + r.dt_ns = fresh ? 5000000 : now - motion_reading_ns; + if (r.dt_ns > (int64_t)(HOS_MOTION_DT_MAX * 1e9)) r.dt_ns = (int64_t)(HOS_MOTION_DT_MAX * 1e9); motion_reading_ns = now; AcquireSRWLockExclusive(&state_lock); motion_head = (motion_head + 1) % HOS_MOTION_HISTORY; diff --git a/tests/motion_test.c b/tests/motion_test.c index 17710853f..9807ec6e0 100644 --- a/tests/motion_test.c +++ b/tests/motion_test.c @@ -91,6 +91,12 @@ int main(void) { run(&m, 0, 0, 0, 4, 1, 1, 0, 0); NEAR(m.bias[1], 0.2, 1e-3); + /* a noisier sensor (0.0067 rad/s on each axis, past the first threshold's 0.0058) still reads still on a table */ + start(&m, FLAT); + offset[1] = 0.02f, tremor = 0.0116f; /* (uniform: 0.0067 rad/s RMS) */ + run(&m, 0, 0, 0, 6, 1, 1, 0, 0); + NEAR(m.bias[1], 0.02, 2e-3); + /* a slow, steady turn in the hand (3 degrees a second, with a hand's tremor) is aim: not learned away */ start(&m, FLAT); run(&m, 0, 0, 0, 1, 1, 1, 0, 0); /* (on the table first) */ @@ -176,6 +182,28 @@ int main(void) { run(&m, 0, 0, 0, 3, 1, 2, 0, 0); NEAR(m.pitch, -1.0, 0.05); + /* Motion sensitivity Y or Invert motion Y changed while the pad is held tilted: the aim stays where it is (no slide + to the new scale), and later tilts move it at the new one */ + start(&m, FLAT); + run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); + run(&m, 1, 0, 0, 0.5f, 1, 1, 0, 0); + NEAR(m.pitch, -0.5, 0.03); + run(&m, 0, 0, 0, 3, 1, 2, 0, 0); + NEAR(m.pitch, -0.5, 0.03); + run(&m, 1, 0, 0, 0.25f, 1, 2, 0, 0); + NEAR(m.pitch, -1.0, 0.04); + run(&m, 0, 0, 0, 3, 1, 2, 0, 1); + NEAR(m.pitch, -1.0, 0.04); + + /* past half a turn from the anchor at a sensitivity that is not a whole number: no jump (pitched forward 3.5 rad at + 50%: the aim goes to -1.75 and stays) */ + start(&m, FLAT); + run(&m, 0, 0, 0, 0.1f, 1, 0.5f, 0, 0); + run(&m, 1, 0, 0, 3.5f, 1, 0.5f, 0, 0); + NEAR(m.pitch, -1.75, 0.05); + run(&m, 0, 0, 0, 2, 1, 0.5f, 0, 0); + NEAR(m.pitch, -1.75, 0.05); + /* picked up from flat to upright without the gyro seeing it: up settles on the measured gravity */ start(&m, FLAT); run(&m, 0, 0, 0, 0.5f, 1, 1, 0, 0); From 73a34a6e06ed449599b323f5f6ee964ff68cae80 Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:51:24 -0500 Subject: [PATCH 2/9] Motion controls: the vertical aim measured from flat; review of #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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- gpu/vk/vk_menu.cpp | 2 +- host/hos_motion.h | 48 ++++++++++++++++++++++------------ host/pad_sdl.c | 6 ++--- readme.md | 3 ++- tests/motion_test.c | 63 +++++++++++++++++++++++++++++++++------------ 5 files changed, 85 insertions(+), 37 deletions(-) diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index d4f8ba76d..9d3ca3c21 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -289,7 +289,7 @@ Row rows[] = { "How strongly the controller rumbles, in percent of the game's rumble. Off turns it off.", true}, {TAB_CONTROLS, "Motion controls", &opts.gyro, 0, 1, 1, tx_gyro, "Aims and looks with the controller's gyroscope where the game uses motion, as on the Switch. Works with Switch Pro " - "controllers and pads in Switch mode, DualSense and DualShock 4. Shown beside it: whether the connected pad has one."}, + "controllers and pads in Switch mode, DualSense and DualShock 4. Shown beside it: whether the connected pad has one.", true}, {TAB_CONTROLS, "Motion sensitivity X", &opts.gyro_sens_x, 10, 300, 10, tx_percent, "How far the aim moves left and right for a given turn of the controller. 100% is the console's own.", true}, {TAB_CONTROLS, "Motion sensitivity Y", &opts.gyro_sens_y, 10, 300, 10, tx_percent, diff --git a/host/hos_motion.h b/host/hos_motion.h index e665af357..73f40573f 100644 --- a/host/hos_motion.h +++ b/host/hos_motion.h @@ -55,21 +55,23 @@ typedef struct { int up_ready; float pitch; /* radians, the vertical aim's tilt */ float tilt_last; /* radians, the pad's tilt about its x axis at the last level reading */ - float tilt_acc; /* radians, that tilt unwrapped since the anchor, the anchor's own value pitch / sv (so it scales - by any sensitivity without a jump at a half turn) */ - float tilt_sv; /* the vertical scale the anchor was taken with: a new one re-anchors */ + float tilt_acc; /* radians, that tilt from flat, unwrapped (so it scales by any sensitivity without a jump at a + half turn) */ + float tilt_sv; /* the vertical scale the aim is at (0 before the first reading): a new one rescales it */ int tilt_ready; float rates[3]; /* (for tests and logs) pitch, yaw, roll about the world's axes, rad/s */ HosMotionState out; } HosMotion; -/* stillness: the gyro's spread under HOS_MOTION_STILL_GYRO rad/s and the accelerometer's (its direction too, so a pad - turning at a steady rate is not still) under HOS_MOTION_STILL_ACCEL G, about 1 G, for HOS_MOTION_STILL_S. Both are - summed over the three axes: 0.015 rad/s is about 0.009 on each, room for a noisier third-party sensor. A hand never - holds that steady (its tremor alone is several times more), so a slow, steady turn is aim, not bias, however slow; - and an offset of any size is learned on a table. */ +/* stillness: the gyro's spread under HOS_MOTION_STILL_GYRO rad/s and the accelerometer's vector's under + HOS_MOTION_STILL_ACCEL G, about 1 G, for HOS_MOTION_STILL_S. Both are summed over the three axes: 0.015 rad/s is + about 0.009 on each and 0.0173 G about 0.01 G on each, room for noisier third-party sensors. What keeps aiming from + being learned as bias is the hand: its tremor alone is several times the gyro's threshold. The accelerometer's + vector adds a steady pitch or roll faster than about 5 degrees a second (its direction moves past the window's lag); + a steady turn about gravity leaves it unchanged, so only a pad on a turntable, untouched, could be mistaken. An + offset of any size is learned on a table. */ #define HOS_MOTION_STILL_GYRO 0.015f -#define HOS_MOTION_STILL_ACCEL 0.01f +#define HOS_MOTION_STILL_ACCEL 0.0173f /* (0.01 G on each axis, summed over three like the gyro's) */ #define HOS_MOTION_STILL_S 0.5f #define HOS_MOTION_WINDOW_S 0.2f /* (the short window's time constant) */ #define HOS_MOTION_DT_MAX 0.1f /* (a longer gap, a hitch, counts as this long) */ @@ -148,10 +150,11 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl the measured gravity while it is about 1 G */ const int valid = alen > 0.65f * HOS_MOTION_G && alen < 1.35f * HOS_MOTION_G; float *up = m->up; + int started = 0; if (!m->up_ready) { if (valid) { for (int i = 0; i < 3; i++) up[i] = accel[i] / alen; - m->up_ready = 1; + m->up_ready = 1, started = 1; } } else { float p[3] = {up[0] + (-w[1] * up[2] + w[2] * up[1]) * dt, up[1] + (-w[2] * up[0] + w[0] * up[2]) * dt, @@ -182,19 +185,32 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl m->rates[1] = w[0] * up[0] + w[1] * up[1] + w[2] * up[2]; m->rates[2] = w[0] * fwd[0] + w[1] * fwd[1] + w[2] * fwd[2]; const float sh = sens_h * (inv_h ? -1.0f : 1.0f), sv = -sens_v * (inv_v ? -1.0f : 1.0f); + /* the vertical aim is absolute: level is the pad lying flat (its face up), however it is held when the filter starts + (at launch, when Motion controls is turned on, on a pad switch or reconnect). It starts at the pad's tilt about its + x from flat, scaled; held rolled, at the nose's angle above level instead (roll does not change that), and the + tilt pull settles it once the pad is level. Before, it started at 0 in whatever pose the pad was in, and a pad held + tilted then aimed off by that tilt for good (Link-22). A new sensitivity or invert rescales it, so it keeps + matching the pad's tilt. */ + if (started) { + const float e = -up[2] > 1 ? 1 : -up[2] < -1 ? -1 : -up[2]; + 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); + m->tilt_sv = sv; + } m->pitch = hos_motion_wrap(m->pitch + m->rates[0] * sv * dt); - /* the vertical aim's drift: pulled gently toward the tilt gravity measures (the pad's tilt about its x axis since - the filter started, scaled the same way), so leftover bias or timing error cannot add up. Only while the pad's + /* the vertical aim's drift: pulled gently toward the tilt gravity measures (the pad's tilt about its x axis from + flat, scaled the same way), so leftover bias or timing error cannot add up. Only while the pad's x is about level (rolled under about 15 degrees): rolled, the tilt about the pad's own x moves faster than the 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) { + if (!started && 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]) */ /* (anchored so the aim stays where it is: the first time the pad is level may be after it moved while rolled, or held tilted when the filter started; sv is never 0, the menu's least sensitivity is 10%) */ - if (!m->tilt_ready || sv != m->tilt_sv) { /* (a new Motion sensitivity Y or Invert motion Y re-anchors too: - the aim stays put rather than sliding to the new scale) */ - m->tilt_acc = m->pitch / sv, m->tilt_sv = sv, m->tilt_ready = 1; + if (!m->tilt_ready) { /* (the tilt from flat itself: what the aim is measured from) */ + m->tilt_acc = tilt, m->tilt_ready = 1; } else { m->tilt_acc += hos_motion_wrap(tilt - m->tilt_last); } diff --git a/host/pad_sdl.c b/host/pad_sdl.c index f17adf53a..1e4d6c9c8 100644 --- a/host/pad_sdl.c +++ b/host/pad_sdl.c @@ -185,9 +185,9 @@ static void motion_poll(int64_t now, int count) { return; if (!motion_warned && motion.learned_t <= 0 && now - motion_start_ns > 30000000000LL) { motion_warned = 1; /* (a pad noisier than the stillness test allows: its drift is never taken off) */ - hos_log("hid: motion: \"%s\" has not read still for 30 s, so its gyroscope's drift is not learned yet (set it " - "down on a table for a second; if that does not help, its sensor is noisier than expected)", - hos_sdl_pad_name(pads[pick].gc)); + hos_log("hid: motion: \"%s\" has not been set down yet, so its gyroscope's drift is not learned: set it on a " + "table for a second (if it is on a table and this stays, its sensor may be noisier than the stillness " + "test allows)", hos_sdl_pad_name(pads[pick].gc)); } if (debug && now >= motion_log_ns) { /* (twice a second: what the game gets) */ motion_log_ns = now + 500000000LL; diff --git a/readme.md b/readme.md index b91983c98..500fc5ba3 100644 --- a/readme.md +++ b/readme.md @@ -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 says whether the connected pad has a gyroscope; Off leaves the game seeing a pad lying still. **Motion sensitivity X/Y** - scale the left-right and up-down aim (100% is the console's), **Invert motion X/Y** turn either direction around. Motion follows the pad used + scale the left-right and up-down aim (100% is the console's), **Invert motion X/Y** turn either direction around. Up and down are measured from the pad + lying flat, however it is held when motion starts. Motion follows the pad used last. The gyroscope's drift is learned while the pad rests on a table, so set it down for a second if the aim creeps. `HOS_MOTION_DEBUG=1` (in `hookshot_env.txt`) logs what the game gets twice a second. - **Layout preset:** Nintendo has A on the right face button, Xbox / PC and Swap Face put A on the bottom one. Changing diff --git a/tests/motion_test.c b/tests/motion_test.c index 9807ec6e0..d649cafdf 100644 --- a/tests/motion_test.c +++ b/tests/motion_test.c @@ -15,6 +15,7 @@ static int failures; static float su[3]; /* the true up, in the pad's frame */ static float offset[3]; /* the gyroscope's bias */ static float tremor; /* the noise's size, rad/s (each axis, uniform) */ +static float anoise; /* the accelerometer's, G (each axis, uniform; 0: a real sensor's least) */ static unsigned rng = 1; static float noise(void) { rng = rng * 1664525u + 1013904223u; @@ -34,8 +35,9 @@ static void run_dt(HosMotion *m, float gx, float gy, float gz, float seconds, fl const int n = (int)lroundf(seconds / dt); for (int i = 0; i < n; i++) { const float w[3] = {gx, gy, gz}; - const float a[3] = {su[0] * HOS_MOTION_G + 1e-5f * noise(), su[1] * HOS_MOTION_G + 1e-5f * noise(), - su[2] * HOS_MOTION_G + 1e-5f * noise()}; + const float an = (anoise > 0 ? anoise * HOS_MOTION_G : 1e-5f); + const float a[3] = {su[0] * HOS_MOTION_G + an * noise(), su[1] * HOS_MOTION_G + an * noise(), + su[2] * HOS_MOTION_G + an * noise()}; float g[3]; for (int k = 0; k < 3; k++) g[k] = w[k] + offset[k] + (tremor > 0 ? tremor : 1e-6f) * noise(); hos_motion_update(m, a, g, dt, sens_h, sens_v, inv_h, inv_v); @@ -56,7 +58,7 @@ static void start(HosMotion *m, float x, float y, float z) { /* a fresh filter hos_motion_reset(m); pose(x, y, z); offset[0] = offset[1] = offset[2] = 0; - tremor = 0; + tremor = 0, anoise = 0; } static void neutral(const HosMotion *m) { /* what the game folds into its recenter: always at rest */ @@ -91,9 +93,9 @@ int main(void) { run(&m, 0, 0, 0, 4, 1, 1, 0, 0); NEAR(m.bias[1], 0.2, 1e-3); - /* a noisier sensor (0.0067 rad/s on each axis, past the first threshold's 0.0058) still reads still on a table */ + /* a noisier sensor (0.0067 rad/s and 5 mG on each axis, past the first thresholds) still reads still on a table */ start(&m, FLAT); - offset[1] = 0.02f, tremor = 0.0116f; /* (uniform: 0.0067 rad/s RMS) */ + offset[1] = 0.02f, tremor = 0.0116f, anoise = 0.0087f; /* (uniform: 0.0067 rad/s and 5 mG RMS on each axis) */ run(&m, 0, 0, 0, 6, 1, 1, 0, 0); NEAR(m.bias[1], 0.02, 2e-3); @@ -142,12 +144,8 @@ int main(void) { case behind Link-22's "wrong after turning Motion controls on while not level" (hckrman101) */ for (int i = 0; i < 80 && su[0] < 0; i++) run(&m, 0, 0, 0.5236f, 0.02f, 1, 1, 0, 0); NEAR(su[0], 0, 0.02); - { - const float p0 = m.pitch; - NEAR(p0, -0.5, 0.08); - run(&m, 0, 0, 0, 3, 1, 1, 0, 0); - NEAR(m.pitch, p0, 0.02); - } + run(&m, 0, 0, 0, 3, 1, 1, 0, 0); + NEAR(m.pitch, -atan2f(-su[2], su[1]), 0.02); /* (the pad's tilt from flat, as it is now) */ /* a leftover bias the filter cannot learn (the pad in the hand) does not drift the vertical aim: 0.01 rad/s for a minute would be 0.6 rad of tilt summed open loop */ @@ -182,18 +180,51 @@ int main(void) { run(&m, 0, 0, 0, 3, 1, 2, 0, 0); NEAR(m.pitch, -1.0, 0.05); - /* Motion sensitivity Y or Invert motion Y changed while the pad is held tilted: the aim stays where it is (no slide - to the new scale), and later tilts move it at the new one */ + /* Motion sensitivity Y or Invert motion Y changed while the pad is held tilted: the aim goes at once to the tilt at + the new scale (it stays measured from level) and holds there, no slide; later tilts move it at the new one */ start(&m, FLAT); run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); run(&m, 1, 0, 0, 0.5f, 1, 1, 0, 0); NEAR(m.pitch, -0.5, 0.03); + run(&m, 0, 0, 0, 0.004f, 1, 2, 0, 0); + NEAR(m.pitch, -1.0, 0.05); run(&m, 0, 0, 0, 3, 1, 2, 0, 0); - NEAR(m.pitch, -0.5, 0.03); + NEAR(m.pitch, -1.0, 0.05); run(&m, 1, 0, 0, 0.25f, 1, 2, 0, 0); - NEAR(m.pitch, -1.0, 0.04); + NEAR(m.pitch, -1.5, 0.06); run(&m, 0, 0, 0, 3, 1, 2, 0, 1); - NEAR(m.pitch, -1.0, 0.04); + NEAR(m.pitch, 1.5, 0.06); + + /* the vertical aim is measured from level, whatever the pose when the filter starts: the same hold aims the same + after a start held rolled and pitched up as after a start flat (Link-22's start 3 against his starts 2 and 4) */ + { + start(&m, FLAT); + run(&m, 0, 0, 0, 0.3f, 1, 1, 0, 0); + run(&m, 1, 0, 0, 0.6f, 1, 1, 0, 0); /* (nose up 0.6 rad, from flat) */ + run(&m, 0, 0, 0, 2, 1, 1, 0, 0); + const float flat_start = m.pitch; + NEAR(flat_start, -0.6, 0.04); + /* the same pose (nose up 0.6) reached rolled: the filter starts there, the pad then rolls level */ + hos_motion_reset(&m); + offset[0] = offset[1] = offset[2] = 0, tremor = 0; + pose(0, cosf(0.6f), -sinf(0.6f)); /* (nose up 0.6, level) */ + { /* rolled 40 degrees about the pad's z (the nose's height stays) */ + const float c = cosf(0.7f), sn = sinf(0.7f); + const float x = su[0] * c - su[1] * sn, y = su[0] * sn + su[1] * c; + pose(x, y, su[2]); + } + run(&m, 0, 0, 0, 0.3f, 1, 1, 0, 0); + NEAR(m.pitch, -0.6, 0.04); + for (int i = 0; i < 80 && fabsf(su[0]) > 0.01f; i++) run(&m, 0, 0, su[0] > 0 ? -0.5236f : 0.5236f, 0.02f, 1, 1, 0, 0); + NEAR(su[0], 0, 0.02); + run(&m, 0, 0, 0, 3, 1, 1, 0, 0); + NEAR(m.pitch, flat_start, 0.05); + } + + /* a steady pitch with no tremor at all (a pad turned by a machine) is not still: its turn is not learned as bias */ + start(&m, FLAT); + run(&m, 0.2f, 0, 0, 3, 1, 1, 0, 0); + NEAR(m.bias[0], 0, 1e-3); /* past half a turn from the anchor at a sensitivity that is not a whole number: no jump (pitched forward 3.5 rad at 50%: the aim goes to -1.75 and stays) */ From 8e211deebd888e40cca4b3a5f247429ed624e3af Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:03:12 -0500 Subject: [PATCH 3/9] Motion controls: sensitivity and invert scale the tilt's change, not 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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- host/hos_motion.h | 29 ++++++++++++++++++----------- readme.md | 2 +- tests/motion_test.c | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/host/hos_motion.h b/host/hos_motion.h index 73f40573f..cba8ef358 100644 --- a/host/hos_motion.h +++ b/host/hos_motion.h @@ -11,8 +11,8 @@ they are neutral: the angles and the pitch rate go out zero, the acceleration at rest (0, 0, -1). The rates are the pad's own, measured about the world's axes: an up vector from a complementary filter (the gyro's prediction pulled toward the measured gravity while it reads 0.65 to 1.35 G), the gyro projected onto right - (pitch), up (yaw) and forward (roll). That makes the way the pad is held irrelevant: flat or upright, a turn toward - the ceiling is up. The gyroscope's resting rate (its bias) is learned while the pad is still (both sensors steady, not + (pitch), up (yaw) and forward (roll). That makes the way the pad is held irrelevant for the turn: flat or upright, a turn + toward the ceiling is up. The vertical aim itself is the pad's tilt from flat, as a Switch reports it. The gyroscope's resting rate (its bias) is learned while the pad is still (both sensors steady, not merely slow) and taken off, and the vertical aim is pulled gently toward the tilt gravity measures, so it cannot drift. Everything is in SDL's gamepad frame (+X right, +Y up out of the face buttons, +Z toward the player; m/s^2 and @@ -58,6 +58,8 @@ typedef struct { float tilt_acc; /* radians, that tilt from flat, unwrapped (so it scales by any sensitivity without a jump at a half turn) */ float tilt_sv; /* the vertical scale the aim is at (0 before the first reading): a new one rescales it */ + float tilt_ref; /* radians, the pad's tilt from flat when the filter started: the sensitivity and the invert scale + the tilt's change from it, and the aim there is the tilt itself */ int tilt_ready; float rates[3]; /* (for tests and logs) pitch, yaw, roll about the world's axes, rad/s */ HosMotionState out; @@ -185,18 +187,23 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl m->rates[1] = w[0] * up[0] + w[1] * up[1] + w[2] * up[2]; m->rates[2] = w[0] * fwd[0] + w[1] * fwd[1] + w[2] * fwd[2]; const float sh = sens_h * (inv_h ? -1.0f : 1.0f), sv = -sens_v * (inv_v ? -1.0f : 1.0f); - /* the vertical aim is absolute: level is the pad lying flat (its face up), however it is held when the filter starts - (at launch, when Motion controls is turned on, on a pad switch or reconnect). It starts at the pad's tilt about its - x from flat, scaled; held rolled, at the nose's angle above level instead (roll does not change that), and the - tilt pull settles it once the pad is level. Before, it started at 0 in whatever pose the pad was in, and a pad held - tilted then aimed off by that tilt for good (Link-22). A new sensitivity or invert rescales it, so it keeps - matching the pad's tilt. */ + /* the vertical aim is the pad's real tilt, as a Switch reports it: measured from the pad lying flat (its face up), + however it is held when the filter starts (at launch, when Motion controls is turned on, on a pad switch or + reconnect); the game recenters from it each time it aims. It starts at the pad's tilt about its x from flat (held + rolled, the nose's angle above level instead: roll does not change that), and the tilt pull settles it once the + pad is level. Before, it started at 0 in whatever pose the pad was in, and a pad held tilted then aimed off by + that tilt for good (Link-22). The sensitivity and the invert scale only the tilt's change from where the filter + started (tilt_ref), so at rest in that hold the aim is the real tilt at any setting; scaling the whole angle from + flat moved a normal hold of 0.7 rad to vertical at 200% and mirrored it with the invert (hckrman101). A new + 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->pitch = hos_motion_wrap(sv * (fabsf(up[0]) < HOS_MOTION_TILT_ROLL ? atan2f(-up[2], up[1]) : asinf(e))); + m->tilt_ref = fabsf(up[0]) < HOS_MOTION_TILT_ROLL ? atan2f(-up[2], up[1]) : asinf(e); + m->pitch = hos_motion_wrap(-m->tilt_ref); 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); + m->pitch = hos_motion_wrap(m->tilt_ready ? -m->tilt_ref + (m->tilt_acc - m->tilt_ref) * sv + : -m->tilt_ref + (m->pitch + m->tilt_ref) / m->tilt_sv * sv); m->tilt_sv = sv; } m->pitch = hos_motion_wrap(m->pitch + m->rates[0] * sv * dt); @@ -215,7 +222,7 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl m->tilt_acc += hos_motion_wrap(tilt - m->tilt_last); } m->tilt_last = tilt; - const float target = hos_motion_wrap(m->tilt_acc * sv); + const float target = hos_motion_wrap(-m->tilt_ref + (m->tilt_acc - m->tilt_ref) * sv); m->pitch = hos_motion_wrap(m->pitch + hos_motion_wrap(target - m->pitch) * (dt / (HOS_MOTION_TILT_S + dt))); } hos_motion_rest(&m->out, m->pitch); diff --git a/readme.md b/readme.md index 500fc5ba3..17c6a3971 100644 --- a/readme.md +++ b/readme.md @@ -271,7 +271,7 @@ and the right one closes the menu. Your choices are saved in `hookshot_options.i - **Rumble strength** scales the controller's rumble: 100% is the game's own, Off turns it off. The game's HD rumble goes to a controller's heavy and light motors by its frequency. - **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 + Switch Pro controllers and pads in Switch mode, DualSense and DualShock 4 (through SDL). Its row says whether the connected pad has a gyroscope; Off leaves the game seeing a pad lying still. **Motion sensitivity X/Y** scale the left-right and up-down aim (100% is the console's), **Invert motion X/Y** turn either direction around. Up and down are measured from the pad lying flat, however it is held when motion starts. Motion follows the pad used diff --git a/tests/motion_test.c b/tests/motion_test.c index d649cafdf..3651cf56d 100644 --- a/tests/motion_test.c +++ b/tests/motion_test.c @@ -221,6 +221,22 @@ int main(void) { NEAR(m.pitch, flat_start, 0.05); } + /* the sensitivity and the invert scale the tilt's change from where the filter started, not the whole angle from + flat: a hold 0.7 rad up aims there at 200% and with the invert too (not near vertical, nor mirrored), and a + further 0.1 rad up moves the aim 0.2 at 200%, and 0.1 the other way inverted (hckrman101) */ + start(&m, 0, cosf(0.7f), -sinf(0.7f)); + run(&m, 0, 0, 0, 0.5f, 1, 2, 0, 0); + NEAR(m.pitch, -0.7, 0.03); + run(&m, 1, 0, 0, 0.1f, 1, 2, 0, 0); + run(&m, 0, 0, 0, 2, 1, 2, 0, 0); + NEAR(m.pitch, -0.9, 0.04); + start(&m, 0, cosf(0.7f), -sinf(0.7f)); + run(&m, 0, 0, 0, 0.5f, 1, 1, 0, 1); + NEAR(m.pitch, -0.7, 0.03); + run(&m, 1, 0, 0, 0.1f, 1, 1, 0, 1); + run(&m, 0, 0, 0, 2, 1, 1, 0, 1); + NEAR(m.pitch, -0.6, 0.04); + /* a steady pitch with no tremor at all (a pad turned by a machine) is not still: its turn is not learned as bias */ start(&m, FLAT); run(&m, 0.2f, 0, 0, 3, 1, 1, 0, 0); From 775ca01d671bccec535642ca9b5774ab8b892033 Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:23:54 -0500 Subject: [PATCH 4/9] Motion controls: the playing hold taken again when the pad is picked up 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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- host/hos_motion.h | 41 ++++++++++++++++++++++++++++++++++++----- readme.md | 5 +++-- tests/motion_test.c | 21 +++++++++++++++++++-- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/host/hos_motion.h b/host/hos_motion.h index cba8ef358..eb81ffeac 100644 --- a/host/hos_motion.h +++ b/host/hos_motion.h @@ -58,8 +58,12 @@ typedef struct { float tilt_acc; /* radians, that tilt from flat, unwrapped (so it scales by any sensitivity without a jump at a half turn) */ float tilt_sv; /* the vertical scale the aim is at (0 before the first reading): a new one rescales it */ - float tilt_ref; /* radians, the pad's tilt from flat when the filter started: the sensitivity and the invert scale - the tilt's change from it, and the aim there is the tilt itself */ + float tilt_ref; /* radians, the pad's playing hold (its tilt from flat): the sensitivity and the invert scale the + tilt's change from it, and the aim there is the tilt itself. Taken when the filter starts, and + again (eased in) once the pad is picked up off a table and held */ + float ref_to; /* (the hold tilt_ref eases to; ref_easing while it does) */ + int ref_easing, was_set_down; + float held_t; /* seconds held steady in the hand since it was last set down */ int tilt_ready; float rates[3]; /* (for tests and logs) pitch, yaw, roll about the world's axes, rad/s */ HosMotionState out; @@ -78,6 +82,9 @@ typedef struct { #define HOS_MOTION_WINDOW_S 0.2f /* (the short window's time constant) */ #define HOS_MOTION_DT_MAX 0.1f /* (a longer gap, a hitch, counts as this long) */ #define HOS_MOTION_TILT_S 1.0f /* (the vertical aim's pull toward the measured tilt) */ +#define HOS_MOTION_HELD_GYRO 0.05f /* (held steady in a hand: the gyro's spread under this, rad/s, summed) */ +#define HOS_MOTION_HELD_S 0.5f /* (... for this long, after being set down: the new playing hold) */ +#define HOS_MOTION_REF_S 0.3f /* (the new hold eased in over about this) */ #define HOS_MOTION_TILT_ROLL 0.25f /* (... only while |up[0]| is under this: about 15 degrees of roll) */ static inline void hos_motion_rest(HosMotionState *o, float pitch) { @@ -118,7 +125,8 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl const float ag = alen / HOS_MOTION_G; /* the bias: learned from the short window's mean while the pad is still (both sensors steady, about 1 G) */ if (!m->stats_ready) { - memcpy(m->gmean, gyro, sizeof m->gmean), m->gvar = 1, m->amean = ag, m->avar = 1; + memcpy(m->gmean, gyro, sizeof m->gmean), m->gvar = 0.01f, m->amean = ag, m->avar = 0.01f; /* (not still until + the window has seen it) */ for (int i = 0; i < 3; i++) m->avec[i] = accel[i] / HOS_MOTION_G; m->stats_ready = 1; } else if (dt > 0) { @@ -216,8 +224,9 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl const float tilt = atan2f(-up[2], up[1]); /* (it grows at the x rate: d/dt = w[0]) */ /* (anchored so the aim stays where it is: the first time the pad is level may be after it moved while rolled, or held tilted when the filter started; sv is never 0, the menu's least sensitivity is 10%) */ - if (!m->tilt_ready) { /* (the tilt from flat itself: what the aim is measured from) */ - m->tilt_acc = tilt, m->tilt_ready = 1; + if (!m->tilt_ready) { /* (the tilt from flat itself, on tilt_ref's branch: near face-down the two could + be a turn apart, and at a sensitivity that is no whole number that is half a turn) */ + m->tilt_acc = m->tilt_ref + hos_motion_wrap(tilt - m->tilt_ref), m->tilt_ready = 1; } else { m->tilt_acc += hos_motion_wrap(tilt - m->tilt_last); } @@ -225,6 +234,28 @@ static inline int hos_motion_update(HosMotion *m, const float accel[3], const fl const float target = hos_motion_wrap(-m->tilt_ref + (m->tilt_acc - m->tilt_ref) * sv); m->pitch = hos_motion_wrap(m->pitch + hos_motion_wrap(target - m->pitch) * (dt / (HOS_MOTION_TILT_S + dt))); } + /* the playing hold, again: a pad that was set down (still on a table) and is then picked up and held steady for a + moment has its new hold eased in as tilt_ref, so a start on the desk does not leave the sensitivity and the + invert measured from flat (hckrman101). The aim moves with tilt_ref by -(1 + sv) per radian: nothing at the + defaults (sv = -1), else an ease to the real tilt at rest in the new hold. */ + if (m->still_t >= HOS_MOTION_STILL_S) m->was_set_down = 1, m->held_t = 0; + else { + /* (held: the gyro's spread a hand's, and the pad not turning, so a smooth sweep is not a hold) */ + float r = 0; + for (int i = 0; i < 3; i++) r += (m->gmean[i] - m->bias[i]) * (m->gmean[i] - m->bias[i]); + const int held = m->gvar < HOS_MOTION_HELD_GYRO * HOS_MOTION_HELD_GYRO && r < HOS_MOTION_HELD_GYRO * HOS_MOTION_HELD_GYRO; + if (m->was_set_down && m->tilt_ready && held && fabsf(up[0]) < HOS_MOTION_TILT_ROLL) m->held_t += dt; + else if (!held) m->held_t = 0; + } + if (m->was_set_down && m->held_t >= HOS_MOTION_HELD_S) { + m->ref_to = m->tilt_acc, m->ref_easing = 1, m->was_set_down = 0, m->held_t = 0; + } + if (m->ref_easing && dt > 0) { + const float d = (m->ref_to - m->tilt_ref) * (dt / (HOS_MOTION_REF_S + dt)); + m->tilt_ref += d; + m->pitch = hos_motion_wrap(m->pitch - (1 + sv) * d); + if (fabsf(m->ref_to - m->tilt_ref) < 1e-4f) m->ref_easing = 0; + } hos_motion_rest(&m->out, m->pitch); m->out.gyro[1] = m->rates[1] * sh / HOS_MOTION_TAU; return 1; diff --git a/readme.md b/readme.md index 17c6a3971..b12bddf98 100644 --- a/readme.md +++ b/readme.md @@ -273,8 +273,9 @@ 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). Its row says whether the connected pad has a gyroscope; Off leaves the game seeing a pad lying still. **Motion sensitivity X/Y** - scale the left-right and up-down aim (100% is the console's), **Invert motion X/Y** turn either direction around. Up and down are measured from the pad - lying flat, however it is held when motion starts. Motion follows the pad used + scale the left-right and up-down aim (100% is the console's), **Invert motion X/Y** turn either direction around. Up and down follow the pad's real tilt, however it + is held when motion starts; Motion sensitivity Y and Invert motion Y scale the tilt's change from your playing hold + (taken again a moment after you pick the pad up off a table). Motion follows the pad used last. The gyroscope's drift is learned while the pad rests on a table, so set it down for a second if the aim creeps. `HOS_MOTION_DEBUG=1` (in `hookshot_env.txt`) logs what the game gets twice a second. - **Layout preset:** Nintendo has A on the right face button, Xbox / PC and Swap Face put A on the bottom one. Changing diff --git a/tests/motion_test.c b/tests/motion_test.c index 3651cf56d..4e9c53604 100644 --- a/tests/motion_test.c +++ b/tests/motion_test.c @@ -180,10 +180,12 @@ int main(void) { run(&m, 0, 0, 0, 3, 1, 2, 0, 0); NEAR(m.pitch, -1.0, 0.05); - /* Motion sensitivity Y or Invert motion Y changed while the pad is held tilted: the aim goes at once to the tilt at - the new scale (it stays measured from level) and holds there, no slide; later tilts move it at the new one */ + /* Motion sensitivity Y or Invert motion Y changed while the pad is held tilted (in the hand: a hand's tremor): the + aim goes at once to the tilt's change at the new scale and holds there, no slide; later tilts move it at the new + one */ start(&m, FLAT); run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); + tremor = 0.03f; run(&m, 1, 0, 0, 0.5f, 1, 1, 0, 0); NEAR(m.pitch, -0.5, 0.03); run(&m, 0, 0, 0, 0.004f, 1, 2, 0, 0); @@ -237,6 +239,21 @@ int main(void) { run(&m, 0, 0, 0, 2, 1, 1, 0, 1); NEAR(m.pitch, -0.6, 0.04); + /* started flat on the desk, then picked up and held 0.7 rad up: the new hold is taken as the playing hold, so at + 200% and with the invert the aim at rest is the real tilt there too, not -1.4 or a mirrored +0.7 (hckrman101) */ + for (int inv = 0; inv < 2; inv++) { + const float sens = inv ? 1.0f : 2.0f; + start(&m, FLAT); + run(&m, 0, 0, 0, 1.5f, 1, sens, 0, inv); /* (on the desk: set down) */ + tremor = 0.03f; + run(&m, 1, 0, 0, 0.7f, 1, sens, 0, inv); /* (picked up, nose 0.7 up) */ + run(&m, 0, 0, 0, 4, 1, sens, 0, inv); /* (held there) */ + NEAR(m.pitch, -0.7, 0.05); + run(&m, 1, 0, 0, 0.1f, 1, sens, 0, inv); /* (then aimed 0.1 further up) */ + run(&m, 0, 0, 0, 0.2f, 1, sens, 0, inv); + NEAR(m.pitch, inv ? -0.6 : -0.9, 0.05); + } + /* a steady pitch with no tremor at all (a pad turned by a machine) is not still: its turn is not learned as bias */ start(&m, FLAT); run(&m, 0.2f, 0, 0, 3, 1, 1, 0, 0); From 5e2f9047054babb282ca2240413958c5d08e5fc7 Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:45:40 -0500 Subject: [PATCH 5/9] Motion controls: the full six-axis state, as Eden builds it 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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- host/hos_motion.h | 342 +++++++++++++++++++------------------------- host/nn_hid.c | 2 +- host/pad_sdl.c | 10 +- readme.md | 5 +- tests/motion_test.c | 304 +++++++++++++-------------------------- 5 files changed, 251 insertions(+), 412 deletions(-) diff --git a/host/hos_motion.h b/host/hos_motion.h index eb81ffeac..72f34f8a7 100644 --- a/host/hos_motion.h +++ b/host/hos_motion.h @@ -1,22 +1,26 @@ -/* (hookshot) A controller's motion sensors as OoT reads its six-axis state (host/pad_sdl.c fills it from SDL, - host/nn_hid.c hands it to the game's GetSixAxisSensorStates). Header-only, no SDL or Windows, so +/* (hookshot) A controller's motion sensors as the Switch reports its six-axis state (host/pad_sdl.c fills it from + SDL, host/nn_hid.c hands it to the game's GetSixAxisSensorStates). Header-only, no SDL or Windows, so tests/test_motion.py runs it as it is. - OoT does not read the six-axis state as a plain sensor. What it aims with was found by spluton (issue 112, "Gyro - aiming from SDL pads", tested on a DualSense) and corrected by Link-22 (the yaw rate's unit, tested on a GameSir T3 - Pro in Switch Pro mode, and the same feel as Eden on that pad): - - the horizontal aim is the yaw rate, in the angular velocity's y slot, in turns a second (SDL's rad/s over 2 pi); - - the vertical aim is the direction matrix, as a pure tilt about x (Rx(pitch)), the pitch rate integrated on its own; - - the game folds the angles, the pitch rate and the acceleration into an aim recenter that cancels the tilt unless - they are neutral: the angles and the pitch rate go out zero, the acceleration at rest (0, 0, -1). - The rates are the pad's own, measured about the world's axes: an up vector from a complementary filter (the gyro's - prediction pulled toward the measured gravity while it reads 0.65 to 1.35 G), the gyro projected onto right - (pitch), up (yaw) and forward (roll). That makes the way the pad is held irrelevant for the turn: flat or upright, a turn - toward the ceiling is up. The vertical aim itself is the pad's tilt from flat, as a Switch reports it. The gyroscope's resting rate (its bias) is learned while the pad is still (both sensors steady, not - merely slow) and taken off, and the vertical aim is pulled gently toward the tilt gravity measures, so it cannot drift. + This is Eden's (and yuzu's) motion, the emulators OoT aims right with: SDL's readings turned into the Switch's frame + and units, and the state built as the console builds it, so the game gets what it gets on hardware and does its own + aiming (it recenters on the camera each time it aims; how the pad is held, in bed or at a desk, does not matter): + - acceleration in G and angular velocity in turns a second, in the Switch's frame (Eden's sdl_driver.cpp: + x = -X, y = Z, z = -Y for the accelerometer, x = X, y = -Z, z = Y for the gyroscope, SDL's +X right, +Y out of the + face buttons, +Z toward the player), so a pad lying face up reads (0, 0, -1) G; + - the rotation: the angular velocity summed (turns); + - the direction: the pad's orientation as a 3x3 matrix, from Mahony's filter (the gyroscope integrated as a + quaternion, pulled toward the measured gravity: kp 0.3, ki 0.005), Eden's motion_input.cpp line for line. + The gyroscope's resting rate (its bias) is learned while the pad is still on a table (both sensors steady) and + taken off, and rates under 0.007 turns a second count as none, as the console's standard zero-drift mode does. + Motion sensitivity X/Y and Invert motion X/Y scale the angular velocity before it is used: its part about the + world's up (the turn, X) and the rest (the tilt, Y). At 100% and not inverted the state is Eden's own. Earlier + versions sent a partial state (a yaw rate and a tilt matrix, the rest neutral, spluton's and Link-22's findings in + issue 112), and every way the pad could be held at the start needed its own case; the full state needs none. - Everything is in SDL's gamepad frame (+X right, +Y up out of the face buttons, +Z toward the player; m/s^2 and - rad/s). The defaults are the two testers': the yaw rate as is (turning left aims left), the pitch rate negated. */ + The filter, the frame and the orientation matrix follow Eden's src/hid_core/frontend/motion_input.cpp and + src/input_common/drivers/sdl_driver.cpp (Copyright 2020 yuzu Emulator Project, GPL-2.0-or-later; Copyright 2026 Eden + Emulator Project, GPL-3.0-or-later), as this project is GPL-3.0. */ #pragma once #include #include @@ -26,10 +30,10 @@ #define HOS_MOTION_TAU 6.28318531f typedef struct { - float accel[3]; /* G (at rest, always: the game folds it into its recenter) */ - float gyro[3]; /* turns a second: the yaw rate in y, x and z zero */ - float rotation[3]; /* turns: zero (the game folds them into its recenter) */ - float direction[9]; /* rows: Rx(pitch), the vertical aim */ + float accel[3]; /* G, the Switch's frame */ + float gyro[3]; /* turns a second */ + float rotation[3]; /* turns */ + float direction[9]; /* rows: the orientation */ } HosMotionState; /* one reading's state as GetSixAxisSensorStates hands it out: n counts the readings, dt_ns is the time since the one @@ -41,222 +45,166 @@ typedef struct { #define HOS_MOTION_HISTORY 32 typedef struct { - float bias[3]; /* rad/s, SDL's axes */ - float still_t; /* seconds the pad has been still (bias learning starts after HOS_MOTION_STILL_S) */ - float learned_t; /* seconds of stillness learned from so far (fast at first, then slowly) */ - float gmean[3], gvar; /* the gyro's short-window mean (rad/s) and variance (summed over its axes) */ - float amean, avar; /* the accelerometer's magnitude's mean, and its vector's variance summed over the axes (G): - a pad turning at a steady rate keeps its magnitude but not its direction */ - float avec[3]; /* (the accelerometer's vector's mean, G) */ - int stats_ready; - float last_accel[3], last_gyro[3]; /* (the last reading: a repeat of it is no new reading) */ - float pending_dt; /* (time from repeats, carried into the next new reading) */ - float up[3]; /* the world's up in the pad's frame (unit) */ - int up_ready; - float pitch; /* radians, the vertical aim's tilt */ - float tilt_last; /* radians, the pad's tilt about its x axis at the last level reading */ - float tilt_acc; /* radians, that tilt from flat, unwrapped (so it scales by any sensitivity without a jump at a - half turn) */ - float tilt_sv; /* the vertical scale the aim is at (0 before the first reading): a new one rescales it */ - float tilt_ref; /* radians, the pad's playing hold (its tilt from flat): the sensitivity and the invert scale the - tilt's change from it, and the aim there is the tilt itself. Taken when the filter starts, and - again (eased in) once the pad is picked up off a table and held */ - float ref_to; /* (the hold tilt_ref eases to; ref_easing while it does) */ - int ref_easing, was_set_down; - float held_t; /* seconds held steady in the hand since it was last set down */ - int tilt_ready; - float rates[3]; /* (for tests and logs) pitch, yaw, roll about the world's axes, rad/s */ + float quat[4]; /* x, y, z, w (Eden's order) */ + float real_error[3], integral_error[3]; + float bias[3]; /* turns a second, the Switch's frame */ + float learned_t; /* seconds of stillness learned from (fast at first, then slowly) */ + float still_t; /* seconds still */ + float gmean[3], gvar, avec[3], avar, amean; /* the short window's means and variances (stillness) */ + int stats_ready, only_accelerometer, reset_counter; + float last_accel[3], last_gyro[3], pending_dt; + float up[3]; /* the world's up in the Switch's frame (from the accelerometer, smoothed: for the split of + the angular velocity into turn and tilt) */ HosMotionState out; } HosMotion; -/* stillness: the gyro's spread under HOS_MOTION_STILL_GYRO rad/s and the accelerometer's vector's under - HOS_MOTION_STILL_ACCEL G, about 1 G, for HOS_MOTION_STILL_S. Both are summed over the three axes: 0.015 rad/s is - about 0.009 on each and 0.0173 G about 0.01 G on each, room for noisier third-party sensors. What keeps aiming from - being learned as bias is the hand: its tremor alone is several times the gyro's threshold. The accelerometer's - vector adds a steady pitch or roll faster than about 5 degrees a second (its direction moves past the window's lag); - a steady turn about gravity leaves it unchanged, so only a pad on a turntable, untouched, could be mistaken. An - offset of any size is learned on a table. */ -#define HOS_MOTION_STILL_GYRO 0.015f -#define HOS_MOTION_STILL_ACCEL 0.0173f /* (0.01 G on each axis, summed over three like the gyro's) */ +#define HOS_MOTION_STILL_GYRO 0.015f /* rad/s, summed over three axes (about 0.009 on each) */ +#define HOS_MOTION_STILL_ACCEL 0.0173f /* G, the same way (0.01 on each) */ #define HOS_MOTION_STILL_S 0.5f -#define HOS_MOTION_WINDOW_S 0.2f /* (the short window's time constant) */ -#define HOS_MOTION_DT_MAX 0.1f /* (a longer gap, a hitch, counts as this long) */ -#define HOS_MOTION_TILT_S 1.0f /* (the vertical aim's pull toward the measured tilt) */ -#define HOS_MOTION_HELD_GYRO 0.05f /* (held steady in a hand: the gyro's spread under this, rad/s, summed) */ -#define HOS_MOTION_HELD_S 0.5f /* (... for this long, after being set down: the new playing hold) */ -#define HOS_MOTION_REF_S 0.3f /* (the new hold eased in over about this) */ -#define HOS_MOTION_TILT_ROLL 0.25f /* (... only while |up[0]| is under this: about 15 degrees of roll) */ +#define HOS_MOTION_WINDOW_S 0.2f +#define HOS_MOTION_DT_MAX 0.1f /* (Eden ignores a longer gap) */ +#define HOS_MOTION_THRESHOLD 0.007f /* turns a second: less is none (the console's standard zero-drift mode) */ -static inline void hos_motion_rest(HosMotionState *o, float pitch) { +static inline void hos_motion_rest(HosMotionState *o) { memset(o, 0, sizeof *o); o->accel[2] = -1; - const float c = cosf(pitch), s = sinf(pitch); - const float r[9] = {1, 0, 0, 0, c, -s, 0, s, c}; - memcpy(o->direction, r, sizeof r); + o->direction[0] = o->direction[4] = o->direction[8] = 1; } static inline void hos_motion_reset(HosMotion *m) { memset(m, 0, sizeof *m); - m->up[1] = 1; - hos_motion_rest(&m->out, 0); + m->quat[2] = -1; /* (Eden's ResetQuaternion: {0, 0, -1, 0}) */ + m->only_accelerometer = 1; + m->up[2] = 1; + hos_motion_rest(&m->out); } -static inline float hos_motion_wrap(float a) { - while (a > 3.14159265f) a -= HOS_MOTION_TAU; - while (a < -3.14159265f) a += HOS_MOTION_TAU; - return a; +static inline float hos_motion_len(const float v[3]) { return sqrtf(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); } + +/* Mahony's filter on the quaternion, Eden's UpdateOrientation (period: seconds; gain: the feedback's weight, 1 for a + reading, 10 for SetOrientationFromAccelerometer's settling) */ +static inline void hos_motion_mahony(HosMotion *m, const float accel[3], const float gyro[3], float period, float gain) { + float q1 = m->quat[3], q2 = m->quat[0], q3 = m->quat[1], q4 = m->quat[2]; + const float alen = hos_motion_len(accel); + float rg[3] = {gyro[1] * HOS_MOTION_TAU, -gyro[0] * HOS_MOTION_TAU, -gyro[2] * HOS_MOTION_TAU}; + if (m->only_accelerometer || gain > 1) rg[0] = rg[1] = rg[2] = 0; + if ((alen >= 0.75f && alen <= 1.25f) || gain > 1) { + const float ax = -accel[0] / alen, ay = accel[1] / alen, az = -accel[2] / alen; + const float vx = 2.0f * (q2 * q4 - q1 * q3); + const float vy = 2.0f * (q1 * q2 + q3 * q4); + const float vz = q1 * q1 - q2 * q2 - q3 * q3 + q4 * q4; + const float e[3] = {az * vx - ax * vz, ay * vz - az * vy, ax * vy - ay * vx}; + memcpy(m->real_error, e, sizeof e); + if (gain <= 1 && hos_motion_len(e) >= 0.05f) + for (int i = 0; i < 3; i++) m->integral_error[i] += e[i]; + else if (gain <= 1) + memset(m->integral_error, 0, sizeof m->integral_error); + const float kp = 0.3f, ki = 0.005f; + const float wp = gain > 1 ? 10 : m->only_accelerometer ? 35 : 1, wi = gain > 1 ? 5 : m->only_accelerometer ? 10 : 1; + for (int i = 0; i < 3; i++) rg[i] += wp * kp * e[i] + wi * ki * m->integral_error[i]; + } + const float gx = rg[1], gy = rg[0], gz = rg[2]; + const float pa = q2, pb = q3, pc = q4; + q1 = q1 + (-q2 * gx - q3 * gy - q4 * gz) * (0.5f * period); + q2 = pa + (q1 * gx + pb * gz - pc * gy) * (0.5f * period); + q3 = pb + (q1 * gy - pa * gz + pc * gx) * (0.5f * period); + q4 = pc + (q1 * gz + pa * gy - pb * gx) * (0.5f * period); + const float n = sqrtf(q1 * q1 + q2 * q2 + q3 * q3 + q4 * q4); + if (n > 0) m->quat[3] = q1 / n, m->quat[0] = q2 / n, m->quat[1] = q3 / n, m->quat[2] = q4 / n; +} + +/* the orientation matrix, Eden's GetOrientation (its quaternion's matrix, with its sign changes) */ +static inline void hos_motion_direction(const HosMotion *m, float d[9]) { + const float x = -m->quat[1], y = -m->quat[0], z = -m->quat[3], w = -m->quat[2]; + const float x2 = x * x, y2 = y * y, z2 = z * z, xy = x * y, wz = w * z, xz = x * z, wy = w * y, yz = y * z, wx = w * x; + const float mm[9] = {1 - 2 * (y2 + z2), 2 * (xy + wz), 2 * (xz - wy), 2 * (xy - wz), 1 - 2 * (x2 + z2), 2 * (yz + wx), + 2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (x2 + y2)}; + d[0] = mm[0], d[1] = mm[1], d[2] = -mm[2]; + d[3] = mm[3], d[4] = mm[4], d[5] = -mm[5]; + d[6] = -mm[6], d[7] = -mm[7], d[8] = mm[8]; } /* One reading: SDL's accelerometer (m/s^2) and gyroscope (rad/s), dt seconds after the last one. sens_h and sens_v - scale the horizontal and the vertical aim (1: as the pad turns); inv_h and inv_v turn them the other way. Returns 0 - for a repeat of the last reading (no new reading: its time goes into the next), 1 otherwise. */ -static inline int hos_motion_update(HosMotion *m, const float accel[3], const float gyro[3], float dt, float sens_h, - float sens_v, int inv_h, int inv_v) { + scale the turn and the tilt (1: as the pad turns); inv_h and inv_v turn them the other way. Returns 0 for a repeat + of the last reading (no new reading: its time goes into the next), 1 otherwise. */ +static inline int hos_motion_update(HosMotion *m, const float accel_sdl[3], const float gyro_sdl[3], float dt, + float sens_h, float sens_v, int inv_h, int inv_v) { if (!(dt >= 0)) dt = 0; - if (m->stats_ready && !memcmp(accel, m->last_accel, sizeof m->last_accel) && - !memcmp(gyro, m->last_gyro, sizeof m->last_gyro)) { + if (m->stats_ready && !memcmp(accel_sdl, m->last_accel, sizeof m->last_accel) && + !memcmp(gyro_sdl, m->last_gyro, sizeof m->last_gyro)) { m->pending_dt += dt; return 0; } - memcpy(m->last_accel, accel, sizeof m->last_accel), memcpy(m->last_gyro, gyro, sizeof m->last_gyro); + memcpy(m->last_accel, accel_sdl, sizeof m->last_accel), memcpy(m->last_gyro, gyro_sdl, sizeof m->last_gyro); dt += m->pending_dt, m->pending_dt = 0; - if (dt > HOS_MOTION_DT_MAX) dt = HOS_MOTION_DT_MAX; /* (a hitch: its time, up to a limit) */ - const float alen = sqrtf(accel[0] * accel[0] + accel[1] * accel[1] + accel[2] * accel[2]); - const float ag = alen / HOS_MOTION_G; - /* the bias: learned from the short window's mean while the pad is still (both sensors steady, about 1 G) */ + /* the Switch's frame and units (Eden's sdl_driver.cpp), clamped as Eden clamps them */ + float a[3] = {-accel_sdl[0] / HOS_MOTION_G, accel_sdl[2] / HOS_MOTION_G, -accel_sdl[1] / HOS_MOTION_G}; + float g[3] = {gyro_sdl[0] / HOS_MOTION_TAU, -gyro_sdl[2] / HOS_MOTION_TAU, gyro_sdl[1] / HOS_MOTION_TAU}; + for (int i = 0; i < 3; i++) { + a[i] = a[i] < -7 ? -7 : a[i] > 7 ? 7 : a[i]; + } + const float alen = hos_motion_len(a); + /* stillness: the short window's spread of both sensors (rad/s and G, summed over the axes), about 1 G */ if (!m->stats_ready) { - memcpy(m->gmean, gyro, sizeof m->gmean), m->gvar = 0.01f, m->amean = ag, m->avar = 0.01f; /* (not still until - the window has seen it) */ - for (int i = 0; i < 3; i++) m->avec[i] = accel[i] / HOS_MOTION_G; - m->stats_ready = 1; + memcpy(m->gmean, g, sizeof m->gmean), memcpy(m->avec, a, sizeof m->avec); + m->gvar = 0.01f, m->avar = 0.01f, m->amean = alen, m->stats_ready = 1; } else if (dt > 0) { const float k = dt / (HOS_MOTION_WINDOW_S + dt); - float gv = 0; - for (int i = 0; i < 3; i++) { - const float d = gyro[i] - m->gmean[i]; - m->gmean[i] += d * k; - gv += d * d; - } - m->gvar += (gv - m->gvar) * k; - m->amean += (ag - m->amean) * k; - float av = 0; + float gv = 0, av = 0; for (int i = 0; i < 3; i++) { - const float d = accel[i] / HOS_MOTION_G - m->avec[i]; - m->avec[i] += d * k; - av += d * d; + const float dg = (g[i] - m->gmean[i]) * HOS_MOTION_TAU, da = a[i] - m->avec[i]; + m->gmean[i] += (g[i] - m->gmean[i]) * k, m->avec[i] += da * k; + gv += dg * dg, av += da * da; } - m->avar += (av - m->avar) * k; + m->gvar += (gv - m->gvar) * k, m->avar += (av - m->avar) * k, m->amean += (alen - m->amean) * k; } const int still = m->gvar < HOS_MOTION_STILL_GYRO * HOS_MOTION_STILL_GYRO && m->avar < HOS_MOTION_STILL_ACCEL * HOS_MOTION_STILL_ACCEL && m->amean > 0.9f && m->amean < 1.1f; m->still_t = still ? m->still_t + dt : 0; if (m->still_t >= HOS_MOTION_STILL_S && dt > 0) { - const float k = dt / ((m->learned_t < 2 ? 0.25f : 3.0f) + dt); /* (fast for the first 2 s still, then slowly) */ + const float k = dt / ((m->learned_t < 2 ? 0.25f : 3.0f) + dt); for (int i = 0; i < 3; i++) m->bias[i] += (m->gmean[i] - m->bias[i]) * k; m->learned_t += dt; } - const float w[3] = {gyro[0] - m->bias[0], gyro[1] - m->bias[1], gyro[2] - m->bias[2]}; - /* up: the gyro's prediction (a world vector seen from a frame turning at w turns the other way), pulled toward - the measured gravity while it is about 1 G */ - const int valid = alen > 0.65f * HOS_MOTION_G && alen < 1.35f * HOS_MOTION_G; - float *up = m->up; - int started = 0; - if (!m->up_ready) { - if (valid) { - for (int i = 0; i < 3; i++) up[i] = accel[i] / alen; - m->up_ready = 1, started = 1; - } - } else { - float p[3] = {up[0] + (-w[1] * up[2] + w[2] * up[1]) * dt, up[1] + (-w[2] * up[0] + w[0] * up[2]) * dt, - up[2] + (-w[0] * up[1] + w[1] * up[0]) * dt}; - float n = sqrtf(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]); - if (n > 0.001f) { - for (int i = 0; i < 3; i++) p[i] /= n; - if (valid) { - const float k = dt / (0.5f + dt); /* (a half-second pull) */ - for (int i = 0; i < 3; i++) p[i] += (accel[i] / alen - p[i]) * k; - n = sqrtf(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]); - if (n > 0.001f) for (int i = 0; i < 3; i++) p[i] /= n; - } - memcpy(up, p, sizeof p); - } + for (int i = 0; i < 3; i++) { + g[i] -= m->bias[i]; + g[i] = g[i] < -5 ? -5 : g[i] > 5 ? 5 : g[i]; } - /* right: the pad's own x, level (gravity taken out); when the pad points x up, its z instead */ - float right[3] = {1 - up[0] * up[0], -up[0] * up[1], -up[0] * up[2]}; - float rl = sqrtf(right[0] * right[0] + right[1] * right[1] + right[2] * right[2]); - if (rl < 0.1f) { - right[0] = -up[0] * up[2], right[1] = -up[1] * up[2], right[2] = 1 - up[2] * up[2]; - rl = sqrtf(right[0] * right[0] + right[1] * right[1] + right[2] * right[2]); + if (hos_motion_len(g) < HOS_MOTION_THRESHOLD) g[0] = g[1] = g[2] = 0; + else m->only_accelerometer = 0; + /* the turn and the tilt: the angular velocity's part about the world's up, and the rest, scaled and turned around + as the menu says (the up from the accelerometer, smoothed) */ + if (alen > 0.65f && alen < 1.35f) { + const float k = dt / (0.1f + dt); + for (int i = 0; i < 3; i++) m->up[i] += (-a[i] / alen - m->up[i]) * k; + const float n = hos_motion_len(m->up); + if (n > 0.001f) for (int i = 0; i < 3; i++) m->up[i] /= n; } - for (int i = 0; i < 3; i++) right[i] /= rl; - const float fwd[3] = {right[1] * up[2] - right[2] * up[1], right[2] * up[0] - right[0] * up[2], - right[0] * up[1] - right[1] * up[0]}; - m->rates[0] = w[0] * right[0] + w[1] * right[1] + w[2] * right[2]; - m->rates[1] = w[0] * up[0] + w[1] * up[1] + w[2] * up[2]; - m->rates[2] = w[0] * fwd[0] + w[1] * fwd[1] + w[2] * fwd[2]; - const float sh = sens_h * (inv_h ? -1.0f : 1.0f), sv = -sens_v * (inv_v ? -1.0f : 1.0f); - /* the vertical aim is the pad's real tilt, as a Switch reports it: measured from the pad lying flat (its face up), - however it is held when the filter starts (at launch, when Motion controls is turned on, on a pad switch or - reconnect); the game recenters from it each time it aims. It starts at the pad's tilt about its x from flat (held - rolled, the nose's angle above level instead: roll does not change that), and the tilt pull settles it once the - pad is level. Before, it started at 0 in whatever pose the pad was in, and a pad held tilted then aimed off by - that tilt for good (Link-22). The sensitivity and the invert scale only the tilt's change from where the filter - started (tilt_ref), so at rest in that hold the aim is the real tilt at any setting; scaling the whole angle from - flat moved a normal hold of 0.7 rad to vertical at 200% and mirrored it with the invert (hckrman101). A new - 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); - m->pitch = hos_motion_wrap(-m->tilt_ref); - m->tilt_sv = sv; - } else if (sv != m->tilt_sv && m->tilt_sv != 0) { - m->pitch = hos_motion_wrap(m->tilt_ready ? -m->tilt_ref + (m->tilt_acc - m->tilt_ref) * sv - : -m->tilt_ref + (m->pitch + m->tilt_ref) / m->tilt_sv * sv); - m->tilt_sv = sv; + { + 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]); } - m->pitch = hos_motion_wrap(m->pitch + m->rates[0] * sv * dt); - /* the vertical aim's drift: pulled gently toward the tilt gravity measures (the pad's tilt about its x axis from - flat, scaled the same way), so leftover bias or timing error cannot add up. Only while the pad's - x is about level (rolled under about 15 degrees): rolled, the tilt about the pad's own x moves faster than the - 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 (!started && 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]) */ - /* (anchored so the aim stays where it is: the first time the pad is level may be after it moved while rolled, - or held tilted when the filter started; sv is never 0, the menu's least sensitivity is 10%) */ - if (!m->tilt_ready) { /* (the tilt from flat itself, on tilt_ref's branch: near face-down the two could - be a turn apart, and at a sensitivity that is no whole number that is half a turn) */ - m->tilt_acc = m->tilt_ref + hos_motion_wrap(tilt - m->tilt_ref), m->tilt_ready = 1; + if (dt > HOS_MOTION_DT_MAX) dt = 0; /* (Eden ignores a stall: no rotation, no orientation step) */ + for (int i = 0; i < 3; i++) m->out.rotation[i] += g[i] * dt; + /* Eden's ResetOrientation: a filter that lost track (its error over 0.1) is set again from the accelerometer once + the pad has lain face up and still for 900 readings */ + if (hos_motion_len(m->real_error) >= 0.1f && !m->only_accelerometer) { + if (hos_motion_len(g) < 0.05f && alen > 0.9f && alen < 1.1f && a[2] <= -0.9f) { + if (++m->reset_counter > 900) { + m->quat[0] = m->quat[1] = m->quat[3] = 0, m->quat[2] = -1; + memset(m->integral_error, 0, sizeof m->integral_error); + for (int it = 0; it < 100 && hos_motion_len(m->real_error) >= 0.01f; it++) + hos_motion_mahony(m, a, g, 0.015f, 10); + m->reset_counter = 0; + } } else { - m->tilt_acc += hos_motion_wrap(tilt - m->tilt_last); + m->reset_counter = 0; } - m->tilt_last = tilt; - const float target = hos_motion_wrap(-m->tilt_ref + (m->tilt_acc - m->tilt_ref) * sv); - m->pitch = hos_motion_wrap(m->pitch + hos_motion_wrap(target - m->pitch) * (dt / (HOS_MOTION_TILT_S + dt))); - } - /* the playing hold, again: a pad that was set down (still on a table) and is then picked up and held steady for a - moment has its new hold eased in as tilt_ref, so a start on the desk does not leave the sensitivity and the - invert measured from flat (hckrman101). The aim moves with tilt_ref by -(1 + sv) per radian: nothing at the - defaults (sv = -1), else an ease to the real tilt at rest in the new hold. */ - if (m->still_t >= HOS_MOTION_STILL_S) m->was_set_down = 1, m->held_t = 0; - else { - /* (held: the gyro's spread a hand's, and the pad not turning, so a smooth sweep is not a hold) */ - float r = 0; - for (int i = 0; i < 3; i++) r += (m->gmean[i] - m->bias[i]) * (m->gmean[i] - m->bias[i]); - const int held = m->gvar < HOS_MOTION_HELD_GYRO * HOS_MOTION_HELD_GYRO && r < HOS_MOTION_HELD_GYRO * HOS_MOTION_HELD_GYRO; - if (m->was_set_down && m->tilt_ready && held && fabsf(up[0]) < HOS_MOTION_TILT_ROLL) m->held_t += dt; - else if (!held) m->held_t = 0; - } - if (m->was_set_down && m->held_t >= HOS_MOTION_HELD_S) { - m->ref_to = m->tilt_acc, m->ref_easing = 1, m->was_set_down = 0, m->held_t = 0; - } - if (m->ref_easing && dt > 0) { - const float d = (m->ref_to - m->tilt_ref) * (dt / (HOS_MOTION_REF_S + dt)); - m->tilt_ref += d; - m->pitch = hos_motion_wrap(m->pitch - (1 + sv) * d); - if (fabsf(m->ref_to - m->tilt_ref) < 1e-4f) m->ref_easing = 0; } - hos_motion_rest(&m->out, m->pitch); - m->out.gyro[1] = m->rates[1] * sh / HOS_MOTION_TAU; + if (dt > 0) hos_motion_mahony(m, a, g, dt, 1); + memcpy(m->out.accel, a, sizeof a); + memcpy(m->out.gyro, g, sizeof g); + hos_motion_direction(m, m->out.direction); return 1; } diff --git a/host/nn_hid.c b/host/nn_hid.c index 7bb4bde22..4aa33a912 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -841,7 +841,7 @@ HLE(_ZN2nn3hid23GetSixAxisSensorHandlesEPNS0_19SixAxisSensorHandleEiRKjNS_4util1 /* SixAxisSensorState, 0x60 bytes: {i64 delta time (ns), i64 sampling number, f32 acceleration[3] (G), angular velocity[3] (revolutions a second), angle[3] (revolutions), direction[3][3], u32 attributes (1 connected), pad}. Newest first. (hookshot) With Motion controls on and a pad with a gyroscope (host/pad_sdl.c), the state OoT aims with - (host/hos_motion.h: the yaw rate and a tilt matrix, the rest neutral); else a pad lying still. */ + (host/hos_motion.h: the full state, as the console and Eden build it); else a pad lying still. */ HLE(_ZN2nn3hid22GetSixAxisSensorStatesEPNS0_18SixAxisSensorStateEiRKNS0_19SixAxisSensorHandleE) { /* (the sampling number goes up once per reading, as the hardware's does: while live it is the readings' own count, moved on past the last number handed out; each entry is its own reading, with the time since the one before) */ diff --git a/host/pad_sdl.c b/host/pad_sdl.c index 1e4d6c9c8..bd504f69c 100644 --- a/host/pad_sdl.c +++ b/host/pad_sdl.c @@ -191,10 +191,12 @@ static void motion_poll(int64_t now, int count) { } if (debug && now >= motion_log_ns) { /* (twice a second: what the game gets) */ motion_log_ns = now + 500000000LL; - hos_log("hid: motion: up (%+.2f, %+.2f, %+.2f), pitch/yaw/roll %+.2f %+.2f %+.2f rad/s, aim tilt %+.3f, yaw " - "%+.3f turns/s, bias (%+.4f, %+.4f, %+.4f)", motion.up[0], motion.up[1], motion.up[2], motion.rates[0], - motion.rates[1], motion.rates[2], motion.pitch, motion.out.gyro[1], motion.bias[0], motion.bias[1], - motion.bias[2]); + hos_log("hid: motion: accel (%+.2f, %+.2f, %+.2f) G, gyro (%+.3f, %+.3f, %+.3f) turns/s, direction rows " + "(%+.2f %+.2f %+.2f / %+.2f %+.2f %+.2f / %+.2f %+.2f %+.2f), bias (%+.4f, %+.4f, %+.4f)", motion.out.accel[0], + motion.out.accel[1], motion.out.accel[2], motion.out.gyro[0], motion.out.gyro[1], motion.out.gyro[2], + motion.out.direction[0], motion.out.direction[1], motion.out.direction[2], motion.out.direction[3], + motion.out.direction[4], motion.out.direction[5], motion.out.direction[6], motion.out.direction[7], + motion.out.direction[8], motion.bias[0], motion.bias[1], motion.bias[2]); } HosMotionSample r; r.st = motion.out; diff --git a/readme.md b/readme.md index b12bddf98..9adb19b05 100644 --- a/readme.md +++ b/readme.md @@ -273,9 +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). Its row says whether the connected pad has a gyroscope; Off leaves the game seeing a pad lying still. **Motion sensitivity X/Y** - scale the left-right and up-down aim (100% is the console's), **Invert motion X/Y** turn either direction around. Up and down follow the pad's real tilt, however it - is held when motion starts; Motion sensitivity Y and Invert motion Y scale the tilt's change from your playing hold - (taken again a moment after you pick the pad up off a table). Motion follows the pad used + scale the left-right and up-down aim (100% is the console's), **Invert motion X/Y** turn either direction around. The game gets the motion + state as a Switch (and Eden) builds it, so how you hold the pad when motion starts does not matter. Motion follows the pad used last. The gyroscope's drift is learned while the pad rests on a table, so set it down for a second if the aim creeps. `HOS_MOTION_DEBUG=1` (in `hookshot_env.txt`) logs what the game gets twice a second. - **Layout preset:** Nintendo has A on the right face button, Xbox / PC and Swap Face put A on the bottom one. Changing diff --git a/tests/motion_test.c b/tests/motion_test.c index 4e9c53604..93b248c60 100644 --- a/tests/motion_test.c +++ b/tests/motion_test.c @@ -61,249 +61,139 @@ static void start(HosMotion *m, float x, float y, float z) { /* a fresh filter tremor = 0, anoise = 0; } -static void neutral(const HosMotion *m) { /* what the game folds into its recenter: always at rest */ - NEAR(m->out.accel[0], 0, 1e-6); - NEAR(m->out.accel[1], 0, 1e-6); - NEAR(m->out.accel[2], -1, 1e-6); - NEAR(m->out.gyro[0], 0, 1e-6); - NEAR(m->out.gyro[2], 0, 1e-6); - NEAR(m->out.rotation[0] + m->out.rotation[1] + m->out.rotation[2], 0, 1e-6); - NEAR(m->out.direction[0], 1, 1e-6); /* (a tilt about x only) */ +static float dot3(const float a[3], const float b[3]) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } +static void same_direction(const HosMotion *a, const HosMotion *b, double tol) { + for (int i = 0; i < 9; i++) NEAR(a->out.direction[i], b->out.direction[i], tol); } int main(void) { - HosMotion m; + HosMotion m, n; - /* before any reading: a pad lying still */ + /* before any reading: a pad lying still, face up */ hos_motion_reset(&m); - neutral(&m); - NEAR(m.out.direction[4] + m.out.direction[8], 2, 1e-6); + NEAR(m.out.accel[2], -1, 1e-6); + NEAR(m.out.direction[0] + m.out.direction[4] + m.out.direction[8], 3, 1e-6); - /* lying still with a gyroscope that reads a small rate: learned and taken off, no aim moves */ + /* lying face up: (0, 0, -1) G in the Switch's frame, no rate, the direction the identity */ start(&m, FLAT); - offset[0] = 0.012f, offset[1] = -0.008f, offset[2] = 0.010f; - run(&m, 0, 0, 0, 4, 1, 1, 0, 0); - NEAR(m.out.gyro[1], 0, 1e-4); - NEAR(m.pitch, 0, 0.02); - neutral(&m); + run(&m, 0, 0, 0, 1, 1, 1, 0, 0); + NEAR(m.out.accel[0], 0, 1e-3); + NEAR(m.out.accel[1], 0, 1e-3); + NEAR(m.out.accel[2], -1, 1e-3); + NEAR(m.out.gyro[0] + m.out.gyro[1] + m.out.gyro[2], 0, 1e-6); + NEAR(m.out.direction[0] + m.out.direction[4] + m.out.direction[8], 3, 0.01); - /* a large offset (0.2 rad/s, past what a rate threshold would call still) is learned too */ + /* the Switch's frame and units (Eden's sdl_driver.cpp): a turn about SDL's +Y (the pad flat, turning left) at one + turn a second is +1 in z; about SDL's +X (the front up), +1 in x; held upright, SDL's -Z up, -Z is +y */ start(&m, FLAT); - offset[1] = 0.2f; - run(&m, 0, 0, 0, 4, 1, 1, 0, 0); - NEAR(m.bias[1], 0.2, 1e-3); - - /* a noisier sensor (0.0067 rad/s and 5 mG on each axis, past the first thresholds) still reads still on a table */ + run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0); + run(&m, 0, HOS_MOTION_TAU, 0, 0.05f, 1, 1, 0, 0); + NEAR(m.out.gyro[2], 1, 1e-3); + NEAR(m.out.gyro[0], 0, 1e-3); start(&m, FLAT); - offset[1] = 0.02f, tremor = 0.0116f, anoise = 0.0087f; /* (uniform: 0.0067 rad/s and 5 mG RMS on each axis) */ - run(&m, 0, 0, 0, 6, 1, 1, 0, 0); - NEAR(m.bias[1], 0.02, 2e-3); - - /* a slow, steady turn in the hand (3 degrees a second, with a hand's tremor) is aim: not learned away */ - start(&m, FLAT); - run(&m, 0, 0, 0, 1, 1, 1, 0, 0); /* (on the table first) */ - tremor = 0.03f; - run(&m, 0, 0.0524f, 0, 2, 1, 1, 0, 0); - NEAR(m.bias[1], 0, 1e-3); + run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0); + run(&m, HOS_MOTION_TAU, 0, 0, 0.02f, 1, 1, 0, 0); + NEAR(m.out.gyro[0], 1, 0.02); + start(&m, UPRIGHT); + run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0); + NEAR(m.out.accel[1], -1, 1e-3); + run(&m, 0, 0, -HOS_MOTION_TAU, 0.05f, 1, 1, 0, 0); + NEAR(m.out.gyro[1], 1, 1e-3); - /* turning left (about SDL's +Y, the pad flat) at one turn a second: the yaw slot reads 1 turn a second */ + /* the rotation is the angular velocity summed: half a second at a quarter turn a second about +z is 0.125 */ start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); - run(&m, 0, HOS_MOTION_TAU, 0, 0.1f, 1, 1, 0, 0); - NEAR(m.out.gyro[1], 1, 0.02); - neutral(&m); + run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0); + run(&m, 0, HOS_MOTION_TAU / 4, 0, 0.5f, 1, 1, 0, 0); + NEAR(m.out.rotation[2], 0.125, 3e-3); - /* tilting the front up (about SDL's +X) at 1 rad/s for half a second: the tilt matrix turns -0.5 rad, the rest - neutral (the pitch rate itself never goes out), and it stays there while held */ + /* how the pad is held when the filter starts does not matter: started flat and then tilted 0.7 rad nose up, or + started there, the direction is the same (Link-22's start-pose case: the console's orientation follows gravity) */ start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); - run(&m, 1, 0, 0, 0.5f, 1, 1, 0, 0); - NEAR(m.pitch, -0.5, 0.03); - NEAR(m.out.direction[4], cosf(m.pitch), 1e-5); - NEAR(m.out.direction[5], -sinf(m.pitch), 1e-5); - NEAR(m.out.direction[7], sinf(m.pitch), 1e-5); - NEAR(m.out.gyro[1], 0, 0.01); - neutral(&m); - run(&m, 0, 0, 0, 3, 1, 1, 0, 0); - NEAR(m.pitch, -0.5, 0.03); - - /* rolled 30 degrees, a tilt of the nose up (about the levelled x) moves the aim by the tilt and it stays there: - the pull, which measures about the pad's own x, waits until the pad is level again (hckrman101) */ - start(&m, -0.5f, 0.8660254f, 0); run(&m, 0, 0, 0, 0.5f, 1, 1, 0, 0); + run(&m, 1, 0, 0, 0.7f, 1, 1, 0, 0); + run(&m, 0, 0, 0, 2, 1, 1, 0, 0); + start(&n, su[0], su[1], su[2]); + run(&n, 0, 0, 0, 2.5f, 1, 1, 0, 0); + same_direction(&m, &n, 0.03); + /* ... rolled 40 degrees and pitched 0.6 at the start, then levelled: the same as a level start in that pose */ + start(&m, 0, cosf(0.6f), -sinf(0.6f)); { - float r[3] = {1 - su[0] * su[0], -su[0] * su[1], -su[0] * su[2]}; - const float n = sqrtf(r[0] * r[0] + r[1] * r[1] + r[2] * r[2]); - run(&m, r[0] / n, r[1] / n, r[2] / n, 0.5f, 1, 1, 0, 0); + const float c = cosf(0.7f), sn = sinf(0.7f), x = su[0] * c - su[1] * sn, y = su[0] * sn + su[1] * c; + pose(x, y, su[2]); + } + run(&m, 0, 0, 0, 0.5f, 1, 1, 0, 0); + for (int i = 0; i < 80 && fabsf(su[0]) > 0.01f; i++) run(&m, 0, 0, su[0] > 0 ? -0.5236f : 0.5236f, 0.02f, 1, 1, 0, 0); + run(&m, 0, 0, 0, 2, 1, 1, 0, 0); + start(&n, su[0], su[1], su[2]); + run(&n, 0, 0, 0, 2.5f, 1, 1, 0, 0); + /* (the tilt agrees: the gravity each matrix implies; the heading about the vertical is the gyro's own, as on the + console, so compare the matrices' third columns, the world's vertical seen from the pad) */ + { + const float a3[3] = {m.out.direction[2], m.out.direction[5], m.out.direction[8]}; + const float b3[3] = {n.out.direction[2], n.out.direction[5], n.out.direction[8]}; + NEAR(dot3(a3, b3), 1, 2e-3); } - NEAR(m.pitch, -0.5, 0.03); - run(&m, 0, 0, 0, 3, 1, 1, 0, 0); - NEAR(m.pitch, -0.5, 0.03); - /* ... then rolled back to level (about the pad's z, which keeps the nose's height): the pull starts there, - anchored to the aim as it is, so the aim neither jumps nor drifts back to level. This filter started rolled, the - case behind Link-22's "wrong after turning Motion controls on while not level" (hckrman101) */ - for (int i = 0; i < 80 && su[0] < 0; i++) run(&m, 0, 0, 0.5236f, 0.02f, 1, 1, 0, 0); - NEAR(su[0], 0, 0.02); - run(&m, 0, 0, 0, 3, 1, 1, 0, 0); - NEAR(m.pitch, -atan2f(-su[2], su[1]), 0.02); /* (the pad's tilt from flat, as it is now) */ - - /* a leftover bias the filter cannot learn (the pad in the hand) does not drift the vertical aim: 0.01 rad/s for a - minute would be 0.6 rad of tilt summed open loop */ - start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); - offset[0] = 0.01f, tremor = 0.03f; - run(&m, 0, 0, 0, 60, 1, 1, 0, 0); - NEAR(m.pitch, 0, 0.03); - - /* held upright the same turn left is about the pad's -Z: the yaw slot still reads +1 (how the pad is held does - not matter) */ - start(&m, UPRIGHT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); - run(&m, 0, 0, -HOS_MOTION_TAU, 0.1f, 1, 1, 0, 0); - NEAR(m.out.gyro[1], 1, 0.02); - /* the sensitivities (each its own axis) and the inverts; the tilt's pull keeps the scaled tilt */ + /* the gyroscope's resting rate is learned on a table and taken off */ start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); - run(&m, 0, HOS_MOTION_TAU, 0, 0.1f, 2, 0.5f, 1, 0); - NEAR(m.out.gyro[1], -2, 0.04); - NEAR(m.pitch, 0, 0.01); + offset[0] = 0.012f, offset[1] = -0.008f, offset[2] = 0.010f; + run(&m, 0, 0, 0, 4, 1, 1, 0, 0); + NEAR(m.bias[0], 0.012 / HOS_MOTION_TAU, 2e-4); + NEAR(m.bias[2], -0.008 / HOS_MOTION_TAU, 2e-4); /* (SDL's y is the Switch's z) */ + NEAR(m.out.gyro[0] + m.out.gyro[1] + m.out.gyro[2], 0, 1e-6); + /* ... a large one too, and a noisier sensor's (0.0067 rad/s and 5 mG on each axis) */ start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); - run(&m, 1, 0, 0, 0.5f, 3, 1, 0, 1); - NEAR(m.pitch, 0.5, 0.03); - NEAR(m.out.gyro[1], 0, 0.01); + offset[1] = 0.2f; + run(&m, 0, 0, 0, 4, 1, 1, 0, 0); + NEAR(m.bias[2], 0.2 / HOS_MOTION_TAU, 2e-4); start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); - run(&m, 1, 0, 0, 0.5f, 1, 2, 0, 0); - NEAR(m.pitch, -1.0, 0.05); - run(&m, 0, 0, 0, 3, 1, 2, 0, 0); - NEAR(m.pitch, -1.0, 0.05); - - /* Motion sensitivity Y or Invert motion Y changed while the pad is held tilted (in the hand: a hand's tremor): the - aim goes at once to the tilt's change at the new scale and holds there, no slide; later tilts move it at the new - one */ + offset[1] = 0.02f, tremor = 0.0116f, anoise = 0.0087f; + run(&m, 0, 0, 0, 6, 1, 1, 0, 0); + NEAR(m.bias[2], 0.02 / HOS_MOTION_TAU, 4e-4); + /* ... but a slow turn in the hand (tremor) is aim, and a steady machine pitch is not stillness: neither is learned */ start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); + run(&m, 0, 0, 0, 1, 1, 1, 0, 0); tremor = 0.03f; - run(&m, 1, 0, 0, 0.5f, 1, 1, 0, 0); - NEAR(m.pitch, -0.5, 0.03); - run(&m, 0, 0, 0, 0.004f, 1, 2, 0, 0); - NEAR(m.pitch, -1.0, 0.05); - run(&m, 0, 0, 0, 3, 1, 2, 0, 0); - NEAR(m.pitch, -1.0, 0.05); - run(&m, 1, 0, 0, 0.25f, 1, 2, 0, 0); - NEAR(m.pitch, -1.5, 0.06); - run(&m, 0, 0, 0, 3, 1, 2, 0, 1); - NEAR(m.pitch, 1.5, 0.06); - - /* the vertical aim is measured from level, whatever the pose when the filter starts: the same hold aims the same - after a start held rolled and pitched up as after a start flat (Link-22's start 3 against his starts 2 and 4) */ - { - start(&m, FLAT); - run(&m, 0, 0, 0, 0.3f, 1, 1, 0, 0); - run(&m, 1, 0, 0, 0.6f, 1, 1, 0, 0); /* (nose up 0.6 rad, from flat) */ - run(&m, 0, 0, 0, 2, 1, 1, 0, 0); - const float flat_start = m.pitch; - NEAR(flat_start, -0.6, 0.04); - /* the same pose (nose up 0.6) reached rolled: the filter starts there, the pad then rolls level */ - hos_motion_reset(&m); - offset[0] = offset[1] = offset[2] = 0, tremor = 0; - pose(0, cosf(0.6f), -sinf(0.6f)); /* (nose up 0.6, level) */ - { /* rolled 40 degrees about the pad's z (the nose's height stays) */ - const float c = cosf(0.7f), sn = sinf(0.7f); - const float x = su[0] * c - su[1] * sn, y = su[0] * sn + su[1] * c; - pose(x, y, su[2]); - } - run(&m, 0, 0, 0, 0.3f, 1, 1, 0, 0); - NEAR(m.pitch, -0.6, 0.04); - for (int i = 0; i < 80 && fabsf(su[0]) > 0.01f; i++) run(&m, 0, 0, su[0] > 0 ? -0.5236f : 0.5236f, 0.02f, 1, 1, 0, 0); - NEAR(su[0], 0, 0.02); - run(&m, 0, 0, 0, 3, 1, 1, 0, 0); - NEAR(m.pitch, flat_start, 0.05); - } - - /* the sensitivity and the invert scale the tilt's change from where the filter started, not the whole angle from - flat: a hold 0.7 rad up aims there at 200% and with the invert too (not near vertical, nor mirrored), and a - further 0.1 rad up moves the aim 0.2 at 200%, and 0.1 the other way inverted (hckrman101) */ - start(&m, 0, cosf(0.7f), -sinf(0.7f)); - run(&m, 0, 0, 0, 0.5f, 1, 2, 0, 0); - NEAR(m.pitch, -0.7, 0.03); - run(&m, 1, 0, 0, 0.1f, 1, 2, 0, 0); - run(&m, 0, 0, 0, 2, 1, 2, 0, 0); - NEAR(m.pitch, -0.9, 0.04); - start(&m, 0, cosf(0.7f), -sinf(0.7f)); - run(&m, 0, 0, 0, 0.5f, 1, 1, 0, 1); - NEAR(m.pitch, -0.7, 0.03); - run(&m, 1, 0, 0, 0.1f, 1, 1, 0, 1); - run(&m, 0, 0, 0, 2, 1, 1, 0, 1); - NEAR(m.pitch, -0.6, 0.04); - - /* started flat on the desk, then picked up and held 0.7 rad up: the new hold is taken as the playing hold, so at - 200% and with the invert the aim at rest is the real tilt there too, not -1.4 or a mirrored +0.7 (hckrman101) */ - for (int inv = 0; inv < 2; inv++) { - const float sens = inv ? 1.0f : 2.0f; - start(&m, FLAT); - run(&m, 0, 0, 0, 1.5f, 1, sens, 0, inv); /* (on the desk: set down) */ - tremor = 0.03f; - run(&m, 1, 0, 0, 0.7f, 1, sens, 0, inv); /* (picked up, nose 0.7 up) */ - run(&m, 0, 0, 0, 4, 1, sens, 0, inv); /* (held there) */ - NEAR(m.pitch, -0.7, 0.05); - run(&m, 1, 0, 0, 0.1f, 1, sens, 0, inv); /* (then aimed 0.1 further up) */ - run(&m, 0, 0, 0, 0.2f, 1, sens, 0, inv); - NEAR(m.pitch, inv ? -0.6 : -0.9, 0.05); - } - - /* a steady pitch with no tremor at all (a pad turned by a machine) is not still: its turn is not learned as bias */ + run(&m, 0, 0.0524f, 0, 2, 1, 1, 0, 0); + NEAR(m.bias[2], 0, 2e-4); start(&m, FLAT); run(&m, 0.2f, 0, 0, 3, 1, 1, 0, 0); - NEAR(m.bias[0], 0, 1e-3); - - /* past half a turn from the anchor at a sensitivity that is not a whole number: no jump (pitched forward 3.5 rad at - 50%: the aim goes to -1.75 and stays) */ - start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 0.5f, 0, 0); - run(&m, 1, 0, 0, 3.5f, 1, 0.5f, 0, 0); - NEAR(m.pitch, -1.75, 0.05); - run(&m, 0, 0, 0, 2, 1, 0.5f, 0, 0); - NEAR(m.pitch, -1.75, 0.05); + NEAR(m.bias[0], 0, 2e-4); - /* picked up from flat to upright without the gyro seeing it: up settles on the measured gravity */ + /* under the console's zero-drift threshold (0.007 turns a second) a rate counts as none */ start(&m, FLAT); - run(&m, 0, 0, 0, 0.5f, 1, 1, 0, 0); - pose(UPRIGHT); - run(&m, 0, 0, 0, 5, 1, 1, 0, 0); - NEAR(m.up[2], -1, 0.01); + run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0); + run(&m, 0, 0.03f, 0, 0.1f, 1, 1, 0, 0); + NEAR(m.out.gyro[2], 0, 1e-6); - /* the real time between readings: polls 1 ms apart (Linux, a 1 kHz pad) and 60 ms apart (a hitch) both add up */ - start(&m, FLAT); - run_dt(&m, 0, 0, 0, 0.1f, 0.001f, 1, 1, 0, 0); - run_dt(&m, 1, 0, 0, 0.5f, 0.001f, 1, 1, 0, 0); - NEAR(m.pitch, -0.5, 0.03); + /* the sensitivities and the inverts: the turn (about the world's up) and the tilt (the rest) each scaled */ start(&m, FLAT); - run_dt(&m, 0, 0, 0, 0.12f, 0.06f, 1, 1, 0, 0); - run_dt(&m, 1, 0, 0, 0.48f, 0.06f, 1, 1, 0, 0); - NEAR(m.pitch, -0.48, 0.03); - - /* a stall (a second between readings) counts as HOS_MOTION_DT_MAX, not a jump (shaken: no tilt pull) */ + run(&m, 0, 0, 0, 0.3f, 1, 1, 0, 0); + run(&m, 0, HOS_MOTION_TAU, 0, 0.05f, 2, 0.5f, 0, 0); + NEAR(m.out.gyro[2], 2, 0.01); + run(&m, 0, HOS_MOTION_TAU, 0, 0.05f, 1, 1, 1, 0); + NEAR(m.out.gyro[2], -1, 0.01); start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); - { - const float a[3] = {0, 2 * HOS_MOTION_G, 0}, g[3] = {1, 0, 0}; - hos_motion_update(&m, a, g, 1.0f, 1, 1, 0, 0); - } - NEAR(m.pitch, -HOS_MOTION_DT_MAX, 2e-3); + run(&m, 0, 0, 0, 0.3f, 1, 1, 0, 0); + run(&m, HOS_MOTION_TAU, 0, 0, 0.01f, 3, 0.5f, 0, 0); + NEAR(m.out.gyro[0], 0.5, 0.02); + NEAR(m.out.gyro[2], 0, 0.01); + run(&m, HOS_MOTION_TAU, 0, 0, 0.01f, 1, 1, 0, 1); + NEAR(m.out.gyro[0], -1, 0.03); - /* a repeat of the last reading is no reading: its time goes into the next */ + /* a repeat of the last reading is no reading (its time goes into the next), and a stall is no step */ start(&m, FLAT); - run(&m, 0, 0, 0, 0.1f, 1, 1, 0, 0); + run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0); { - const float a[3] = {0, 2 * HOS_MOTION_G, 0}, g0[3] = {0, 0, 0}, g1[3] = {1, 0, 0}; - const float p0 = m.pitch; + const float a[3] = {0, HOS_MOTION_G, 0}, g0[3] = {0, 0, 0}, g1[3] = {0, HOS_MOTION_TAU, 0}; NEAR(hos_motion_update(&m, a, g0, 0.004f, 1, 1, 0, 0), 1, 0); NEAR(hos_motion_update(&m, a, g0, 0.004f, 1, 1, 0, 0), 0, 0); + const float r0 = m.out.rotation[2]; NEAR(hos_motion_update(&m, a, g1, 0.004f, 1, 1, 0, 0), 1, 0); - NEAR(m.pitch - p0, -0.008, 2e-4); + NEAR(m.out.rotation[2] - r0, 0.008, 2e-4); + const float r1 = m.out.rotation[2]; + hos_motion_update(&m, a, g0, 1.0f, 1, 1, 0, 0); + NEAR(m.out.rotation[2], r1, 1e-6); } if (failures) return 1; From d68db5b4e003ced312430fb7d58665a87d2f3d7b Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:12:56 -0500 Subject: [PATCH 6/9] Motion controls: Y sensitivity and invert kept in the handed-out orientation 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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- host/hos_motion.h | 83 ++++++++++++++++++++++++++++++++------------- tests/motion_test.c | 16 +++++++++ 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/host/hos_motion.h b/host/hos_motion.h index 72f34f8a7..898f84174 100644 --- a/host/hos_motion.h +++ b/host/hos_motion.h @@ -45,7 +45,9 @@ typedef struct { #define HOS_MOTION_HISTORY 32 typedef struct { - float quat[4]; /* x, y, z, w (Eden's order) */ + float quat[4]; /* x, y, z, w (Eden's order): the real orientation, Eden's filter on the real rates */ + float quat_out[4]; /* the orientation handed out: integrated from the rates as Motion sensitivity and Invert + motion scale them, with the real filter's drift correction (the same as quat at 100%) */ float real_error[3], integral_error[3]; float bias[3]; /* turns a second, the Switch's frame */ float learned_t; /* seconds of stillness learned from (fast at first, then slowly) */ @@ -74,6 +76,7 @@ static inline void hos_motion_rest(HosMotionState *o) { static inline void hos_motion_reset(HosMotion *m) { memset(m, 0, sizeof *m); m->quat[2] = -1; /* (Eden's ResetQuaternion: {0, 0, -1, 0}) */ + m->quat_out[2] = -1; m->only_accelerometer = 1; m->up[2] = 1; hos_motion_rest(&m->out); @@ -81,13 +84,34 @@ static inline void hos_motion_reset(HosMotion *m) { static inline float hos_motion_len(const float v[3]) { return sqrtf(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); } -/* Mahony's filter on the quaternion, Eden's UpdateOrientation (period: seconds; gain: the feedback's weight, 1 for a - reading, 10 for SetOrientationFromAccelerometer's settling) */ -static inline void hos_motion_mahony(HosMotion *m, const float accel[3], const float gyro[3], float period, float gain) { - float q1 = m->quat[3], q2 = m->quat[0], q3 = m->quat[1], q4 = m->quat[2]; +/* a quaternion (Eden's x, y, z, w) turned at Eden's filter rate rg for period seconds */ +static inline void hos_motion_integrate(float quat[4], const float rg[3], float period) { + float q1 = quat[3], q2 = quat[0], q3 = quat[1], q4 = quat[2]; + const float gx = rg[1], gy = rg[0], gz = rg[2]; + const float pa = q2, pb = q3, pc = q4; + q1 = q1 + (-q2 * gx - q3 * gy - q4 * gz) * (0.5f * period); + q2 = pa + (q1 * gx + pb * gz - pc * gy) * (0.5f * period); + q3 = pb + (q1 * gy - pa * gz + pc * gx) * (0.5f * period); + q4 = pc + (q1 * gz + pa * gy - pb * gx) * (0.5f * period); + const float n = sqrtf(q1 * q1 + q2 * q2 + q3 * q3 + q4 * q4); + if (n > 0) quat[3] = q1 / n, quat[0] = q2 / n, quat[1] = q3 / n, quat[2] = q4 / n; +} +static inline void hos_motion_rate(const HosMotion *m, const float gyro[3], float rg[3]) { /* (Eden's swap, rad/s) */ + rg[0] = gyro[1] * HOS_MOTION_TAU, rg[1] = -gyro[0] * HOS_MOTION_TAU, rg[2] = -gyro[2] * HOS_MOTION_TAU; + if (m->only_accelerometer) rg[0] = rg[1] = rg[2] = 0; +} + +/* Mahony's filter on the real quaternion, Eden's UpdateOrientation (period: seconds; gain 10: the settling of + SetOrientationFromAccelerometer). Returns the feedback it added to the rate, in *corr (rad/s, the filter's frame), so + the handed-out orientation can lose the same drift. */ +static inline void hos_motion_mahony(HosMotion *m, const float accel[3], const float gyro[3], float period, float gain, + float corr[3]) { + const float q1 = m->quat[3], q2 = m->quat[0], q3 = m->quat[1], q4 = m->quat[2]; const float alen = hos_motion_len(accel); - float rg[3] = {gyro[1] * HOS_MOTION_TAU, -gyro[0] * HOS_MOTION_TAU, -gyro[2] * HOS_MOTION_TAU}; - if (m->only_accelerometer || gain > 1) rg[0] = rg[1] = rg[2] = 0; + float rg[3]; + hos_motion_rate(m, gyro, rg); + if (gain > 1) rg[0] = rg[1] = rg[2] = 0; + corr[0] = corr[1] = corr[2] = 0; if ((alen >= 0.75f && alen <= 1.25f) || gain > 1) { const float ax = -accel[0] / alen, ay = accel[1] / alen, az = -accel[2] / alen; const float vx = 2.0f * (q2 * q4 - q1 * q3); @@ -101,21 +125,15 @@ static inline void hos_motion_mahony(HosMotion *m, const float accel[3], const f memset(m->integral_error, 0, sizeof m->integral_error); const float kp = 0.3f, ki = 0.005f; const float wp = gain > 1 ? 10 : m->only_accelerometer ? 35 : 1, wi = gain > 1 ? 5 : m->only_accelerometer ? 10 : 1; - for (int i = 0; i < 3; i++) rg[i] += wp * kp * e[i] + wi * ki * m->integral_error[i]; + for (int i = 0; i < 3; i++) corr[i] = wp * kp * e[i] + wi * ki * m->integral_error[i]; } - const float gx = rg[1], gy = rg[0], gz = rg[2]; - const float pa = q2, pb = q3, pc = q4; - q1 = q1 + (-q2 * gx - q3 * gy - q4 * gz) * (0.5f * period); - q2 = pa + (q1 * gx + pb * gz - pc * gy) * (0.5f * period); - q3 = pb + (q1 * gy - pa * gz + pc * gx) * (0.5f * period); - q4 = pc + (q1 * gz + pa * gy - pb * gx) * (0.5f * period); - const float n = sqrtf(q1 * q1 + q2 * q2 + q3 * q3 + q4 * q4); - if (n > 0) m->quat[3] = q1 / n, m->quat[0] = q2 / n, m->quat[1] = q3 / n, m->quat[2] = q4 / n; + for (int i = 0; i < 3; i++) rg[i] += corr[i]; + hos_motion_integrate(m->quat, rg, period); } /* the orientation matrix, Eden's GetOrientation (its quaternion's matrix, with its sign changes) */ -static inline void hos_motion_direction(const HosMotion *m, float d[9]) { - const float x = -m->quat[1], y = -m->quat[0], z = -m->quat[3], w = -m->quat[2]; +static inline void hos_motion_direction(const float quat[4], float d[9]) { + const float x = -quat[1], y = -quat[0], z = -quat[3], w = -quat[2]; const float x2 = x * x, y2 = y * y, z2 = z * z, xy = x * y, wz = w * z, xz = x * z, wy = w * y, yz = y * z, wx = w * x; const float mm[9] = {1 - 2 * (y2 + z2), 2 * (xy + wz), 2 * (xz - wy), 2 * (xy - wz), 1 - 2 * (x2 + z2), 2 * (yz + wx), 2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (x2 + y2)}; @@ -180,13 +198,14 @@ static inline int hos_motion_update(HosMotion *m, const float accel_sdl[3], cons const float n = hos_motion_len(m->up); if (n > 0.001f) for (int i = 0; i < 3; i++) m->up[i] /= n; } + float gs[3]; /* (the scaled rates: what the game is handed; g stays real for the filter) */ { 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]); + for (int i = 0; i < 3; i++) gs[i] = sh * t * m->up[i] + sv * (g[i] - t * m->up[i]); } if (dt > HOS_MOTION_DT_MAX) dt = 0; /* (Eden ignores a stall: no rotation, no orientation step) */ - for (int i = 0; i < 3; i++) m->out.rotation[i] += g[i] * dt; + for (int i = 0; i < 3; i++) m->out.rotation[i] += gs[i] * dt; /* Eden's ResetOrientation: a filter that lost track (its error over 0.1) is set again from the accelerometer once the pad has lain face up and still for 900 readings */ if (hos_motion_len(m->real_error) >= 0.1f && !m->only_accelerometer) { @@ -194,17 +213,33 @@ static inline int hos_motion_update(HosMotion *m, const float accel_sdl[3], cons if (++m->reset_counter > 900) { m->quat[0] = m->quat[1] = m->quat[3] = 0, m->quat[2] = -1; memset(m->integral_error, 0, sizeof m->integral_error); + float c[3]; for (int it = 0; it < 100 && hos_motion_len(m->real_error) >= 0.01f; it++) - hos_motion_mahony(m, a, g, 0.015f, 10); + hos_motion_mahony(m, a, g, 0.015f, 10, c); + memcpy(m->quat_out, m->quat, sizeof m->quat); /* (set again from gravity: the handed-out one too) */ m->reset_counter = 0; } } else { m->reset_counter = 0; } } - if (dt > 0) hos_motion_mahony(m, a, g, dt, 1); + if (dt > 0) { + /* the real filter, then the handed-out orientation: the scaled rates plus the real filter's correction, so it + loses the gyro's drift but keeps a scaled or inverted move (the gravity pull acts on the real one only; + hckrman101). While only the accelerometer has been seen the two settle together. */ + const int settling = m->only_accelerometer; + float c[3], rs[3]; + hos_motion_mahony(m, a, g, dt, 1, c); + if (settling) { + memcpy(m->quat_out, m->quat, sizeof m->quat); + } 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); + } + } memcpy(m->out.accel, a, sizeof a); - memcpy(m->out.gyro, g, sizeof g); - hos_motion_direction(m, m->out.direction); + memcpy(m->out.gyro, gs, sizeof gs); + hos_motion_direction(m->quat_out, m->out.direction); return 1; } diff --git a/tests/motion_test.c b/tests/motion_test.c index 93b248c60..396f08635 100644 --- a/tests/motion_test.c +++ b/tests/motion_test.c @@ -181,6 +181,22 @@ int main(void) { run(&m, HOS_MOTION_TAU, 0, 0, 0.01f, 1, 1, 0, 1); NEAR(m.out.gyro[0], -1, 0.03); + /* ... and the orientation keeps a scaled or inverted tilt while held: 0.5 rad nose up at 200% stays at 1.0 for 3 s, + and inverted at -0.5 (the gravity pull acts on the real filter only; hckrman101). Compared against the matrix of + a pad really tilted that far, at 100% */ + for (int c = 0; c < 2; c++) { + const float sens = c ? 1.0f : 2.0f, want = c ? -0.5f : 1.0f; + start(&m, FLAT); + run(&m, 0, 0, 0, 0.5f, 1, sens, 0, c); + run(&m, 1, 0, 0, 0.5f, 1, sens, 0, c); + run(&m, 0, 0, 0, 3, 1, sens, 0, c); + start(&n, FLAT); + run(&n, 0, 0, 0, 0.5f, 1, 1, 0, 0); + run(&n, want > 0 ? 1.0f : -1.0f, 0, 0, fabsf(want), 1, 1, 0, 0); + run(&n, 0, 0, 0, 3, 1, 1, 0, 0); + same_direction(&m, &n, 0.04); + } + /* a repeat of the last reading is no reading (its time goes into the next), and a stall is no step */ start(&m, FLAT); run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0); From 0b221fc4949998c8cf6b5fb332a283785bb73538 Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:24:48 -0500 Subject: [PATCH 7/9] Motion controls: their rows on a Motion page, opened from the Controls 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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- gpu/vk/vk_menu.cpp | 30 +++++++++++++++++++----------- readme.md | 8 ++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index 09e619670..13f850901 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -49,7 +49,8 @@ enum MenuTab { TAB_GRAPHICS = 0, TAB_GAMEPLAY = 1, 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) }; struct Row { @@ -488,7 +489,7 @@ Row rows[] = { "What the ocarina hears when the game's microphone option is on (playing an instrument, or humming).", hn_mic, false, true}, - // Controls (14 rows) + // Controls (10 rows; the Motion row opens the 5 TAB_MOTION rows) {TAB_CONTROLS, "Controller in background", &opts.pad_background, 0, 1, 1, tx_onoff, "Whether the controller plays the game while another window has the focus.", hn_background}, {TAB_CONTROLS, "Mouse camera", &opts.mouse_native, 0, 1, 1, tx_mousecam, "How the captured mouse turns the camera.", @@ -504,20 +505,22 @@ Row rows[] = { "Inverts vertical look (up/down) on the right analog stick.", nullptr, false, true}, {TAB_CONTROLS, "Rumble strength", &opts.rumble, 0, 150, 25, tx_rumble, "How strongly the controller rumbles, in percent of the game's rumble. Off turns it off.", nullptr, false, true}, - {TAB_CONTROLS, "Motion controls", &opts.gyro, 0, 1, 1, tx_gyro, + {TAB_MOTION, "Motion controls", &opts.gyro, 0, 1, 1, tx_gyro, "Aiming and looking with the controller's gyroscope where the game uses motion, as on the Switch.", hn_gyro, false, true}, - {TAB_CONTROLS, "Motion sensitivity X", &opts.gyro_sens_x, 10, 300, 10, tx_percent, + {TAB_MOTION, "Motion sensitivity X", &opts.gyro_sens_x, 10, 300, 10, tx_percent, "How far the aim moves left and right for a given turn of the controller. 100% is the console's own.", nullptr, false, true}, - {TAB_CONTROLS, "Motion sensitivity Y", &opts.gyro_sens_y, 10, 300, 10, tx_percent, + {TAB_MOTION, "Motion sensitivity Y", &opts.gyro_sens_y, 10, 300, 10, tx_percent, "How far the aim moves up and down for a given tilt of the controller. 100% is the console's own.", nullptr, false, true}, - {TAB_CONTROLS, "Invert motion X", &opts.gyro_inv_x, 0, 1, 1, tx_onoff, "Turns the motion aim's left and right around.", + {TAB_MOTION, "Invert motion X", &opts.gyro_inv_x, 0, 1, 1, tx_onoff, "Turns the motion aim's left and right around.", nullptr, false, true}, - {TAB_CONTROLS, "Invert motion Y", &opts.gyro_inv_y, 0, 1, 1, tx_onoff, "Turns the motion aim's up and down around.", + {TAB_MOTION, "Invert motion Y", &opts.gyro_inv_y, 0, 1, 1, tx_onoff, "Turns the motion aim's up and down around.", nullptr, false, true}, {TAB_CONTROLS, "Layout preset", &opts.pad_layout, 0, 3, 1, tx_pad_layout, "Which face button is which. Binding one yourself on the Controls page makes it Custom.", hn_pad_layout}, + {TAB_CONTROLS, "Motion", nullptr, 0, 0, 0, nullptr, // (hookshot: opens the Motion page, the TAB_MOTION rows) + "Motion controls: aiming with the controller's gyroscope, its sensitivity and its inverts."}, {TAB_CONTROLS, "Controls", nullptr, 0, 0, 0, nullptr, // (hookshot: opens its page) "Which gamepad button and which keys press each of the Switch's buttons."}, {TAB_GAMEPLAY, "Cheats", nullptr, 0, 0, 0, nullptr, @@ -612,7 +615,8 @@ constexpr int kRows = sizeof rows / sizeof rows[0]; constexpr int kMenuWidth = menu_hit::kWidth; // (at a scale of 1: 720 lines) int cur_tab = TAB_GRAPHICS; -int tab_selected[TAB_COUNT] = {0, 0, 0}; +int tab_selected[TAB_COUNT + 1] = {0, 0, 0, 0}; // (and the Motion page's) +bool is_motion_row(const Row& r) { return r.tab == TAB_CONTROLS && !r.value && !strcmp(r.name, "Motion"); } // ---- the rows that take a restart: their values at the start (menu_init, the GPU's at menu_set_gpu_count) ---- namespace mr = menu_restart; @@ -1317,7 +1321,7 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { const int center_start = menu_hit::kTabSide * s; const int tab_slot_w = menu_hit::tab_slot_w(s, (int)w); for (int t = 0; t < TAB_COUNT; t++) { - const bool active = (t == cur_tab); + const bool active = t == (cur_tab == TAB_MOTION ? TAB_CONTROLS : cur_tab); // (the Motion page: under Controls) int slot_x = center_start + t * tab_slot_w; SIZE tsz{}; GetTextExtentPoint32A(dc, tab_titles[t], (int)strlen(tab_titles[t]), &tsz); @@ -1421,7 +1425,8 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { } SelectObject(dc, body); SetTextColor(dc, RGB(140, 150, 144)); - const char* hint = "Arrows change Q/E or LB/RB: tabs Insert, F1 or Home closes"; // (fits 448 px at 16 px) + const char* hint = cur_tab == TAB_MOTION ? "Arrows change Esc goes back Insert, F1 or Home closes" + : "Arrows change Q/E or LB/RB: tabs Insert, F1 or Home closes"; // (fits 448 px at 16 px) TextOutA(dc, 16 * s, help_top + 4 * s + help_px + 10 * s, hint, (int)strlen(hint)); if (cur_tab == TAB_GRAPHICS && restart_pending()) { // (the rows that take a restart, changed: measure_pending's room) SelectObject(dc, help); @@ -2167,9 +2172,11 @@ void menu_tick() { } } } + if (press[5] && cur_tab == TAB_MOTION) cur_tab = TAB_CONTROLS, press[5] = false, changed = true; // (back) if (press[5] && restart_ask()) page = 5, ask_sel = 0, changed = true; // (the restart question first) else if (press[5]) open_now = false; - // (the tabs: LB and RB, Q and E, or Tab) + // (the tabs: LB and RB, Q and E, or Tab; from the Motion page, as from Controls) + if ((tab_left || tab_right) && cur_tab == TAB_MOTION) cur_tab = TAB_CONTROLS; if (tab_left) cur_tab = (cur_tab + TAB_COUNT - 1) % TAB_COUNT, changed = true; else if (tab_right) cur_tab = (cur_tab + 1) % TAB_COUNT, changed = true; const int tab_count = tab_row_count(cur_tab); @@ -2178,6 +2185,7 @@ void menu_tick() { if (press[2]) selected = (selected + 1) % tab_count, changed = true; Row& r = rows[tab_row_to_global(cur_tab, selected)]; if (press[7] && &r == &rows[kCheatsRow]) page = 2, changed = true; + else if (press[7] && is_motion_row(r)) cur_tab = TAB_MOTION, tab_selected[TAB_MOTION] = 0, press[7] = false, changed = true; else if (press[7] && !r.value && can_bind()) page = 1, csel = 0, ccol = 0, changed = true; if ((press[3] || press[4] || press[7]) && r.value) { // (Enter, or the bottom face button, as Right) if (press[7]) press[4] = true; diff --git a/readme.md b/readme.md index 3c554c42b..f24163c7f 100644 --- a/readme.md +++ b/readme.md @@ -239,12 +239,8 @@ choices and starts the game again, so progress since your last save is lost. | | Invert camera X | Off, On | Off | | | Invert camera Y | Off, On | Off | | | Rumble strength | Off, 25% to 150%, in steps of 25 | 100% | -| | Motion controls | Off, On | On | -| | Motion sensitivity X | 10% to 300% | 100% | -| | Motion sensitivity Y | 10% to 300% | 100% | -| | Invert motion X | Off, On | Off | -| | Invert motion Y | Off, On | Off | | | Layout preset | Nintendo (Def), Xbox / PC, Swap Face, Custom | Nintendo (Def) | +| | Motion | Opens the Motion page: Motion controls (Off, On; On), Motion sensitivity X and Y (10% to 300%; 100%), Invert motion X and Y (Off, On; Off) | | | | Controls | Opens the Controls page | | - **Quality preset** sets Render resolution, Window scaling filter, Sharpness, Frame generation, Frame pacing, Game @@ -291,7 +287,7 @@ choices and starts the game again, so progress since your last save is lost. place of the right stick. The - and = keys change the sensitivity in game too. - **Rumble strength** scales the controller's rumble: 100% is the game's own, Off turns it off. The game's HD rumble goes to a controller's heavy and light motors by its frequency. -- **Motion controls:** the controller's gyroscope aims where the game uses motion, as on the Switch. It works with +- **Motion controls** (Controls tab, Motion page): 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). Its row says whether the connected pad has a gyroscope; Off leaves the game seeing a pad lying still. **Motion sensitivity X/Y** scale the left-right and up-down aim (100% is the console's), **Invert motion X/Y** turn either direction around. The game gets the motion From 1ece02ea78fa872469c2182970356c14fd3eaf63 Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:34:37 -0500 Subject: [PATCH 8/9] Motion controls: a Motion deadzone row (the console's three zero-drift 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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- gpu/vk/vk_api.h | 2 +- gpu/vk/vk_menu.cpp | 8 ++++++++ gpu/vk/vk_menu.h | 1 + gpu/vk/vk_replay.cpp | 2 +- host/hos_motion.h | 8 ++++++-- host/pad_sdl.c | 5 +++++ readme.md | 2 +- tests/motion_test.c | 12 ++++++++++++ 8 files changed, 35 insertions(+), 5 deletions(-) diff --git a/gpu/vk/vk_api.h b/gpu/vk/vk_api.h index ab699f59e..845e7ab37 100644 --- a/gpu/vk/vk_api.h +++ b/gpu/vk/vk_api.h @@ -19,7 +19,7 @@ extern "C" { enum { HOS_OPT_CHEAT_HEARTS = 32, HOS_OPT_CHEAT_FLIGHT, HOS_OPT_CHEAT_ACTION, HOS_OPT_CHEAT_CAMERA }; /* (hookshot) the Controls tab's motion rows (host/pad_sdl.c reads them, vk_replay.cpp's option answers them): Motion controls on or off, the horizontal's and the vertical's sensitivity in percent, and each turned around */ -enum { HOS_OPT_GYRO = 18, HOS_OPT_GYRO_SENS_X, HOS_OPT_GYRO_INV_X, HOS_OPT_GYRO_INV_Y, HOS_OPT_GYRO_SENS_Y }; +enum { HOS_OPT_GYRO = 18, HOS_OPT_GYRO_SENS_X, HOS_OPT_GYRO_INV_X, HOS_OPT_GYRO_INV_Y, HOS_OPT_GYRO_SENS_Y, HOS_OPT_GYRO_DEADZONE }; /* (hookshot) NVIDIA DLSS's camera jitter (host/rx_backend.c, gpu/vk/vk_dlss.cpp): the scene's width and height while DLSS draws, 0 when there is to be no jitter, and the jitter sequence's length. 24 on: 18 to 22 are the motion rows' */ enum { HOS_OPT_DLSS_JITTER_W = 24, HOS_OPT_DLSS_JITTER_H, HOS_OPT_DLSS_JITTER_PHASES }; diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index 13f850901..648d5f019 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -333,6 +333,11 @@ const char* hn_mic(int v, int variant) { return missing ? "Not plugged in: Default stands in until it is back." : "This microphone, by its name. A change applies at once."; } const char* tx_rumble(int v) { return v ? tx_percent(v) : "Off"; } +// (hookshot) the motion's deadzone: the console's three zero-drift modes, the rate under which the pad counts as still +const char* tx_deadzone(int v) { + static const char* t[] = {"Low", "Standard", "High"}; + return t[v < 0 ? 0 : v > 2 ? 2 : v]; +} const char* tx_robust(int v) { static const char* t[] = {"Auto", "Off", "On"}; return (unsigned)v < 3 ? t[v] : t[0]; @@ -517,6 +522,9 @@ Row rows[] = { nullptr, false, true}, {TAB_MOTION, "Invert motion Y", &opts.gyro_inv_y, 0, 1, 1, tx_onoff, "Turns the motion aim's up and down around.", nullptr, false, true}, + {TAB_MOTION, "Motion deadzone", &opts.gyro_deadzone, 0, 2, 1, tx_deadzone, + "How slow a turn counts as none, the console's three settings: Standard is its own. High steadies a pad that jitters " + "when held still; Low keeps the smallest movements.", nullptr, false, true}, {TAB_CONTROLS, "Layout preset", &opts.pad_layout, 0, 3, 1, tx_pad_layout, "Which face button is which. Binding one yourself on the Controls page makes it Custom.", hn_pad_layout}, {TAB_CONTROLS, "Motion", nullptr, 0, 0, 0, nullptr, // (hookshot: opens the Motion page, the TAB_MOTION rows) diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h index 7f0264dcb..ded5c5809 100644 --- a/gpu/vk/vk_menu.h +++ b/gpu/vk/vk_menu.h @@ -39,6 +39,7 @@ struct Options { int gyro_sens_y = 100; // the motion aim's vertical sensitivity in percent int gyro_inv_x = 0; // the motion aim's horizontal turned around int gyro_inv_y = 0; // the motion aim's vertical turned around + int gyro_deadzone = 1; // the motion's deadzone: 0 tight, 1 standard (the console's default), 2 loose int robust = 0; // bounds-checked buffer access: 0 Auto (on), 1 Off, 2 On (gpu/vk/vk_main.cpp; RX_VK_ROBUST) int preset = 0; // quality preset: 0 Custom, 1 Minimum (4GB), 2 Low (6GB), 3 Balanced (8GB), 4 High (12GB), 5 Ultra (16GB+) int vram_mb = 0; // dynamic texture cache budget limit in MB diff --git a/gpu/vk/vk_replay.cpp b/gpu/vk/vk_replay.cpp index e6b315d98..9beb99a10 100644 --- a/gpu/vk/vk_replay.cpp +++ b/gpu/vk/vk_replay.cpp @@ -3736,7 +3736,7 @@ void fill_replay_table(MkwVkRenderer& t) { if (id == HOS_OPT_CHEAT_ACTION) return menu_take_cheat_action(); if (id == HOS_OPT_CHEAT_CAMERA) return menu_free_camera(); return id == 0 ? opts.realtime : id == 1 ? menu_is_open() : id == 2 ? resolution_auto() && !dlss_full_scale() : id == 3 ? (dlss_full_scale() ? 10000 : resolution_scale()) : id == 4 ? opts.pad_background : id == 5 ? opts.text_speed : id == 6 ? opts.mouse_native : - id == 7 ? opts.mouse_sens : id == 8 ? opts.mouse_invert : id == 9 ? opts.rumble : id == 16 ? opts.invert_cam_x : id == 17 ? opts.invert_cam_y : id == HOS_OPT_GYRO ? opts.gyro : id == HOS_OPT_GYRO_SENS_X ? opts.gyro_sens_x : id == HOS_OPT_GYRO_SENS_Y ? opts.gyro_sens_y : id == HOS_OPT_GYRO_INV_X ? opts.gyro_inv_x : id == HOS_OPT_GYRO_INV_Y ? opts.gyro_inv_y : + id == 7 ? opts.mouse_sens : id == 8 ? opts.mouse_invert : id == 9 ? opts.rumble : id == 16 ? opts.invert_cam_x : id == 17 ? opts.invert_cam_y : id == HOS_OPT_GYRO ? opts.gyro : id == HOS_OPT_GYRO_SENS_X ? opts.gyro_sens_x : id == HOS_OPT_GYRO_SENS_Y ? opts.gyro_sens_y : id == HOS_OPT_GYRO_INV_X ? opts.gyro_inv_x : id == HOS_OPT_GYRO_INV_Y ? opts.gyro_inv_y : id == HOS_OPT_GYRO_DEADZONE ? opts.gyro_deadzone : id == HOS_OPT_DLSS_JITTER_W || id == HOS_OPT_DLSS_JITTER_H ? (int)dlss_jitter_size(id - HOS_OPT_DLSS_JITTER_W) : id == HOS_OPT_DLSS_JITTER_PHASES ? (int)dlss_jitter_size(2) : 0; }; diff --git a/host/hos_motion.h b/host/hos_motion.h index 898f84174..503c16342 100644 --- a/host/hos_motion.h +++ b/host/hos_motion.h @@ -12,7 +12,8 @@ - the direction: the pad's orientation as a 3x3 matrix, from Mahony's filter (the gyroscope integrated as a quaternion, pulled toward the measured gravity: kp 0.3, ki 0.005), Eden's motion_input.cpp line for line. The gyroscope's resting rate (its bias) is learned while the pad is still on a table (both sensors steady) and - taken off, and rates under 0.007 turns a second count as none, as the console's standard zero-drift mode does. + taken off, and rates under 0.007 turns a second count as none, as the console's standard zero-drift mode does (the + Motion deadzone row: Low and High are its tight 0.002 and loose 0.01). Motion sensitivity X/Y and Invert motion X/Y scale the angular velocity before it is used: its part about the world's up (the turn, X) and the rest (the tilt, Y). At 100% and not inverted the state is Eden's own. Earlier versions sent a partial state (a yaw rate and a tilt matrix, the rest neutral, spluton's and Link-22's findings in @@ -55,6 +56,7 @@ typedef struct { float gmean[3], gvar, avec[3], avar, amean; /* the short window's means and variances (stillness) */ int stats_ready, only_accelerometer, reset_counter; float last_accel[3], last_gyro[3], pending_dt; + float threshold; /* turns a second, the deadzone (0: HOS_MOTION_THRESHOLD) */ float up[3]; /* the world's up in the Switch's frame (from the accelerometer, smoothed: for the split of the angular velocity into turn and tilt) */ HosMotionState out; @@ -66,6 +68,8 @@ typedef struct { #define HOS_MOTION_WINDOW_S 0.2f #define HOS_MOTION_DT_MAX 0.1f /* (Eden ignores a longer gap) */ #define HOS_MOTION_THRESHOLD 0.007f /* turns a second: less is none (the console's standard zero-drift mode) */ +#define HOS_MOTION_THRESHOLD_TIGHT 0.002f /* (its tight and loose modes: the Motion deadzone row's Low and High) */ +#define HOS_MOTION_THRESHOLD_LOOSE 0.01f static inline void hos_motion_rest(HosMotionState *o) { memset(o, 0, sizeof *o); @@ -188,7 +192,7 @@ static inline int hos_motion_update(HosMotion *m, const float accel_sdl[3], cons g[i] -= m->bias[i]; g[i] = g[i] < -5 ? -5 : g[i] > 5 ? 5 : g[i]; } - if (hos_motion_len(g) < HOS_MOTION_THRESHOLD) g[0] = g[1] = g[2] = 0; + if (hos_motion_len(g) < (m->threshold > 0 ? m->threshold : HOS_MOTION_THRESHOLD)) g[0] = g[1] = g[2] = 0; else m->only_accelerometer = 0; /* the turn and the tilt: the angular velocity's part about the world's up, and the rest, scaled and turned around as the menu says (the up from the accelerometer, smoothed) */ diff --git a/host/pad_sdl.c b/host/pad_sdl.c index bd504f69c..34ff3b174 100644 --- a/host/pad_sdl.c +++ b/host/pad_sdl.c @@ -179,6 +179,11 @@ static void motion_poll(int64_t now, int count) { /* the filter runs outside the lock (the game's input calls take it); a repeat of the last reading is no reading */ const int64_t dt_ns = now - motion_ns; motion_ns = now; + { /* (the Motion deadzone row: the console's tight, standard and loose zero-drift thresholds, turns a second) */ + static const float zone[3] = {HOS_MOTION_THRESHOLD_TIGHT, HOS_MOTION_THRESHOLD, HOS_MOTION_THRESHOLD_LOOSE}; + const int z = rx_option(HOS_OPT_GYRO_DEADZONE, 1); + motion.threshold = zone[z < 0 ? 0 : z > 2 ? 2 : z]; + } if (!hos_motion_update(&motion, a, g, (float)(dt_ns / 1e9), rx_option(HOS_OPT_GYRO_SENS_X, 100) / 100.0f, 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) diff --git a/readme.md b/readme.md index f24163c7f..23cd79061 100644 --- a/readme.md +++ b/readme.md @@ -240,7 +240,7 @@ choices and starts the game again, so progress since your last save is lost. | | Invert camera Y | Off, On | Off | | | Rumble strength | Off, 25% to 150%, in steps of 25 | 100% | | | Layout preset | Nintendo (Def), Xbox / PC, Swap Face, Custom | Nintendo (Def) | -| | Motion | Opens the Motion page: Motion controls (Off, On; On), Motion sensitivity X and Y (10% to 300%; 100%), Invert motion X and Y (Off, On; Off) | | +| | Motion | Opens the Motion page: Motion controls (Off, On; On), Motion sensitivity X and Y (10% to 300%; 100%), Invert motion X and Y (Off, On; Off), Motion deadzone (Low, Standard, High; Standard) | | | | Controls | Opens the Controls page | | - **Quality preset** sets Render resolution, Window scaling filter, Sharpness, Frame generation, Frame pacing, Game diff --git a/tests/motion_test.c b/tests/motion_test.c index 396f08635..3879af717 100644 --- a/tests/motion_test.c +++ b/tests/motion_test.c @@ -166,6 +166,18 @@ int main(void) { run(&m, 0, 0.03f, 0, 0.1f, 1, 1, 0, 0); NEAR(m.out.gyro[2], 0, 1e-6); + /* the Motion deadzone row: Low (the console's tight mode) lets a 0.005 turns/s turn through, Standard does not, + High (loose) also drops 0.008 */ + start(&m, FLAT); + run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0); + m.threshold = HOS_MOTION_THRESHOLD_TIGHT; + run(&m, 0, 0.03f, 0, 0.1f, 1, 1, 0, 0); + NEAR(m.out.gyro[2], 0.03 / HOS_MOTION_TAU, 1e-4); + m.threshold = HOS_MOTION_THRESHOLD_LOOSE; + run(&m, 0, 0.05f, 0, 0.1f, 1, 1, 0, 0); + NEAR(m.out.gyro[2], 0, 1e-6); + m.threshold = 0; + /* the sensitivities and the inverts: the turn (about the world's up) and the tilt (the rest) each scaled */ start(&m, FLAT); run(&m, 0, 0, 0, 0.3f, 1, 1, 0, 0); From 11d915cd0dd005f9d750867b71f4d133c6b79062 Mon Sep 17 00:00:00 2001 From: wyatt-poole <246767831+wyatt-poole@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:53:24 -0500 Subject: [PATCH 9/9] Motion controls: scale turn and tilt in the world's frame; vk_api comments 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 Claude-Session: https://claude.ai/code/session_01BsKC8mjbyEUFAv6uy3SQiW --- gpu/vk/vk_api.h | 10 +++++----- host/hos_motion.h | 15 ++++++++++++++- tests/motion_test.c | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/gpu/vk/vk_api.h b/gpu/vk/vk_api.h index 845e7ab37..d08f0dc4e 100644 --- a/gpu/vk/vk_api.h +++ b/gpu/vk/vk_api.h @@ -17,11 +17,11 @@ extern "C" { #define MKWVK_API_VERSION 9 enum { HOS_OPT_CHEAT_HEARTS = 32, HOS_OPT_CHEAT_FLIGHT, HOS_OPT_CHEAT_ACTION, HOS_OPT_CHEAT_CAMERA }; -/* (hookshot) the Controls tab's motion rows (host/pad_sdl.c reads them, vk_replay.cpp's option answers them): Motion - controls on or off, the horizontal's and the vertical's sensitivity in percent, and each turned around */ +/* (hookshot) the Motion page's rows (host/pad_sdl.c reads them, vk_replay.cpp's option answers them): Motion controls on + or off, the horizontal's and the vertical's sensitivity in percent, each turned around, and the deadzone (0 to 2) */ enum { HOS_OPT_GYRO = 18, HOS_OPT_GYRO_SENS_X, HOS_OPT_GYRO_INV_X, HOS_OPT_GYRO_INV_Y, HOS_OPT_GYRO_SENS_Y, HOS_OPT_GYRO_DEADZONE }; /* (hookshot) NVIDIA DLSS's camera jitter (host/rx_backend.c, gpu/vk/vk_dlss.cpp): the scene's width and height while DLSS - draws, 0 when there is to be no jitter, and the jitter sequence's length. 24 on: 18 to 22 are the motion rows' */ + draws, 0 when there is to be no jitter, and the jitter sequence's length. 24 on: 18 to 23 are the motion rows' */ enum { HOS_OPT_DLSS_JITTER_W = 24, HOS_OPT_DLSS_JITTER_H, HOS_OPT_DLSS_JITTER_PHASES }; /* (hookshot) the controls' names (host/nn_hid.c, host/pad_sdl.c, the pad_ and key_ lines of hookshot_options.ini): @@ -153,8 +153,8 @@ typedef struct MkwVkRenderer { void (*nvn_event_signal)(uint64_t gpu, uint32_t value); int (*option)(int id); /* (hookshot, the Insert menu) 0: game time follows the clock, 1: the menu is open, 2: dynamic resolution, 3: its fixed scale in percent, 4: the controllers count behind other windows, 5: text speed (0 normal to 3 instant), 6: native mouse look (host/mouse_native.c), 7: the mouse's sensitivity in percent, 8: its up and down swapped (host/nn_hid.c), - 18: motion controls from a pad's gyroscope (host/pad_sdl.c), 19 and 22: their horizontal - and vertical sensitivity in percent, 20 and 21: each turned around */ + 18 to 23: the Motion page (HOS_OPT_GYRO*: motion controls from a pad's gyroscope, host/pad_sdl.c; + the horizontal and vertical sensitivity in percent, each turned around, the deadzone) */ /* a submit with its queue */ void (*nvn_submit_q)(const uint64_t *handles, int n, uint64_t queue); /* (hookshot) a fence of one queue: the value its own last submission signals (nvnQueueFenceSync) */ diff --git a/host/hos_motion.h b/host/hos_motion.h index 503c16342..385a3b5e2 100644 --- a/host/hos_motion.h +++ b/host/hos_motion.h @@ -237,7 +237,20 @@ static inline int hos_motion_update(HosMotion *m, const float accel_sdl[3], cons if (settling) { memcpy(m->quat_out, m->quat, sizeof m->quat); } else { - hos_motion_rate(m, gs, rs); + /* the scaling in the world's frame (hckrman101): the real rate taken to the world by the real orientation, + its vertical part (the turn) scaled by sh and the rest (the tilt) by sv, and brought back to the pad by the + handed-out one, so a scaled turn while tilted turns quat_out about its own vertical and does not tip it. + Both matrices map the world to the pad (their third column is the world's up seen from the pad). */ + float dr[9], dq[9], w[3], go[3]; + hos_motion_direction(m->quat, dr); + hos_motion_direction(m->quat_out, dq); + { + const float sh = sens_h * (inv_h ? -1.0f : 1.0f), sv = sens_v * (inv_v ? -1.0f : 1.0f); + for (int i = 0; i < 3; i++) w[i] = dr[i] * g[0] + dr[3 + i] * g[1] + dr[6 + i] * g[2]; /* (dr^T g) */ + w[0] *= sv, w[1] *= sv, w[2] *= sh; + for (int i = 0; i < 3; i++) go[i] = dq[3 * i] * w[0] + dq[3 * i + 1] * w[1] + dq[3 * i + 2] * w[2]; + } + hos_motion_rate(m, go, rs); for (int i = 0; i < 3; i++) rs[i] += c[i]; hos_motion_integrate(m->quat_out, rs, dt); } diff --git a/tests/motion_test.c b/tests/motion_test.c index 3879af717..08e2fd030 100644 --- a/tests/motion_test.c +++ b/tests/motion_test.c @@ -209,6 +209,27 @@ int main(void) { same_direction(&m, &n, 0.04); } + /* a scaled turn while tilted keeps the handed-out orientation upright (the scaling in the world's frame, + hckrman101): 0.5 rad nose up at 200% (1.0 out), then a quarter turn about the vertical at 200% (a half turn out): + the world's up seen from the pad, its third column, is still a real 1.0 rad tilt's */ + start(&m, FLAT); + run(&m, 0, 0, 0, 0.5f, 2, 2, 0, 0); + run(&m, 1, 0, 0, 0.5f, 2, 2, 0, 0); + { /* (the quarter turn: about the world's up, seen from the pad) */ + const float u[3] = {su[0], su[1], su[2]}; + run(&m, u[0] * 1.5708f, u[1] * 1.5708f, u[2] * 1.5708f, 1, 2, 2, 0, 0); + } + run(&m, 0, 0, 0, 1, 2, 2, 0, 0); + start(&n, FLAT); + run(&n, 0, 0, 0, 0.5f, 1, 1, 0, 0); + run(&n, 1, 0, 0, 1, 1, 1, 0, 0); + run(&n, 0, 0, 0, 1.5f, 1, 1, 0, 0); + { + const float a3[3] = {m.out.direction[2], m.out.direction[5], m.out.direction[8]}; + const float b3[3] = {n.out.direction[2], n.out.direction[5], n.out.direction[8]}; + NEAR(dot3(a3, b3), 1, 2e-3); + } + /* a repeat of the last reading is no reading (its time goes into the next), and a stall is no step */ start(&m, FLAT); run(&m, 0, 0, 0, 0.2f, 1, 1, 0, 0);