Skip to content

rewriter/frame-step: step by the measured frame time between vsyncs - #2259

Open
roandegraaf wants to merge 1 commit into
seedds:mainfrom
roandegraaf:oot-frame-step-measured
Open

roandegraaf wants to merge 1 commit into
seedds:mainfrom
roandegraaf:oot-frame-step-measured

Conversation

@roandegraaf

@roandegraaf roandegraaf commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Rebased on main at c081686 (2026-11-12). Main's 1e3fd03 (the averaged step up and the stub's declared largest step, smax) merged without a conflict in nvn_fstep.h and hle_nvn.c; the state page's +0x38 / +0x3c are still unused on main, and the profile's patches.py has not changed since the old base, so the stub and its recomputed offsets stand as they were. Only the docs conflicted (the game note, frame-rate.md; the symptom index regenerated). This branch and #2367 (the fit rule in nvn_fstep_next) touch neighbouring code in nvn_fstep.h and t_fstep.c but merge together cleanly, in either order.

What and why

0400486024DEC000 now plays at normal speed when the Mac runs it a little under 60 fps (55–59 fps), instead of slightly slow with a small jump every few frames.

This extends main's whole-vsync frame step to the range between whole vsyncs; it does not replace it.

  • Symptom: the game steps its time by a constant per frame. Main's frame_step stub (placed by function fingerprint, MATCH_FRAME_STEP) gives it the host's step in whole vsyncs. Below the target, the frames of a GPU-bound Mac are not whole vsyncs long (55–59.7 fps), so the game ran slightly slow and the host paid the time back one whole vsync at a time. Where frames are longer than two vsyncs at hold 1 it was not paid back at all: 0.908–0.934 of real time at 27 fps.
  • Change in the stub (dm/games/0400486024DEC000/patches.py): after the host's step × base / 60, it loads the frame time the host measured (state page +0x38, float seconds, 0 = at the target), marks +0x3c so the host measures only for a main that reads the value, and takes fmax of the two. The step is never shorter than the host's.
  • Change in the host (dm/gfx/nvn/nvn_fstep.h, fstep_tick in hle_nvn.c):
    • nvn_fdelta_next: the mean of the last 8 present intervals; a stall counts at most 0.1 s, the result stops at 4 frames, and under 1.01 frames (59.4 fps) it is 0, the game's own step.
    • nvn_fstep_measured: no whole vsync is paid back beside a measured time, the debt counts what the game stepped, and the measured time is handed out only while the game is behind.
    • DM_FRAME_DELTA=0 turns the measured time off. The block runs only for a main whose stub marks the page, and not under the deterministic clock or a step the game fixes itself.

Relation to #1543. That PR proposed the measured step as a separate stub and was closed on 2026-11-10 as superseded by main's whole-vsync step. Its review named two hazards of merging such a step into main's frame_step. Both are handled here:

  1. The fixed cbz offsets land inside the new words, and addressing the state page through x17 after a store faulted on an iPhone (x17 = 0 at x17 + 0x3c on the first frame). The two offsets are recomputed for the six added words (+13 → +19, +11 → +17, both to the original division), and the added words take the page again into x16 (ADRP) and load before they store. test_oot_real_speed.py runs the emitted stub under Unicorn on every path: no target, no step decided, no measurement, a measured time above and below the host's step.
  2. The host's debt has to take what the measured step adds, or the time is paid back twice (a model ran 9 % fast). That is nvn_fstep_measured. Before it, the measured time and the whole-vsync payback together ran the game at 1.003 of real time; t_fstep now holds game time to real time over 6,000 frames for seven frame patterns.

The idea of stepping by the frame time came from gunvalk's work in zortos293/Lichii PR #216 (GPL; only the fact was taken), as #1543 says.

Reference entry: Frames that are not whole vsyncs long under the frame step. Game note: dm/games/0400486024DEC000/README.md, Frame rate.

Checked

  • Tests, on this branch after the rebase (c081686 + this change):

    • make -C dm/host GAME=0400486024DEC000 builds.
    • make -C dm/gfx/tests GAME=0400486024DEC000 t_fstep && ./dm/gfx/tests/t_fstep: 98 checks, 0 failed.
    • pytest dm/tools/tests/test_oot_real_speed.py dm/tools/tests/test_patches.py dm/tools/tests/test_nso_match.py with Unicorn installed: 58 passed, none skipped.
    • pytest dm/tools/tests/test_docs.py dm/tools/tests/test_env_switches.py dm/tools/tests/test_game_profile.py: pass.
    • Before the rebase, patch_nso.py on the game's v131072 main: rewrites, the frame ratio cut at main+0x6e6cc4 listed with its function match (not repeated; the rewriter's frame-step path and the profile are unchanged by the rebase).
  • Games run, with title ID and version: 0400486024DEC000, v131072, chain cb2e91c138e3, DM_FPS_HOLD=off, one run a side, on a host built from 8db5dd4 with this change, not repeated in the game on ced3612:

    Scene Frame rate Game time / real time
    Open field scene, GPU-bound, headless 57.8 fps 1.000 over the last 1,800 frames, no whole vsync paid back in them, no red lines
    Same, fullscreen, DM_PRESENT_LINK=0 58.8 fps 1.000, 0–1 of 300 frames held too long
    Host throttled (taskpolicy -b after load) 27 fps 0.999 with the stub; 0.934 and 0.908 with DM_FRAME_DELTA=0
    Steady 60 fps 1.000–1.001 either way

    With the band at 1.1 frames (the first version, as in Ocarina of Time keeps real speed below 60 fps: its frame step follows the measured frame time #1543) a steady 55–59 fps measured nothing; 1.01 frames is 59.4 fps.

  • Re-measured on ced3612 with its own shader libraries (chain bc8830d4ba5a), headless, DM_FPS_HOLD=off, runs interleaved (stub, DM_FRAME_DELTA=0, stub, …), the last 1,800 frames. Honestly, this weakens the table above:

    • The open field scene that ran at 57.8 fps now holds 60.00 fps with or without the stub, so there the two are indistinguishable (1.000 of real time, no vsync paid back, both ways).
    • At 58–59 fps (a walk through a village), game time is 0.999–1.002 of real time with the stub and 0.999 without: game time alone does not separate them on that base. What differs is the vsyncs paid back, 24–30 with the stub against 133–151 without, that is, how often motion jumps by a whole vsync.
    • Throttled to 19 fps, game time is 0.993 of real time with the stub and 0.627 without.
    • In a scene at 52 fps the stub reads slightly lower than without it (0.992–0.993 against 0.995–0.996).
  • Devices and OS: Apple M4 Pro (20-core GPU), macOS 27.2.

  • scripts/regress.py check --games: not run. This is the only game built on this Mac, and the host's new block runs only for a main whose stub marks the state page (+0x3c), which only this profile's stub does.

  • Not checked:

    • An iPhone: no iOS build of this game exists here, so the x16 re-take that answers Ocarina of Time keeps real speed below 60 fps: its frame step follows the measured frame time #1543's iPhone fault is checked only under Unicorn and on the Mac.
    • The game itself on c081686 (only the build and the tests were run after the rebase).
    • By eye, only my own play on the Mac: the heavy areas feel right, and a village at 49 fps plays at normal speed. No recorded comparison.
    • Code of the game that uses 1/60 directly is not covered by the stub.

Docs

  • The finding is in its game note or its docs/reference/<subsystem>.md entry, and python3 dm/tools/docs_symptoms.py was run (the symptom index)
  • A chain tool changed: dmsh_chain.py --header was run and dm/gfx/nvn/dmsh_chain.h is part of the change
  • No game files, keys or data extracted from a game in the change or the attachments

@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) rewriter The NSO rewriter and per-build patches tools Tools and scripts (dm/tools, scripts) docs Documentation game: 0400486024DEC000 A game labels Nov 10, 2026
@roandegraaf
roandegraaf marked this pull request as ready for review November 10, 2026 23:10
@roandegraaf

Copy link
Copy Markdown
Contributor Author

Re-measured on ced3612 with its own shader libraries (chain bc8830d4ba5a).

The scene the body measured at 57.8 fps now holds 60.00 fps with or without the stub, so there the two are indistinguishable (1.000 of real time, no vsync paid back, both ways). At 58–59 fps (a walk through a town, added here) game time is 0.999–1.002 of real time with the stub and 0.999 without, so the game-time figure does not separate them on this base; what differs is the vsyncs paid back, 24–30 against 133–151. The body's throttled result holds (0.993 of real time with the stub, 0.627 without, at 19 fps). In a scene at 52 fps the stub reads slightly lower than without it (0.992–0.993 against 0.995–0.996).

Machine and settings: Apple M4 Pro (20-core GPU, 24 GB), macOS 27.2, profile 0400486024DEC000 v131072, headless, DM_FPS_HOLD=off, audio off, the branch's host with the modules rewritten by the branch's rewriter (frame step matched at 0x6e6be0; main differs from the baseline's, sdk and subsdk0 do not). Every run starts from a copy of the same warmed pipeline archive. Runs interleaved (stub, DM_FRAME_DELTA=0, stub, …). Frame rate and frame times are over the last 1,200 frames; the frame step figures are the log's last frame step: game time line (the last 1,800 frames). The log says game step from the measured frame time: on in every stub run and off (DM_FRAME_DELTA=0) in the others.

stub (default) DM_FRAME_DELTA=0
Field scene, about 2,700 draws, 95 s, 3 runs each
Frame rate 60.00 fps (3 of 3) 60.00 fps (3 of 3)
Game time of real time, last 1,800 frames 1.000 (3 of 3) 1.000 (3 of 3)
Vsyncs paid back in them 0 0
Game's GPU time 14.79–14.88 ms a frame 14.80–14.87 ms a frame
Other processes' GPU time 6.0–7.4 ms/s 6.4–7.6 ms/s
Same scene, host throttled with taskpolicy -b 60 s in, 215 s, 1 run each
Frame rate 18.7 fps 19.5 fps
Game time of real time, last 1,800 frames 0.993 (98.62 s in 99.33 s) 0.627 (60.00 s in 95.74 s)
Vsyncs paid back in them 0 1,800
Other processes' GPU time 8.0 ms/s 7.4 ms/s
Recorded walk into a town, about 2,200 draws, 70 s, 3 runs each (not in the body)
Frame rate, last 1,200 frames 58.4–60.1 fps 58.1–59.1 fps
Game time of real time, the run's one 1,800-frame line 0.999, 1.002, 0.999 0.999 (3 of 3)
Vsyncs paid back since the start of the run 24–30 133–151
Game's GPU time 15.30–15.82 ms a frame 15.66–15.88 ms a frame
Other processes' GPU time 6.5–7.4 ms/s 5.7–7.9 ms/s
Village scene, about 2,910 draws, 95 s, 2 runs each (not in the body)
Frame rate 52.2–52.7 fps 52.1–52.4 fps
Game time of real time, last 1,800 frames 0.992–0.993 0.995–0.996
Vsyncs paid back since the start of the run 17–19 170–180
Game's GPU time 17.08–17.17 ms a frame 17.07–17.14 ms a frame
Other processes' GPU time 6.3–7.8 ms/s 7.3–7.9 ms/s

The baseline host with the baseline's modules in the village scene (2 runs, interleaved with the above): 51.9–52.6 fps, 0.995–0.996 of real time, 169 and 180 vsyncs paid back, the same as the branch with DM_FRAME_DELTA=0.

Notes:

  • The throttled runs came out at 19 fps, not the 25–30 fps of the body's throttled runs (27 fps there); taskpolicy -b gives no finer control. The last 1,800 frames of those runs begin about 5 s before the throttle.
  • GPU time by process was sampled from outside the host (the accelerator's user clients in the I/O registry) over 20 s inside the measured window, since this branch does not have the log line of mac/memstat: log GPU time by process; DM_WINDOW_SCREEN picks a screen #2261.
  • No [RED] lines in the field and town runs. In the village runs a texture type mismatch … slot 7 line appears 0–3 times a run, on the branch both ways and on the baseline in other runs of this scene; it is not from this change.

Tests on the branch at ced3612: t_fstep 90 checks, 0 failed; the three rewriter test files named in the body 57 passed (with Unicorn installed; 52 passed and 5 skipped without it); test_docs.py, test_env_switches.py 7 passed.

Not checked: a settled scene at 55–59 fps (the town walk is the only route I have in that band, and it gives one frame step line a run), a real window, iOS, other games.

The 0400486024DEC000 frame step takes the measured frame time where frames are not whole vsyncs long: below the target a GPU-bound Mac runs at 55-59.7 fps, and the host's whole-vsync step left the game slightly slow and paid the time back a vsync at a time (not at all where frames are over two vsyncs at hold 1: 0.908-0.934 of real time at 27 fps).

The stub now takes the larger of the host's step × base / 60 and base × the frame time the host measures (state +0x38: the mean of the last 8 present intervals, a stall at most 0.1 s, at most 4 frames, 0 under 1.01 frames), and marks +0x3c so the host measures only for a main that reads it; nvn_fstep_measured pays back no whole vsync beside a measured time, counts what the game stepped in the debt and hands the time out only while the game is behind (both together ran the game at 1.003).

The two hazards named in PR seedds#1543's review are handled: the stub's cbz offsets are recomputed for the added words (+19 / +17) and the page is taken again into x16 with the load before the store (x17 after the store faulted on an iPhone there), and the debt takes what the measured step adds.

On an M4 Pro, on a host built from 8db5dd4: game time 1.000 of real time at 57.8 fps headless and at 58.8 fps in fullscreen with no whole vsync paid back, 0.999 at 27 fps; DM_FRAME_DELTA=0 keeps the whole-vsync step alone. Re-measured on ced3612: a scene that holds 60 fps no longer separates the stub from no stub; at 58-59 fps the stub pays back 24-30 vsyncs against 133-151 without, and throttled to 19 fps game time is 0.993 of real time against 0.627.

Stepping by frame time was found by gunvalk (zortos293/Lichii PR seedds#216, GPL; only the fact was taken).

Rebased on c081686: main's 1e3fd03 (the averaged step up, smax) merged without conflict in nvn_fstep.h and hle_nvn.c; the state page's +0x38 / +0x3c are still unused on main, and the profile's patches.py is unchanged since the old base, so the stub's offsets stand. Checked after the rebase: host build, t_fstep (98 checks, 0 failed), test_oot_real_speed and test_patches (27 passed, with unicorn), test_game_profile, test_docs, test_env_switches. Not checked: an iPhone, other games, the game itself on this main.
@roandegraaf
roandegraaf force-pushed the oot-frame-step-measured branch from e42cb7d to 9e3fda8 Compare November 12, 2026 14:02
@roandegraaf roandegraaf changed the title OoT: frame step from the measured frame time at 55–59 fps rewriter/frame-step: step by the measured frame time between vsyncs Nov 12, 2026
Kayyluhh added a commit to Kayyluhh/nxnative that referenced this pull request Nov 14, 2026
… frame time where frames are not whole vsyncs long: below the target a GPU-bound Mac runs at 55-59.7 fps, and the host's whole-vsync step left the game s
Kayyluhh added a commit to Kayyluhh/nxnative that referenced this pull request Nov 14, 2026
…come out of the frame step's time beside seedds#1942's GPU-idle flag and seedds#2259's measured step
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400486024DEC000 A game nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) rewriter The NSO rewriter and per-build patches tools Tools and scripts (dm/tools, scripts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant