From c84724da2cd1a5650ba6cf15ec0d7eaa19498a53 Mon Sep 17 00:00:00 2001 From: chrg6 Date: Fri, 13 Nov 2026 07:07:34 -0500 Subject: [PATCH] Make a moving floor carry Link above 100 fps as it does at 60 (set ofollow, floor_carry = 1). The character controller's three follow-surface modules kept only a tenth of the floor's velocity whenever the physics' inverse step passed 100, which every update above 100 fps did. The gate now compares with 100 over frames per update from the patch page above 60 fps, and with the game's 100 at 60 and 30 fps. Co-Authored-By: Claude Opus 5.5 --- docs/building.md | 5 +++- nx2navi.ini.example | 4 ++++ recomp/patches-v111.txt | 30 ++++++++++++++++++++++++ runtime/cfg.c | 7 ++++-- runtime/frame_rate.c | 22 ++++++++++++++++++ tests/frame_step_check.c | 48 ++++++++++++++++++++++++++++++++++++--- tests/frame_step_check.py | 41 +++++++++++++++++++++++++++++++-- 7 files changed, 149 insertions(+), 8 deletions(-) diff --git a/docs/building.md b/docs/building.md index c1671e1a..34ea73cd 100644 --- a/docs/building.md +++ b/docs/building.md @@ -471,7 +471,10 @@ the water managers and floors of the set `owater` get 60 fixed steps a second wi frame gives what the game computes. It also checks that each of those sites in `recomp/patches-v111.txt` ends in one `bl` to its page slot, and runs the physics solver step count's words of the original and the sets `osolver` and `osolverfull` (`physics_steps` 0, 1 and 2), lifted as the generator lifts them: the game's 4 and 8 at 60 and 30 fps -for all three, rounded up by `osolver` and at least 4 every update by `osolverfull` above 60 fps. No game files. +for all three, rounded up by `osolver` and at least 4 every update by `osolverfull` above 60 fps. For the set `ofollow` +(`floor_carry`) it checks the two words of each of the three floor-carry gates and runs the gate's compare with the +game's words and the set's: the page's cut is 100 at 60 and 30 fps, and from 90 to 240 fps the set cuts a floor's +velocity at the time scales where the game cuts it at 60 fps and nowhere else. No game files. A change to how the mixer applies volumes (`runtime/audio_mixer.c` `accumulate_ramp`, the voice and splitter paths of `audio_mixer_render`) or to what it owes the device after a stall (`ring_pull`, `audio_mixer_owed`) passes diff --git a/nx2navi.ini.example b/nx2navi.ini.example index 6abeee94..723c251e 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -58,6 +58,10 @@ # held after an area unloaded until new sounds and then the music went silent. 1 runs the sound system 60 times a # second with whole frames while the frame rate is above 60; 0 is the game's own step. Read at start. # sound_steps = 1 +# Above 100 fps the game kept only a tenth of a moving floor's speed for whoever stood on it, so Link was left behind +# on moving platforms (the Deku Tree's floating one). 1 makes the floor carry him as at 60 fps at any frame rate; 0 is +# the game's own cut. Read at start; needs game code generated with this release. +# floor_carry = 1 # A movie's narration and fades follow the picture on screen: 1 counts their time from the shown picture's # timestamp, so they stay with it at any frame rate (with movie_fps = 0 too) and wait when the picture stalls; 0 counts # the game's redraws, as before, which keeps them with the music when the picture stalls. Read at start; needs game diff --git a/recomp/patches-v111.txt b/recomp/patches-v111.txt index dd993f5f..83674b4c 100644 --- a/recomp/patches-v111.txt +++ b/recomp/patches-v111.txt @@ -99,6 +99,36 @@ site osolver main 38afd10 bd440d02 1e2a1002 # the same three words in the seco site osolver main 38afd1c 1f010800 1f218800 site osolver main 38afd20 1e380001 1e280001 +# Floors that carry a character: the character controller's update (main+0x3890a80, run from the physics step's jobs) +# calls each of the controller's update modules (vtable +0x50). Three of them make the character follow what it stands +# on: CharacterUpdateFollowSurface (vtable main+0x4c35730, update main+0x13d490), CharacterUpdateFollowSurfaceExtension +# (main+0x4b9c008, main+0x139dc0) and CharacterUpdateDynamicFollowSurface (main+0x4c357b0, main+0x389aa40). Each finds +# the supporting body by its ID (the word at [[controller +8] +0x28] +0x2c, looked up in the physics system's body +# table), takes the body's velocity at the character (main+0x139c20: the linear part from the body's last and previous +# transforms times the inverse of the step, main+0x138940, plus the angular part) and adds it to the character's move. +# Before it adds it, each compares the physics system's inverse step ([[main+0x4e87ff0] +0xc0] +0x28, one over the +# frame's seconds times the time scale, main+0x3b69870) with 100.0 and, above it, keeps a tenth of the velocity +# (main+0x13dad4, main+0x13a39c, main+0x389af24; the 0.1 at main+0x3e40fb4). At the game's 60 and 30 fps the inverse +# step is 60 or 30, so that happens only in a slow motion under 0.6 of the game's speed; above 100 fps it happened every +# update. Link on the Deku Tree's floating platform (B1, Obj_Deku_MoveFloor_A_01) was carried at about a tenth of its +# speed at 117 and 160 fps, and the platform drifted from under him (at 240 fps not at all, which the tenth alone does +# not explain). The set compares with the page's +260 instead (runtime/frame_rate.c floor_carry_store, written with +# +256): 100 / frames per update under one frame per update, else 100. The inverse step times frames per update is 60 +# over the time scale at any frame rate, so above 60 fps the cut comes where it came at 60 fps, and at 60 fps and below +# (100 there, and until the first present) the words give what the game computes. At each site mov w8, #100.0 and +# fmov s1, w8 become adrp x8, page and ldr s1, [x8, #260]; the fcmp +# s0, s1 and b.gt after them are the game's. x8 is written again before any read on every path from the sites; s1 is +# written again before any read, or reaches only calls that take no float argument (getters such as main+0xacd430 and +# those behind vtable +0x68 and +0x210, nn::os::LockMutex and UnlockMutex, GetTlsValue, memset) and returns whose +# caller (main+0x3890a80) reads no float result. No word reads or writes the flags. +set ofollow NX2NAVI_FLOOR_CARRY 1 +site ofollow main 13d704 52a85908 @page:x8 # CharacterUpdateFollowSurface: mov w8, #0x42c80000 (100.0) -> adrp x8, page +site ofollow main 13d708 1e270101 bd410501 # fmov s1, w8 -> ldr s1, [x8, #260] +site ofollow main 13a064 52a85908 @page:x8 # the same two in CharacterUpdateFollowSurfaceExtension +site ofollow main 13a068 1e270101 bd410501 +site ofollow main 389ac20 52a85908 @page:x8 # and in CharacterUpdateDynamicFollowSurface +site ofollow main 389ac24 1e270101 bd410501 + # Fixed steps of the height-field surfaces: the physics' two water managers, phive::InteractiveWaterMgr (its update # main+0x3875f20, the vtable slot at main+0x4c32f90) and phive::ResponsiveWaterMgr (main+0x3882660, the slot at # main+0x4c33a88; the names are those of their postUpdateWorld profiler scopes, and their bodies are named diff --git a/runtime/cfg.c b/runtime/cfg.c index 8e7bcc6d..ac73e1f2 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -33,7 +33,7 @@ static int save_ok = 1; // the last cfg_save_to wrote (o // Every name the runtime reads (and the patch sets in generated/), without the prefix. A file key outside this list is // logged as unknown. A new variable goes here too, or its ini key logs a harmless "unknown key" line. static const char *const known[] = { - "CORES", "TM_DISPATCH", "NVN2_CS_TABLE", "SKIP_SHADERS", "MOVIE_DEBAND", "MOVIE_FPS", "MOVIE_SKIP", "MOVIE_OVERLAY", "MOVIES", "NPLN", "NVN2_PROGRAMS", "NVN_TRAP", "OCARINA_REALTIME", "ONESHOT_BUDGET", "ONESHOT_WAIT_MS", "OCARINA_STEP", "PHYSICS_STEPS", "PHYSICS_STEPS_FULL", "PICKUP_COUNT", "WATER_STEPS", "SOUND_STEPS", "FLOOR_PROBE", "FLOOR_SCAN", "MOVIE_PTS", "STEP_STEADY", "VI_HZ", "VI_VRR", "TIME_DUMP", "SHADER_DUMP", "SHOT_AFTER", "SHOT_DIR", "SHOT_EVERY", // Switch 2 port: movie skip (romfs.c), NVN2 proc traps (nvn_procs.c) + "CORES", "TM_DISPATCH", "NVN2_CS_TABLE", "SKIP_SHADERS", "MOVIE_DEBAND", "MOVIE_FPS", "MOVIE_SKIP", "MOVIE_OVERLAY", "MOVIES", "NPLN", "NVN2_PROGRAMS", "NVN_TRAP", "OCARINA_REALTIME", "ONESHOT_BUDGET", "ONESHOT_WAIT_MS", "OCARINA_STEP", "PHYSICS_STEPS", "PHYSICS_STEPS_FULL", "PICKUP_COUNT", "WATER_STEPS", "SOUND_STEPS", "FLOOR_PROBE", "FLOOR_SCAN", "FLOOR_CARRY", "MOVIE_PTS", "STEP_STEADY", "VI_HZ", "VI_VRR", "TIME_DUMP", "SHADER_DUMP", "SHOT_AFTER", "SHOT_DIR", "SHOT_EVERY", // Switch 2 port: movie skip (romfs.c), NVN2 proc traps (nvn_procs.c) "AA", "AA_LOG", "AB", "AMIIBO_DIR", "ANISO", "APP_POOL", "ASPECT", "ASPECT_PATCH", "ASSUME_FOCUS", "ASTC", "ASTC_BC", "ASTC_TIME", "ASYNC_PREEMPT", "AUDIO", "AUDIO_CATCHUP", "AUDIO_CHANNELS", "AUDIO_DEVICE", "AUDIO_GAME_PRIO", "AUDIO_OFF", "AUDIO_PREFILL_MS", "AUDIO_RAMP", "AUDIO_STATS", "BARRIER_GAME", "MESH_AHEAD", "MESH_FALLBACK", "MESH_FALLBACK_MB", "MESH_FALLBACK_WORKERS", "PROBE_MESHFB_DIR", "BACKGROUND_WINDOW", "BIND_A", "BIND_B", "BIND_DOWN", "BIND_L", "BIND_LCLICK", "BIND_LEFT", "BIND_LSTICK_DOWN", "BIND_LSTICK_LEFT", @@ -104,7 +104,7 @@ static const struct { const char *key, *section; } sections[] = { {"FPS_LIMIT", "frame rate"}, {"FPS60", "frame rate"}, {"RTSS", "frame rate"}, {"REALTIME", "frame rate"}, {"CLOTH_REALTIME", "frame rate"}, {"HIGHFPS_FIX", "frame rate"}, {"PHYSICS_STEPS", "frame rate"}, {"PICKUP_COUNT", "frame rate"}, {"WATER_STEPS", "frame rate"}, - {"SOUND_STEPS", "frame rate"}, + {"SOUND_STEPS", "frame rate"}, {"FLOOR_CARRY", "frame rate"}, {"MOVIE_PTS", "frame rate"}, {"STEP_STEADY", "frame rate"}, {"FULLSCREEN", "display"}, {"WINDOW_SIZE", "display"}, {"PERF_OVERLAY", "display"}, {"VK_PRESENT", "display"}, {"GPU_INDEX", "display"}, {"MONITOR", "display"}, {"OUTPUT_RES", "display"}, {"GAME_ASPECT", "display"}, {"GAME_RES", "display"}, {"RENDER_SCALE", "display"}, {"UPSCALER", "display"}, @@ -243,6 +243,9 @@ static const CfgMeta metas[] = { {"WATER_STEPS", "1", "0|1", RESTART}, // the set osound of patches-v111.txt (frame_rate.c frame_rate_sound_gate_host): the sound job at 60 Hz above 60 fps {"SOUND_STEPS", "1", "0|1", RESTART}, + // the set ofollow of patches-v111.txt (frame_rate.c floor_carry_store): a moving floor carries a character above + // 100 fps as at 60 + {"FLOOR_CARRY", "1", "0|1", RESTART}, // frame_rate.c floor_probe: lines about Link on a moving floor (0 off, 1 = 4000 lines, or a count) {"FLOOR_PROBE", "0", NULL, RESTART}, // frame_rate.c floor_scan: finds the moving floor under Link in guest memory (0 off, 1 = 20000 lines, or a count) diff --git a/runtime/frame_rate.c b/runtime/frame_rate.c index 30a6f79f..c0561407 100644 --- a/runtime/frame_rate.c +++ b/runtime/frame_rate.c @@ -68,6 +68,20 @@ static _Atomic uint64_t *step_word; // patch page +0: ratio (low float), secon static double step_now = STEP_FIRST_S; // the step the patch page holds: what the game steps each frame static _Atomic uint32_t *step_interval; // patch page +8: seconds * 60 (patches-v143.txt realtime) static _Atomic uint32_t *ostep_page; // patch page +256 with the set ostep: frames per update (patches-v111.txt) +static _Atomic uint32_t *carry_page; // patch page +260 with the set ofollow: 100 / frames per update + +// The set ofollow (patches-v111.txt): the three follow-surface modules of the character controller keep only a tenth +// of the supporting floor's velocity when the physics' inverse step is over the number at page +260. The game compares +// with 100, which the inverse step passed at every update above 100 fps; under one frame per update 100 / frames per +// update makes the cut come where it came at 60 fps. At one frame or more it stays 100, so 60 and 30 fps keep the +// game's own cut. Written with frames per update (ocarina_step_write). +static void floor_carry_store(float frames) { + if (!carry_page || !(frames > 0)) return; + float cut = frames < 1.0f ? 100.0f / frames : 100.0f; + uint32_t bits; + memcpy(&bits, &cut, 4); + atomic_store_explicit(carry_page, bits, memory_order_release); +} static uint64_t last_ns; static double samples[STEP_SAMPLES]; static unsigned sample_count, sample_next; @@ -96,6 +110,13 @@ void frame_step_init(void) { atomic_store_explicit(ostep_page, bits, memory_order_release); rt_log("frame rate: the game's frames per update follow the display interval (set ostep)"); } + int follow = rt_patch_set("ofollow"); + if (follow >= 0) { + carry_page = (_Atomic uint32_t *)((uint8_t *)rt_patch_page() + 260); + floor_carry_store(1.0f); + rt_log("frame rate: a moving floor carries a character as at 60 fps: %s", + rt_patch_enabled(follow) ? "on (set ofollow)" : "off, the game's cut above 100 fps (floor_carry = 0)"); + } // physics_steps (patches-v111.txt osolver and osolverfull): 0 the game's solver step count, trunc(frames per update // * 4 + 0.001); 1 (the default) that count rounded up; 2 rounded up and at least the 4 of a 60 Hz frame, every // update. The two sets patch the same words; this start keeps one of them on. @@ -725,6 +746,7 @@ static void ocarina_step_write(double dt) { uint32_t bits; memcpy(&bits, &f, 4); atomic_store_explicit(ostep_page, bits, memory_order_release); + floor_carry_store(f); ostep_last = f; return; } diff --git a/tests/frame_step_check.c b/tests/frame_step_check.c index fd866fd1..e80a2260 100644 --- a/tests/frame_step_check.c +++ b/tests/frame_step_check.c @@ -21,8 +21,8 @@ const char *cfg_get(const char *key) { return NULL; } unsigned cfg_version(void) { return version; } -int rt_patch_set(const char *name) { return !strcmp(name, "ostep") ? 0 : -1; } -int rt_patch_enabled(int set) { return set == 0; } +int rt_patch_set(const char *name) { return !strcmp(name, "ostep") ? 0 : !strcmp(name, "ofollow") ? 1 : -1; } +int rt_patch_enabled(int set) { return set == 0 || set == 1; } void rt_patch_disable(int set) { (void)set; } void rt_patch_switch(int set, int on) { (void)set, (void)on; } void *rt_patch_page(void) { return page; } @@ -148,13 +148,29 @@ static uint32_t solver_steps(void (*fn)(Cpu *), float frames) { return (uint32_t)c.x[1]; } +// The floor-carry gate (set ofollow): 1 when its b.gt is taken, which keeps a tenth of the floor's velocity. s0 is the +// physics' inverse step; the set's load reads the page through x8. +void follow_game(Cpu *c); +void follow_ofollow(Cpu *c); +static int follow_cut(void (*fn)(Cpu *), float inverse) { + Cpu c; + memset(&c, 0, sizeof c); + uint32_t bits; + memcpy(&bits, &inverse, 4); + c.v[0] = bits, c.x[8] = (uint64_t)(uintptr_t)page; + fn(&c); + return !FLAG_Z(&c) && FLAG_N(&c) == FLAG_V(&c); // gt +} + int main(int argc, char **argv) { int mean = argc > 1 && !strcmp(argv[1], "mean"); if (mean) steady = "0"; frame_step_init(); - float one; + float one, cut0; memcpy(&one, page + 256, 4); + memcpy(&cut0, page + 260, 4); CHECK(one == 1.0f, "the page holds one frame before the first present, not %g", one); + CHECK(cut0 == 100.0f, "the floor carry's cut is the game's 100 before the first present, not %g", cut0); const double p60 = 16666667e-9, p240 = 4166667e-9; if (mean) { // the comparison setting: 5 and 30 ms in turn give 15 and 20 ms in turn @@ -379,6 +395,32 @@ int main(int argc, char **argv) { 144 * solver_steps(solver_osolver, 60.0f / 144.0f), 240 * solver_steps(solver_osolver, 0.25f), 144 * solver_steps(solver_osolverfull, 60.0f / 144.0f), 240 * solver_steps(solver_osolverfull, 0.25f)); } + // The floor carry (set ofollow). The physics' inverse step is 60 / (frames per update * time scale); the game cuts + // a floor's velocity to a tenth above 100, which at 60 fps is a time scale under 0.6 and above 100 fps every + // update. With the page's 100 / frames per update the set cuts exactly where the game cuts at 60 fps at any rate + // above 60; at 60 and 30 fps the page holds 100 and the set cuts where the game's own words do. + { + const char *limits[] = {"60", "30", "90", "120", "144", "240"}; + const int hz[] = {60, 30, 90, 120, 144, 240}; + const float scales[] = {1.0f, 0.8f, 0.62f, 0.58f, 0.5f, 0.25f}; + for (int r = 0; r < 6; r++) { + set_limit(limits[r]); + float frames = 0, cut; + for (int i = 0; i < 60; i++) frames = present(1.0 / hz[r]); + memcpy(&cut, page + 260, 4); + CHECK(cut == (frames < 1 ? 100.0f / frames : 100.0f), "%d fps (%g frames): the floor carry's cut is %g", + hz[r], frames, cut); + for (int s = 0; s < 6; s++) { + float inverse = 60.0f / (frames * scales[s]); + int set = follow_cut(follow_ofollow, inverse), + want = follow_cut(follow_game, hz[r] > 60 ? 60.0f / scales[s] : inverse); + CHECK(set == want, "%d fps, time scale %.2f: the set %s, the game %s %s", hz[r], scales[s], + set ? "cuts" : "carries", hz[r] > 60 ? "at 60 fps" : "here", want ? "cuts" : "carries"); + } + CHECK(follow_cut(follow_game, 60.0f / frames) == (hz[r] > 100), "%d fps: the game's own words %s", hz[r], + hz[r] > 100 ? "do not cut" : "cut"); + } + } printf("frame_step_check: %s\n", fails ? "FAILED" : "ok"); return fails != 0; } diff --git a/tests/frame_step_check.py b/tests/frame_step_check.py index 5e132cc3..5fd37fb0 100644 --- a/tests/frame_step_check.py +++ b/tests/frame_step_check.py @@ -92,13 +92,50 @@ def solver_functions(): return '\n'.join(out) +# The floor-carry gate of the three follow-surface modules (set ofollow): mov w8, #100.0 and fmov s1, w8 before the +# game's fcmp s0, s1 (s0 the physics' inverse step) and the b.gt to the tenth of the floor's velocity. The set makes the +# two words adrp x8, page and ldr s1, [x8, #260]; the test lifts the load and the fcmp with x8 set to its page. +FOLLOW_SITES = (0x13d704, 0x13a064, 0x389ac20) +FOLLOW_GAME = (0x52a85908, 0x1e270101) # mov w8, #0x42c80000; fmov s1, w8 +FOLLOW_LOAD = 0xbd410501 # ldr s1, [x8, #260] +FCMP = 0x1e212000 # fcmp s0, s1 + + +def check_follow(): + """Each gate's two words are the set ofollow's adrp to the page in x8 and its load of +260, over the game's.""" + table = load(ROOT / 'recomp/patches-v111.txt', [('main', 0x7000000000, PAGE)]) + names = {i: t[0] for i, t in enumerate(table.sets)} + bad = 0 + for site in FOLLOW_SITES: + first = {names[k]: (words, have) for k, words, have in table.sites['main'].get(site, [])} + second = {names[k]: (words, have) for k, words, have in table.sites['main'].get(site + 4, [])} + if (first != {'ofollow': ([('page', 8)], FOLLOW_GAME[0])} or + second != {'ofollow': ([FOLLOW_LOAD], FOLLOW_GAME[1])}): + print(f'FAIL: the floor-carry gate at main+{site:#x} is {first} / {second}') + bad += 1 + return bad + + +def follow_functions(): + lift = Lifter(0, simd='c') + out = [] + for name, words in (('game', FOLLOW_GAME + (FCMP,)), ('ofollow', (FOLLOW_LOAD, FCMP))): + body = [] + for k, w in enumerate(words): + insn = lift.lift(w, 0x7000000000 + FOLLOW_SITES[0] + 4 * k + (4 if name == 'ofollow' else 0)) + assert insn.kind == 'op', (name, hex(w), insn.kind) + body.append(' ' + insn.code) + out.append(f'void follow_{name}(Cpu *c) {{\n' + '\n'.join(body) + '\n}\n') + return '\n'.join(out) + + def main(): work = ROOT / 'build' / 'test' / 'frame_step' work.mkdir(parents=True, exist_ok=True) (work / 'layout.h').write_text(LAYOUT) exe = work / ('frame_step_check.exe' if os.name == 'nt' else 'frame_step_check') solver = work / 'solver_sites.c' - solver.write_text(solver_functions()) + solver.write_text(solver_functions() + '\n' + follow_functions()) env = dict(os.environ) if os.name == 'nt' and MSYS_BIN.exists(): env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '') @@ -106,7 +143,7 @@ def main(): '-I', str(ROOT / 'runtime'), '-I', str(work), str(ROOT / 'tests' / 'frame_step_check.c'), str(solver), '-o', str(exe), '-lm'], env=env) rc = subprocess.run([str(exe)], env=env).returncode - return subprocess.run([str(exe), 'mean'], env=env).returncode or rc or check_sites() + return subprocess.run([str(exe), 'mean'], env=env).returncode or rc or check_sites() + check_follow() if __name__ == '__main__':