Skip to content

Ocarina of Time keeps real speed below 60 fps: its frame step follows the measured frame time - #1543

Closed
LeonardoBai12 wants to merge 2 commits into
seedds:mainfrom
LeonardoBai12:oot-real-speed
Closed

LeonardoBai12 wants to merge 2 commits into
seedds:mainfrom
LeonardoBai12:oot-real-speed

Conversation

@LeonardoBai12

@LeonardoBai12 LeonardoBai12 commented Nov 6, 2026 •

Copy link
Copy Markdown

Ocarina of Time played in slow motion whenever it ran below 60 fps. Its frame loop (main+0x6e6be0) computes each frame's step as a constant over its target rate and never reads the clock, so one frame is one step however long it took.

gunvalk found this in Lichii PR #216. Lichii is GPL-3, so this was written here from those facts, not copied.

The change

  • A stub at main+0x6e6cc4 keeps the game's own step and takes the measured frame time when that is longer, so the step never falls below the original.
  • The host writes the frame time at each present, as the mean of the last 8 intervals, with a single stall capped at 0.1 s and the step capped at 4/60 s.
  • The host measures only for a main whose stub marks the state page, so other games are not touched.
  • DM_FRAME_DELTA=0 turns it off.

If #1497 merges first, the two stubs share FRAME_STEP for this build and patches.py needs a small merge: one function that calls both.

Tested

Not tested

  • scripts/regress.py check was not run: Ocarina of Time is the only game on this Mac.
  • The 30 fps mode of the game's fcsel was checked only in the Unicorn test.

🤖 Generated with Claude Code

@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 6, 2026
@PucaVaz

PucaVaz commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Tested this change on an M4 MacBook Air (24 GB, macOS 27.2), cherry-picked alone onto main 75c1a8c (no conflicts), in scripted runs from the Kokiri Forest autosave at Link's house: game time against wall time was 0.998–1.000 with the change on, against 0.50 at 30 fps and 0.67–0.70 at 40–42 fps with it off; the same timed walk to the door took 1.5 s at 30 fps and uncapped with the change, 3.0 s without it at 30 fps. No GPU errors, footprint 10.2–10.8 GB.

With the change, DM_FPS_TARGET=30 applies (main's fps_present_cap logs not applied without it), but the launcher doesn't offer a frame-rate choice for this game because its profile's fps_target is 0, so a player can't pick 30 fps from the launcher. In fullscreen on a fixed 60 Hz screen, a 30 fps cap also ran into a display-link problem (#1554, fix in #1555). Full numbers in #1265.

@LeonardoBai12

Copy link
Copy Markdown
Author

Thanks @PucaVaz, this is very helpful, especially testing it alone on main. I added your result to the PR description. I am leaving the launcher frame-rate choice out of this PR to keep it small, since it needs a separate decision about the profile's fps_target. I can look at it in a follow-up if that is useful.

@PucaVaz

PucaVaz commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Compared #1543 and #1584 in game on an M4 MacBook Air (24 GB, macOS 27.2, built-in 60 Hz screen).

Setup. Two Mac apps from the same tree: main b2d46b8 + #1544 + #1555, one with #1543 (f301c698; the later commit only changes the test) and one with #1584 (bf5dc97).

  • Same shader chain, 5c852d7989ac.
  • The shader libraries (all 33,925 metallibs) and the pipeline archive are byte-identical between the two apps, so only the frame step differs.
  • Handheld, DM_UPSCALER=game, windowed (2560x1440).

Route. Scripted runs from the Kokiri Forest autosave in Link's house: 60 s in the house, a timed walk to the door, out of the door, Saria's greeting, 8 screenshots on the balcony, then 180 s of scripted stick pushes outside (the walk). The runs alternated between the two apps.

Game speed: both run at real time.

Frame pacing (house = 60 s idle, walk = 180 s outside):

runs house fps walk fps walk frame p50 / p95 / p99 walk frames > 60 ms walk on-screen 1 / 2 / 3 vsyncs walk on-screen sd present → display p50
#1543 uncapped (its default, fps_target 0) 2 51.6–52.1 44.4–45.0 20.9 / 30.1–30.2 / 31.8–31.9 ms 3–5 68% / 29% / 2% 9.3–10.7 ms 58 ms
#1584 at its default (fps_target 60) 3 31.8–32.2 27.3–29.5 33.2–33.5 / 50.6–51.5 / 52.5–53.3 ms 5–8 13% / 61% / 26% 9.6–11.6 ms 76–79 ms
#1543, DM_FPS_TARGET=30 2 30.0 29.9–30.0 33.2 / 34.5–34.6 / 35.6–36.1 ms 3–5 0 / 100% / 0 2.8–3.3 ms 110 ms
#1584, DM_FPS_TARGET=30 1 30.0 29.9 33.2 / 34.5 / 35.2 ms 4 0 / 100% / 0 3.5 ms 110 ms

"Present → display" is the host's game enqueue to target display p50. A 3-vsync hold is 50 ms, so the table counts frames over 60 ms.

At a 30 cap the two behave the same on this Mac: every frame is held 2 vsyncs, and the game runs at real time.

At #1584's default 60 target, this Mac does not hold 60 (21–24 ms of GPU per frame at hold 1).

  • The host probes hold 1 six to eight times a minute, and every probe after the title failed (41–43 per run).
  • With kmin 1 the hold can also go to 3 (kmax = FSTEP_MAX), and it did in the heavier views: 17–34% of walk frames, 73–89% of frames at the door and in Saria's greeting (20–24 fps), and the balcony at 20 fps, against 32 fps for Ocarina of Time keeps real speed below 60 fps: its frame step follows the measured frame time #1543 uncapped.
  • A one-frame pass dump in the house and on the balcony shows the scene at 854x480 with either PR, so the resolution rise described for the M1 Ultra did not happen here.

At the door, #1543 shows the game's one-frame stall (119–146 ms in 3 of 4 runs), as does #1584 at a 30 cap (128 ms). #1584 at its default has no frame over 74 ms there, since it is already at hold 3.

Launcher. #1584's fps_target 60 gives the launcher a Frame rate row (30 / 60). With #1543 the profile's fps_target stays 0, and there is no such row.

No GPU errors, and the same [RED] lines in all runs (the partial-library notes and one texture type mismatch). Footprint peak 10.1–10.8 GB.

Not covered: docked mode, MetalFX, areas past the start of Kokiri Forest, a Mac that holds 60, and code in the game that might use 1/60 directly (#1543's note).

thomasruegsegger pushed a commit to thomasruegsegger/nxnative that referenced this pull request Nov 7, 2026
…tep (upstream PRs seedds#1497 and seedds#1543)

Both stubs are FRAME_STEP of main e0f3ccfaf50065ae4ea01eb5aeb13b1a, so the build's function runs both: real_speed at main+0x6e6cc4 (frame step from the measured frame time) and render_scale at main+0x31d3f48 (DM_RENDER_SCALE caps the game's dynamic-resolution scale). test_oot_real_speed checks the build takes both sites and marks the render-scale capability; game-patches.md regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aax1HnchwCweUDRNWjhHx9
thomasruegsegger pushed a commit to thomasruegsegger/nxnative that referenced this pull request Nov 7, 2026
…s first write stays resident while any of them holds it: releasing one took it out of the residency set (GPU page faults, issue seedds#1326)

The serials of one storage share one packaged texture (g_pkg_share). At the GPU's first write over that storage, cpu_unc_drop gives each of them the package's texture itself (tex_put), so they all hold one texture that was in no shared set. The first holder released (tex_put's ghost-ring eviction, serial_put or serial_del, through obj_drop or defer_obj_release), or the package's own release (NvPkg dealloc's res_remove), took it out of the residency set while the other serials still drew with it, and a command buffer reading it faulted.

pkg_hand_over enters the handed-over texture in g_alias_set and in g_handed, a list by the package's sharing key, so releasing one holder only drops a reference (alias_has). The package's release and its re-upload's spare leave a handed-over texture resident, mtl_alias_drop_pool retires it with its pool, and tex_upgrade moves its entry. It does not enter g_alias, so a new texture object over the same storage still gets a texture of its own. DM_UNC_SHARE=0 gives back the old hand-over.

Ocarina of Time v131072, Mac mini M4 16 GB, macOS 27.2, on main b2d46b8 with seedds#1590, seedds#1557, seedds#1556, seedds#1575, seedds#1522, seedds#1543 and seedds#1390: page faults at 44, 29, 24 and 15.5 minutes, with the missing textures named by DM_RES_CHECK and a diagnostic of where each left the residency set, before two of them, as 260x260 terrain tiles handed over this way and released by the ghost-ring eviction. One binary: with the change, 107 minutes and no fault (86 hand-overs); with DM_UNC_SHARE=0, a fault at 37 minutes with the same kind of tiles named. t_cpu_tex --metal handover_shared: 97 checks, 0 failed; with DM_UNC_SHARE=0, 2 failed. The other GPU suites are unchanged.

(cherry picked from commit 8cbc330)
LeonardoBai12 and others added 2 commits November 7, 2026 11:17
… the measured frame time

The frame loop computes each frame's step as a constant over its target rate and never reads the clock, so everything ran in slow motion below the target. Found by gunvalk in zortos293/Lichii PR seedds#216; written here from those facts. A stub keeps the game's own step and takes the measured frame time when that is longer, and the host measures only for a main that marks the state page. DM_FRAME_DELTA=0 turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k asks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LeonardoBai12

Copy link
Copy Markdown
Author

Thank you @PucaVaz for the careful side-by-side, especially keeping the shaders and pipeline archive byte-identical so only the frame step differed.

My reading of it:

The two could also work together: #1543 for the uncapped default and #1584's fps_target row for players who want a locked 30. I am happy to rebase on #1584 or adjust if maintainers prefer one approach.

I rebased this branch on current main (12ddb1f), so the CI failures from the old base (t_gfx_suspend link and test_dmsh_pool_arrays, both fixed on main) should clear.

thomasruegsegger pushed a commit to thomasruegsegger/nxnative that referenced this pull request Nov 7, 2026
…tep (upstream PRs seedds#1497 and seedds#1543)

Both stubs are FRAME_STEP of main e0f3ccfaf50065ae4ea01eb5aeb13b1a, so the build's function runs both: real_speed at main+0x6e6cc4 (frame step from the measured frame time) and render_scale at main+0x31d3f48 (DM_RENDER_SCALE caps the game's dynamic-resolution scale). test_oot_real_speed checks the build takes both sites and marks the render-scale capability; game-patches.md regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aax1HnchwCweUDRNWjhHx9
(cherry picked from commit 1e0258b)
thomasruegsegger pushed a commit to thomasruegsegger/nxnative that referenced this pull request Nov 8, 2026
…mmits on top of main, without the real-speed and render-scale frame steps (seedds#1543, seedds#1497), which main's own frame step (7d8dcdf) and dynamic-resolution switch (04419bc) replace (this merge keeps the earlier branch history reachable, so the push needs no force)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aax1HnchwCweUDRNWjhHx9
dsi-hub pushed a commit to dsi-hub/nxnative that referenced this pull request Nov 8, 2026
…n: with the measured-time words inserted, main's fixed cbz offsets (+13, +11) landed inside them, and an iPhone 18 Pro Max faulted at x17 + 0x3c (x17 = 0) on the first frame, before the host had decided a step; the block addresses the page through x16 (PR seedds#1543's sequence). test_oot_frame_step runs the stub under Unicorn: step not decided, target 0, a slow frame, never below the host's step, and x17 lost after a store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brad-n added a commit to brad-n/TVPort that referenced this pull request Nov 9, 2026
dsi-hub pushed a commit to dsi-hub/nxnative that referenced this pull request Nov 9, 2026
…ub: the ratio is never below the measured frame time (PR seedds#1543's state +0x38 / +0x3c, host measurement and t_fstep checks), so it holds with DM_FPS_HOLD off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dsi-hub pushed a commit to dsi-hub/nxnative that referenced this pull request Nov 9, 2026
…n: with the measured-time words inserted, main's fixed cbz offsets (+13, +11) landed inside them, and an iPhone 18 Pro Max faulted at x17 + 0x3c (x17 = 0) on the first frame, before the host had decided a step; the block addresses the page through x16 (PR seedds#1543's sequence). test_oot_frame_step runs the stub under Unicorn: step not decided, target 0, a slow frame, never below the host's step, and x17 lost after a store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@oliverames

Copy link
Copy Markdown
Contributor

Thanks for this. One finding from the HookLink fork that touches the stub, and a check you may want to add.

In HookLink's tree, #1543's measured-time words were inserted into the frame-step stub that is on main now (frame_step in dm/games/0400486024DEC000/patches.py), which has two fixed branches, cbz w17,orig (+13) and (+11). With the new words in, those offsets landed inside them. An iPhone 18 Pro Max then faulted at x17 + 0x3c with x17 = 0 on the first frame, before the host had decided a step: the block addressed the state page through x17, which the rewriter uses as scratch, so it does not survive a store. HookLink's fix computes the branches to the division and takes the page again into x16, as your sequence does: dsi-hub@b02c7d64 (building on dsi-hub@8b9fc9a8). They also added dm/tools/tests/test_oot_frame_step.py, which runs the stub under Unicorn.

I read your head (4d4f967) and it does not have this problem. Your real_speed at 0x6e6cc4 has no cbz, so there is no fixed offset to land inside anything, and it addresses the page through x16 after its own ADRP. The hazard is only for a merge with main's existing stub, which your description already mentions for #1497 ("one function that calls both"). If that merge happens, the branches need to be computed, not carried over.

One suggestion, not a blocker. HookLink's test has a clobber_x17 run that zeroes x17 after every store, as the rewriter can, and one for "step not decided yet". test_oot_real_speed.py could take the same run, so a later edit that goes back to addressing the page through x17 fails in CI and not on a phone. I have not run your test with it; the phone fault above is HookLink's, and I did not reproduce it.

@oliverames

Copy link
Copy Markdown
Contributor

If the floor ever meets main's host step, as it does in HookLink's tree, the host's debt has to take what the floor adds to the step. Left as it is, the step pays the same time back again. With the hold off and frames of 1.8 vsyncs the steps go 1, 2, 2, 2, 2 under a floor of 1.8, and a model of the controller runs 9% fast (17% at 1.5 vsyncs). Your stub doesn't have this, because it takes the larger of the game's own step and the frame time and no host step is involved. The fix I'm running is nvn_fstep_moved in oliverames@fd62582d, with t_fstep cases.

@LeonardoBai12

Copy link
Copy Markdown
Author

Thanks @oliverames for reading the stub so closely, and for checking that this head doesn't address the page through x17. The two hazards you describe are worth knowing for anyone who merges a step like this into main's frame_step:

  • the fixed cbz offsets land inside the new words;
  • the host's debt has to take what a floor adds, or the step pays that time back twice.

main has had its own real-speed step since 7d8dcdf, in whole vsyncs with the launcher's Frame rate row. This branch now conflicts with it. I've played on main's step for the last few days on an M1 Pro, and the game keeps real time there whenever it holds 30 fps. So I'm closing this PR as superseded by main.

The idea came from gunvalk's work in zortos293/Lichii PR #216. Thanks again @PucaVaz for the side-by-side with #1584.

Aznracer pushed a commit to Aznracer/Mighty-Green-Kokiri-Kid that referenced this pull request Nov 11, 2026
…es 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. Stepping by frame time was found by gunvalk (zortos293/Lichii PR seedds#216, GPL; only the fact was taken). Not checked: an iPhone, other games, the game itself on this main

(cherry picked from commit e42cb7d)
roandegraaf added a commit to roandegraaf/nxnative that referenced this pull request Nov 12, 2026
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.
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.

3 participants