From 5c9e480f908c4debe867b8fce3684df781f43060 Mon Sep 17 00:00:00 2001 From: chrg6 Date: Fri, 13 Nov 2026 19:00:47 -0500 Subject: [PATCH] Swing the shake key's stick by the game's updates instead of the clock, two updates a side at any frame rate. shake_stick picked the side from the time since the press, 67 ms a side (two frames at the game's 30 fps), so above 30 fps each side lasted more of the game's updates: 8 at 120 fps and 16 at 240, slower swings than the game reads at its own rate. The side now comes from the game's present count, which frame_rate.c already kept for the flight recorder and now gives through frame_step_presents, two presents a side. The press still swings for 800 ms of the clock and a hold keeps it going, as before; while no present comes (a load) the stick keeps its side. Not reproduced before the change (read from the code). The new tests/shake_key_check.py builds shake_stick from input.c and passes (it fails 118 times on the old code); the runtime compile check and the -Wall comparison of input.c and frame_rate.c show no new warnings. Co-Authored-By: Claude Opus 5.5 --- docs/building.md | 5 ++ runtime/frame_rate.c | 5 +- runtime/host.h | 1 + runtime/input.c | 14 +++-- tests/shake_key_check.py | 129 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 tests/shake_key_check.py diff --git a/docs/building.md b/docs/building.md index 7f6905cd..32a15087 100644 --- a/docs/building.md +++ b/docs/building.md @@ -756,6 +756,11 @@ files. A change to how a gamepad's axes are read (`runtime/input.c` `pad_axis`: an axis counts once it has been seen at rest) passes `python tests\pad_rest_check.py`, which builds that function with stand-ins for SDL. No game files. +A change to the shake key (`runtime/input.c` `shake_stick`) passes `python tests\shake_key_check.py`, which builds that +function with stand-ins for the clock and the game's present count: each side lasts two of the game's updates from 20 +to 240 fps, also with two reads an update, a press swings for 800 ms, a hold keeps swinging until its release, and a +swing without presents keeps its side. No game files. + A change to HD rumble's encoding or report schedule (`runtime/rumble_hd.c`: a vibration value as the bytes of a Switch controller's rumble report, and which reports are written when) passes `python tests\rumble_hd_check.py`, which builds that file alone: known values and the neutral one, the frequency and amplitude clamps (no amplitude step above the diff --git a/runtime/frame_rate.c b/runtime/frame_rate.c index 1f32e8da..282702ad 100644 --- a/runtime/frame_rate.c +++ b/runtime/frame_rate.c @@ -425,7 +425,10 @@ static double ocarina_step_s(void) { return base * speed / 60.0; } -static _Atomic uint64_t guest_presents; // frame_step_present calls so far (frame_step_flight) +static _Atomic uint64_t guest_presents; // frame_step_present calls so far (frame_step_flight, frame_step_presents) + +// The game's presents so far, one per update of the game at any frame rate (input.c shake_stick counts its updates). +uint64_t frame_step_presents(void) { return atomic_load_explicit(&guest_presents, memory_order_relaxed); } // The flight recorder's view of the step at a host present (flight.c, render thread): the newest step the patch page // holds in 60 Hz frames per update (set ostep; -1 without), the game's own step from its time manager in 60 Hz frames diff --git a/runtime/host.h b/runtime/host.h index cf6014ed..763d5a1e 100644 --- a/runtime/host.h +++ b/runtime/host.h @@ -469,6 +469,7 @@ void frame_rate_sound_gate_host(Cpu *c); // the sound job at wh void frame_rate_sound_step_host(Cpu *c); // the step the sound job hands on (set osound, page +0xf00) float frame_step_frames(void); // the 60 Hz frames the game steps per update (set ostep), else 1 void frame_step_flight(float *page, float *game, uint64_t *presents); // flight.c: the step at a host present +uint64_t frame_step_presents(void); // the game's presents so far: one per update of the game // rtss_limit.c: RivaTuner Statistics Server takes the frame limit where it runs void rtss_limit_want(int64_t mhz); // the pacer's limit in millihertz: >0, 0 none; or why RTSS is // kept out (rtss_decide.h RTSS_OUT_*) diff --git a/runtime/input.c b/runtime/input.c index 71de1758..88b16244 100644 --- a/runtime/input.c +++ b/runtime/input.c @@ -1032,21 +1032,23 @@ static void pick(int32_t *x, int32_t *y, int32_t kx, int32_t ky) { } // The shake key (B_SHAKE): the game detaches a part fused to the held object when the right stick swings left and -// right a few times. A press swings it for SHAKE_NS, holding keeps it swinging; each side lasts SHAKE_SIDE_NS, two -// frames at the game's 30 fps, so every side is read even at 20 fps. Returns the stick x, 0 while not shaking. +// right a few times. A press swings it for SHAKE_NS, holding keeps it swinging. Each side lasts SHAKE_SIDE_STEPS +// updates of the game, counted by its presents (frame_rate.c frame_step_presents), as two frames at the game's 30 fps +// did, so the game sees the same swings at any frame rate. A side of a fixed time (two 30 fps frames, 67 ms) lasted 8 +// updates at 120 fps and 16 at 240. Returns the stick x, 0 while not shaking. // Under lock. #define SHAKE_NS 800000000ull -#define SHAKE_SIDE_NS 66666667ull +#define SHAKE_SIDE_STEPS 2 static int32_t shake_stick(int held) { static int was; static uint64_t from, until; - uint64_t now = plat_now_ns(); - if (held && !was && now >= until) from = now; + uint64_t now = plat_now_ns(), steps = frame_step_presents(); + if (held && !was && now >= until) from = steps; if (held && !was) until = now + SHAKE_NS; if (held && until < now) until = now; was = held; if (now >= until && !held) return 0; - return (now - from) / SHAKE_SIDE_NS % 2 ? STICK_MAX : -STICK_MAX; + return (steps - from) / SHAKE_SIDE_STEPS % 2 ? STICK_MAX : -STICK_MAX; } // Under lock and inside sdl_enter (the gamepads' state is up to date), state 1: the open gamepad's inputs by position diff --git a/tests/shake_key_check.py b/tests/shake_key_check.py new file mode 100644 index 00000000..6be22765 --- /dev/null +++ b/tests/shake_key_check.py @@ -0,0 +1,129 @@ +"""The shake key swings the right stick by the game's updates, not by the clock (runtime/input.c shake_stick): builds +shake_stick, cut from input.c by its text, with stand-ins for the clock and the present count +(frame_rate.c frame_step_presents), and checks that + + - each side lasts two updates at 20, 30, 60, 120, 144 and 240 fps, starting left, also when the stick is read + twice an update; + - a press swings it for 800 ms, a hold keeps it swinging and the release ends it at once; + - a press inside the swing keeps its side, and a swing without presents (a load) keeps one side. + +No game files, no SDL. Usage: python tests/shake_key_check.py [--cc ] +""" +import argparse +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MSYS_BIN = Path(r'C:\msys64\ucrt64\bin') + +HARNESS = r''' +#include +#include +enum { STICK_MAX = 32767 }; +static uint64_t now_ns = 5000000000ull, presents = 1000; +static uint64_t plat_now_ns(void) { return now_ns; } +static uint64_t frame_step_presents(void) { return presents; } +%s +static int fails; +#define CHECK(cond, ...) do { if (!(cond)) { printf("FAIL: " __VA_ARGS__); putchar('\n'); fails++; } } while (0) + +// One update at hz: the clock moves a frame, the game presents once, and the stick is read reads times. +static int32_t update(int hz, int held, int reads) { + now_ns += 1000000000ull / (uint64_t)hz; + presents++; + int32_t x = 0; + for (int r = 0; r < reads; r++) { + int32_t y = shake_stick(held); + CHECK(r == 0 || y == x, "two reads in one update differ: %%d %%d", x, y); + x = y; + } + return x; +} + +static void settle(void) { + for (int i = 0; i < 4; i++) now_ns += 1000000000ull, presents++, shake_stick(0); +} + +int main(void) { + static const int rates[] = {20, 30, 60, 120, 144, 240}; + for (int i = 0; i < 6; i++) { + for (int reads = 1; reads <= 2; reads++) { + int hz = rates[i]; + settle(); + int32_t first = shake_stick(1); // the press, read before the update that shows it + CHECK(first == -STICK_MAX, "%%d fps: the swing starts left (%%d)", hz, first); + int n = 0, sides = 0; + int32_t last = first, run = 1; + for (int k = 0; k < hz * 2; k++) { + int32_t x = update(hz, 0, reads); + if (!x) break; + n++; + if (x != last) { + CHECK(run == 2, "%%d fps, %%d reads: a side lasted %%d updates", hz, reads, run); + sides++, run = 0, last = x; + } + run++; + } + // the press swings for 800 ms: 0.8 * hz updates after the press, the first counted at the press + int want = (int)(0.8 * hz + 0.5) - 1; + CHECK(n >= want - 1 && n <= want + 1, "%%d fps: %%d updates swung, want about %%d", hz, n, want); + CHECK(sides >= n / 2 - 1, "%%d fps: %%d sides in %%d updates", hz, sides, n); + } + } + // A hold keeps swinging past 800 ms; the release ends it at once. + settle(); + shake_stick(1); + int swung = 0; + for (int k = 0; k < 240 * 2; k++) swung += update(240, 1, 1) != 0; + CHECK(swung == 480, "a 2 s hold swung %%d of 480 updates", swung); + CHECK(update(240, 0, 1) == 0, "the release after a long hold ends the swing"); + // A second press inside the swing keeps the side it is on. + settle(); + shake_stick(1); + update(60, 0, 1), update(60, 0, 1); + int32_t on = update(60, 0, 1); + int32_t again = shake_stick(1); + CHECK(on == STICK_MAX && again == on, "a press inside the swing moved the side: %%d to %%d", on, again); + // No presents (a load screen): the stick keeps its side while the swing lasts. + settle(); + shake_stick(1); + for (int k = 0; k < 10; k++) { + now_ns += 50000000ull; + CHECK(shake_stick(0) == -STICK_MAX, "without presents the side changed at %%d", k); + } + printf(fails ? "shake key: %%d failures\n" : "shake key: ok\n", fails); + return fails != 0; +} +''' + + +def cut(text, start, end_marker): + a = text.index(start) + b = text.index(end_marker, a) + return text[a:b] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--cc', help='the C compiler (default: MSYS2 UCRT64 clang on Windows, clang elsewhere)') + args = ap.parse_args() + win = os.name == 'nt' + cc = args.cc or (str(MSYS_BIN / 'clang.exe') if win and MSYS_BIN.exists() else 'clang') + env = dict(os.environ) + if win and MSYS_BIN.exists(): + env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '') + src = (ROOT / 'runtime' / 'input.c').read_text(encoding='utf-8') + body = cut(src, '#define SHAKE_NS', '\n// Under lock and inside sdl_enter') + with tempfile.TemporaryDirectory(prefix='nx2navi-shake-') as tmp: + c = Path(tmp) / 'check.c' + c.write_text(HARNESS % body, encoding='utf-8') + exe = Path(tmp) / ('check.exe' if win else 'check') + subprocess.check_call([cc, '-O2', '-Wall', '-Wextra', '-Wno-unused-function', str(c), '-o', str(exe)], env=env) + return subprocess.call([str(exe)]) + + +if __name__ == '__main__': + sys.exit(main())