From 02dc1b24b3148ca7df273672e0880151969c4024 Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Mon, 9 Nov 2026 16:00:24 -0500 Subject: [PATCH] Ocarina of Time: the half rate's two calcs count a frame's vsyncs once, the stub's branches are computed, and a test runs it at both base rates Base 30 in the frame ratio is the framework's half rate, not a cutscene speed: bit 0 of its +0x24f is the mode and bit 1 flips each pass of its frame function, and with both set the draw and the present return at once while the calc tree runs. The game then calcs twice a drawn frame, the ratio's function among them, so each calc takes half the frame's step: step x base / 60 is right, and the step alone (PR #1996) would run that mode at twice its speed. The ratio's unit is 1/60 s whatever the base (the time object's step in seconds is ratio x 1/60). The stub counted the frame's vsyncs at both calcs, so the host's game time would read twice the real time in that mode. It now counts them at the calc that is drawn. Its branch targets are computed from the words emitted instead of fixed offsets. test_oot_frame_step.py runs the emitted stub under Unicorn (optional, as test_astral_fps.py): both bases at steps 1 to 3, the count, x17 lost after a store, and the game's own division at target 0 and before a step is decided. It fails on main's stub (the count) and on PR #1996's (the ratio). Co-authored-by: Zard Studios <96824937+Zard-Studios@users.noreply.github.com> Co-authored-by: dsi-hub <260802842+dsi-hub@users.noreply.github.com> --- dm/games/0400486024DEC000/README.md | 1 + dm/games/0400486024DEC000/patches.py | 53 ++++++++----- dm/tools/tests/test_oot_frame_step.py | 110 ++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 dm/tools/tests/test_oot_frame_step.py diff --git a/dm/games/0400486024DEC000/README.md b/dm/games/0400486024DEC000/README.md index ad02695a39..2550b4f23a 100644 --- a/dm/games/0400486024DEC000/README.md +++ b/dm/games/0400486024DEC000/README.md @@ -34,6 +34,7 @@ Measured on an M6 Mac with 16 GB, headless, on one walk with camera turns (`walk - Current on an M5 Pro MacBook Pro (16-inch, 20-core GPU, 24 GB, macOS 27.2, 2026-11-09), `kokiri-deck` from a deck autosave with the full library of chain `dedc9391c30b`, at full resolution (Dynamic resolution off), two runs a variant: in a window a locked 60 fps (59.8–60.0, frame p99 16.8–19.4 ms, GPU p50 10.2 ms). In fullscreen on its ProMotion screen it held 40 fps (39.6–40.1) at the same GPU time, with every present a 120 Hz refresh late and every other link tick skipped: [The 60 Hz link at 40 fps in fullscreen on a ProMotion screen](../../../docs/reference/frame-rate.md#the-60-hz-link-at-40-fps-in-fullscreen-on-a-promotion-screen). With the 120 Hz link there, 58.8–59.6 fps, frame p99 25.3 ms; with the screen held at its fixed 60 Hz mode, 60.0 fps, frame p99 16.9 ms, which fullscreen now switches to. MetalFX present upscaling to the 3456-wide fullscreen cost no measurable GPU time (59.4–59.7 fps). Resident about 10 GB, GPU allocated 11.2–11.4 GB; the prepare step took 7.3 s on a first start. - Current: the title and menu, a movie behind them, are a locked 60 fps (p50 16.67 ms, GPU span 15.6 ms). - Game speed: the game steps its time by a frame ratio, its base rate (60, or 30) / its int frame rate, whatever the frame took (`main+0x6e6cc4` on v131072), so frames longer than a vsync played in slow motion (seen by the user on a slower Mac; [issue 1375](https://github.com/seedds/nxnative/issues/1375)'s M1 Max ran the title at about 28 fps). With `fps_target 60` the ratio is the host's frame step × base / 60 ([A fixed frame step plays in slow motion on a slow Mac](../../../docs/reference/frame-rate.md#a-fixed-frame-step-plays-in-slow-motion-on-a-slow-mac)), a stub placed by its function's fingerprint ([`patches.py`](patches.py)). Mac runs on the M6 (2026-11-08, a test host on the libraries of chain `3ee493b2c3a4`): the user's play sessions held 60 fps with 2- and 3-vsync holds while areas loaded, game time 1.000 of real time by the host's count. That count is the stub's own; no run on a Mac slow enough to show the slow motion before and after. +- Current: the base rate of 30 is the framework's half rate, not a cutscene speed. The ratio's unit is 1/60 s whatever the base (the time object's step in seconds is ratio / 60, `main+0x3b4e300`). While bit 0 of the framework's `+0x24f` is set, bit 1 flips each pass of its frame function (`main+0x346c680`), and with both set the draw and the present return at once (`main+0x2cb020`, `main+0x40cd20`): the calc tree, the ratio's function in it (`ModuleSystem::moduleCalc_` by its job's name), runs twice a drawn frame. So each calc takes half the frame's step, step × 30 / 60, as the console's 30 / 30 = 1 twice a 30 fps frame; the step alone ([PR #1996](https://github.com/seedds/nxnative/pull/1996)) would run that mode at twice its speed. The stub counts a half-rate frame's vsyncs once, at the calc that is drawn: counted at both, the log's game time read twice the real time there. `test_oot_frame_step.py` runs the stub at both bases under Unicorn and fails on the step alone. The mode was not met in play: on an iPhone Duo (2026-11-09, docked, a build with HookLink's stub, through the opening, Link's house, Kokiri Forest and the Deku Tree's speech) the stub ran once a present in every 1,800-present window (its vsyncs and the dropped ones add up to the real time within 0.03 s). What the half-speed cutscenes of that PR were: [movies](https://github.com/seedds/nxnative/issues/1922), whose player takes one picture a calc. - Current: walking the village at the game's top internal resolution, 1536x864: a locked 60 fps, mean 16.67 ms over each settled window. Per 2,700 frames: 0-8 frames over 25 ms and 0-2 over 33 ms. Frame p95 17.8-18.9 ms with no window, as the game's own loop moves around its fence wait; in a window p95 17.8 ms, 3 over 25 ms and 5-16 over 18 ms per 600. GPU span p50 16.5 ms, summed GPU 15.8 ms, CPU 6 ms, about 101 passes a frame. The heavy stretch where the village opens (about 1,560 draws) holds 16.65-16.8 ms by lowering its resolution, then returns to full. The main pass used to sit at 763x429 at the same GPU time: [marked shaders tested depth late](../../../docs/reference/frame-rate.md#a-hosts-own-store-in-a-fragment-shader-moves-its-depth-test). - Before [pool shaders stopped counting as depth readers](../../../docs/reference/rendering.md#pool-shaders-taken-as-depth-readers): passes 118, 104, 219, 224 in a four-frame cycle, frame times 15, 15, 18, 18.5 ms, mean 16.9 ms. Half the frames rendered the reflection probe's faces with one draw a pass. - The host's GPU figure adds up command buffers, which overlap, so it reads above the real span: 17.5 ms was never a frame's real time. The real span is `@gpu_end_ms − @gpu_beg_ms` in `DM_NVN_TSV`, or the `first start to last end` of `DM_NVN_GPUPROF`. diff --git a/dm/games/0400486024DEC000/patches.py b/dm/games/0400486024DEC000/patches.py index cade577a2e..7aa3ae59b9 100644 --- a/dm/games/0400486024DEC000/patches.py +++ b/dm/games/0400486024DEC000/patches.py @@ -2,33 +2,50 @@ # MATCH_FRAME_STEP as described there ("Registered patches"). Patches of Nintendo SDK module builds are in dm/tools/sdk_patches.py from nso_asm import ADRP, B -# The game's frame ratio. Once a frame the function at main+0x6e6be0 (v131072) divides its base rate (60.0, or 30.0 while bit 0 of [x21+0x24f] is set) by the int frame rate at -# [x21+0x2c8] (fdiv s8,s1,s0 at +0xe4) and hands the result to the time object (main+0x3b4e300: ratio at +0x10, 1 / ratio at +0x14, round(60 / ratio) at +0x1c), which the game -# steps by; the frame's own time never enters it, so a frame that takes two vsyncs at rate 60 plays at half speed. The stub gives the ratio the host's step instead: step × base / 60 -# (the division's value when the host keeps frames 60 / rate vsyncs), and counts the vsyncs stepped. Until the host has decided a step, and at fps_target 0, the division runs. -# s0 and x16 / x17 are free there (s0 is written again on every path before it is read; the function uses neither x16 nor x17); s1 keeps the base rate. +# The game's frame ratio. Once a frame the function at main+0x6e6be0 (v131072, ModuleSystem::moduleCalc_ by the name of its job) divides its base rate (60.0, or 30.0 while +# bit 0 of [x21+0x24f] is set) by the int frame rate at [x21+0x2c8] (fdiv s8,s1,s0 at +0xe4) and hands the result to the time object (main+0x3b4e300: ratio at +0x10, 1 / ratio +# at +0x14, ratio / 60 at +0x18, the step in seconds, round(60 / ratio) at +0x1c), which the game steps by; the frame's own time never enters it, so a frame that takes two +# vsyncs at rate 60 plays at half speed. The stub gives the ratio the host's step instead: step × base / 60, and counts the vsyncs stepped. Until the host has decided a step, +# and at fps_target 0, the division runs. s0 and x16 / x17 are free there (s0 is written again on every path before it is read; the function uses neither x16 nor x17); s1 keeps +# the base rate and x21 the framework. +# Why × base / 60 and not the step alone (PR #1996 read base 30 as a cutscene rate and took the step, twice the speed there): the ratio's unit is 1/60 s whatever the base +# (the time object's +0x18 is ratio × 1/60, the constant at main+0x3e4066c). Base 30 is the framework's half rate: bit 0 of its +0x24f is that mode and bit 1 flips each pass of +# its frame function (main+0x346c680); with both set the draw and the present return at once (main+0x2cb020, main+0x40cd20) while the calc tree runs (main+0x2cabd0). So it +# calcs twice a drawn frame, this function among them, and each calc takes half the frame's step: 30 / 30 = 1 twice a 30 fps frame on the console, step / 2 twice here. The +# vsyncs are counted once a drawn frame, at the calc that is drawn (bit 1 clear): counted at both, the host's game time read twice the real time in that mode. +# The branch targets are computed from the words emitted, so a word added to the stub cannot leave one pointing into its middle (dsi-hub's HookLink met that on an iPhone, +# a fault on the first frame, when a block was added between main's fixed offsets and their target). # Lichii (a GPL emulator; only the fact was taken) found the division (its notes: main+0x6E6CC4, 60 / [x21+0x2C8]) def frame_step(ctx): here, emit, fs_site, fs_put, FS_STATE_OFF, base = ctx.here, ctx.emit, ctx.site, ctx.put, ctx.state, ctx.base site = base + 0xe4 fs_site(site, '2818201e') # fdiv s8,s1,s0 - start = here(); emit(ADRP(16, here(), FS_STATE_OFF)) - for w in (0xb9400211, # ldr w17,[x16] target fps (0 = off) - 0x340001b1, # cbz w17,orig (+13) - 0xb9402211, # ldr w17,[x16,#0x20] step in vsyncs - 0x34000171, # cbz w17,orig (+11) not decided yet - 0x1e230220, # ucvtf s0,w17 - 0xf9401a10, # ldr x16,[x16,#0x30] + start = here() + ORIG, COUNTED, n = 18, 13, [0] # the original division is word 18 of the stub, the ratio starts at word 13 (both checked below); n counts the words emitted + def put(w): emit(w); n[0] += 1 + def cbz_w17_orig(): return 0x34000000 | (((ORIG - n[0]) & 0x7FFFF) << 5) | 17 # from this word to word ORIG + put(ADRP(16, here(), FS_STATE_OFF)) + put(0xb9400211) # ldr w17,[x16] target fps (0 = off) + put(cbz_w17_orig()) # cbz w17,orig + put(0xb9402211) # ldr w17,[x16,#0x20] step in vsyncs + put(cbz_w17_orig()) # cbz w17,orig not decided yet + put(0x1e230220) # ucvtf s0,w17 + put(0x39493eb0) # ldrb w16,[x21,#0x24f] the framework's half-rate bits + put(0x37000000 | 1 << 19 | ((COUNTED - n[0]) & 0x3FFF) << 5 | 16) # tbnz w16,#1,ratio: the calc of a half-rate frame that is not drawn; its vsyncs are counted at the drawn one + put(ADRP(16, here(), FS_STATE_OFF)) + for w in (0xf9401a10, # ldr x16,[x16,#0x30] 0x8b110210): # add x16,x16,x17 - emit(w) - emit(ADRP(17, here(), FS_STATE_OFF)) - for w in (0xf9001a30, # str x16,[x17,#0x30] vsyncs stepped - 0x1e210808, # fmul s8,s0,s1 step × base rate + put(w) + put(ADRP(17, here(), FS_STATE_OFF)) + put(0xf9001a30) # str x16,[x17,#0x30] vsyncs stepped + assert n[0] == COUNTED, f'frame_step: the ratio starts at word {n[0]}, the tbnz goes to word {COUNTED}' + for w in (0x1e210808, # fmul s8,s0,s1 step × base rate 0x52a84e10, # mov w16,#0x42700000 60.0 0x1e270200, # fmov s0,w16 0x1e201908): # fdiv s8,s8,s0 / 60 - emit(w) - emit(B(here(), site + 4)) + put(w) + put(B(here(), site + 4)) + assert n[0] == ORIG, f'frame_step: the original division is word {n[0]}, the cbz branches go to word {ORIG}' emit(0x1e201828); emit(B(here(), site + 4)) # orig: fdiv s8,s1,s0; back fs_put(site, [B(site, start)], 'frame ratio: the host\'s step × base rate / 60 instead of base rate / the game\'s frame rate (state page target fps; 0 = the game\'s ratio)') dynamic_resolution(ctx) diff --git a/dm/tools/tests/test_oot_frame_step.py b/dm/tools/tests/test_oot_frame_step.py new file mode 100644 index 0000000000..04fe9e7d9c --- /dev/null +++ b/dm/tools/tests/test_oot_frame_step.py @@ -0,0 +1,110 @@ +"""Execute Ocarina of Time's frame-step stub (patches.py frame_step): the host's step × base / 60. + +Runs the emitted code under Unicorn (an optional dependency). Base 30 is the framework's half rate, two calcs a drawn frame (bit 0 +of its +0x24f the mode, bit 1 the calc that is not drawn): each calc takes half the frame's step, and the frame's vsyncs are +counted once. PR #1996 took the step alone there, twice the speed. In rewritten code x17 does not survive a store, so the +`clobber_x17` runs zero it after every store, as the rewriter can: a stub that keeps something in it across one fails here. +The harness is HookLink's (dsi-hub). +""" +import importlib +import importlib.util +from pathlib import Path +import struct +import sys +import unittest + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO / 'dm/tools')) + +spec = importlib.util.spec_from_file_location('oot_patches', REPO / 'dm/games/0400486024DEC000/patches.py') +assert spec is not None and spec.loader is not None +profile = importlib.util.module_from_spec(spec) +spec.loader.exec_module(profile) +try: + unicorn = importlib.import_module('unicorn') + arm = importlib.import_module('unicorn.arm64_const') +except ImportError: + unicorn = arm = None + +BASE, FUNC, AREA, STATE, FRAMEWORK = 0x4000000, 0x6e6be0, 0x800000, 0x900000, 0x700000 +SITE = FUNC + 0xe4 + + +class Ctx: + state = STATE + base = FUNC + + def __init__(self, machine): + self.machine, self.cursor, self.sites, self.others, self.capabilities = machine, AREA, {}, {}, set() + + def here(self): + return self.cursor + + def emit(self, word): + self.machine.mem_write(BASE + self.cursor, struct.pack('