fix(vi): keep the game at real speed under a Frame rate cap above 60 #1612

Merged
klimovich008 merged 1 commit from fix/frame-rate-cap-game-speed into main 2026-11-10 12:59:54 +00:00
klimovich008 commented 2026-11-10 11:06:10 +00:00 (Migrated from github.com)

With the Frame rate cap above 60 (the Insert menu's row from #1485, or HOS_FPS_MAX), the game runs faster than real time: 4 times too fast in the menus at 240 fps, and 1.2 to 1.4 times in Kokiri Forest at 74 to 85 fps. This makes the game keep real time at any rate up to the cap.

Why it happens

The cap raises the pacing and writes the measured rate, up to the cap, into the engine's nominal rate (+0x2c8), from which the engine makes its time step (60 / rate). But the engine's own rate is 60 divided by a whole number of display intervals, never above 60, and its frame-rate controller puts 60 back over a higher value before the step is made from it. So above 60 fps every frame still steps 1/60 s.

What changes

  • nx2/oot_patches.py patch_frame_seconds: at the two places where the engine turns the nominal rate into a time step (the frame's step in f_80706be0, the global step in f_83b6ea90), the lifted code takes base * <the frame's seconds> when the host gives it a frame time, and the game's own divide when it does not. The hook goes into both places or neither; a lift where they are not both found is left as it is, with a warning.
  • host/nn_vi.c realtime_step: with a cap above 60 it publishes the frames' own time (the same 5-frame average the real-time step already uses, at most 1/15 s as before, at least 1/240 s) and keeps the rate under it within the engine's own 15 to 60.
  • At a cap of 60, the default, the host publishes nothing and the game's own divide runs: nothing changes but one load and a branch at each of the two divides, and one store a frame.
  • HOS_FPS_STEP=0 keeps the rate-up-to-the-cap behaviour, for comparisons. A build whose lift does not have the hook keeps it too.
  • HOS_FPS_LOG=1 also logs the game clock against the wall clock every 300 presents (vi: game clock ...: ... (speed 1.000) at 85.1 fps), which is how the numbers below were read.

Tested

Windows 11, Ryzen 7 5700X3D, Radeon RX 9070 XT (Vulkan 1.4, driver 0x8001a0), this branch on main 779bdcf. Kokiri Forest from a save, 5120x1440 (32:9), a scripted run measured over the 20 s after the save load. "Speed" is the game clock's advance against the wall clock, 1.000 being real time.

runs fps in play speed in play speed in the menus worst frame frames over 100 ms
cap 60 (default) 3 60.0 / 60.0 / 60.0 0.999-1.008, median 1.006, at 60 fps (60 fps) 26 / 31 / 24 ms 0 / 0 / 0
HOS_FPS_MAX=240, this PR 5 85.0 / 86.8 / 79.1 / 84.0 / 84.2 0.998-1.003, median 1.000, at 70-90 fps 1.005-1.034, median 1.010, at 237-240 fps 33 / 26 / 94 / 23 / 26 ms 0 in all five
HOS_FPS_MAX=240 HOS_FPS_STEP=0 (as main is) 1 83.2 1.23-1.42, median 1.37, at 73-85 fps 3.95-4.01, median 4.00, at 237-240 fps 22 ms 0
  • The speeds are per 300 presents. A span that holds part of the save load or a pause reads lower in every row (0.72 to 0.97) and is left out of the ranges.
  • A second run of the last row was rejected by my harness: with the menus running 4 times too fast its scripted save load did not come where the harness looks for it. Its log shows the same speeds (4.00 in the menus, 1.26 to 1.40 in play).
  • No frame over 250 ms in any run. p99 frame time: 19.9 / 21.7 / 20.1 ms at the 60 cap, 17.8 / 16.9 / 22.7 ms in the first three runs with this PR.
  • tests/test_frame_rate_cap_step.py (registered in tests/run_all.py): the patch on a synthetic function of the lifted shape (both places, a second run, another game version, one place missing, a rerun after one file changed), and realtime_step cut from host/nn_vi.c and run with the cap at 120 and 60, with HOS_FPS_STEP=0 and without the hook. It fails on main's nn_vi.c and on main's oot_patches.py, each on its own.
  • The build's log confirms both places are found in the supported game version (the frame's time at 1 step divide(s) in two files).

Not covered

  • Only Kokiri Forest was measured on this branch, on one PC. Linux was not run (the tests only).
  • The speed above is the engine's main clock. That what is on screen keeps real time too (walking, the camera, the loading screen) I measured only on an earlier form of this hook on v2.0.11, by comparing frame logs of the same scripted run at 60 fps and unlocked; I did not repeat that on main.
  • Anything in the game that steps once a frame whatever the time step says (cloth, hair, some particles would be candidates) is not looked at. It ran fps/60 too fast before as well, with the rest of the game.
  • At the 240 cap the clock reads about 1 % fast in the menus (median 1.010, up to 1.034 in the first ones); at the 60 cap main reads a median of 1.006.

#1485 added the cap. #1367 (closed in its favour) and #340 (closed) stepped the game by wall time at the first of the two places; this one uses the real-time step's own average at both.

🤖 Generated with Claude Code

With the Frame rate cap above 60 (the Insert menu's row from #1485, or `HOS_FPS_MAX`), the game runs faster than real time: 4 times too fast in the menus at 240 fps, and 1.2 to 1.4 times in Kokiri Forest at 74 to 85 fps. This makes the game keep real time at any rate up to the cap. ## Why it happens The cap raises the pacing and writes the measured rate, up to the cap, into the engine's nominal rate (`+0x2c8`), from which the engine makes its time step (`60 / rate`). But the engine's own rate is 60 divided by a whole number of display intervals, never above 60, and its frame-rate controller puts 60 back over a higher value before the step is made from it. So above 60 fps every frame still steps 1/60 s. ## What changes - `nx2/oot_patches.py` `patch_frame_seconds`: at the two places where the engine turns the nominal rate into a time step (the frame's step in `f_80706be0`, the global step in `f_83b6ea90`), the lifted code takes `base * <the frame's seconds>` when the host gives it a frame time, and the game's own divide when it does not. The hook goes into both places or neither; a lift where they are not both found is left as it is, with a warning. - `host/nn_vi.c` `realtime_step`: with a cap above 60 it publishes the frames' own time (the same 5-frame average the real-time step already uses, at most 1/15 s as before, at least 1/240 s) and keeps the rate under it within the engine's own 15 to 60. - At a cap of 60, the default, the host publishes nothing and the game's own divide runs: nothing changes but one load and a branch at each of the two divides, and one store a frame. - `HOS_FPS_STEP=0` keeps the rate-up-to-the-cap behaviour, for comparisons. A build whose lift does not have the hook keeps it too. - `HOS_FPS_LOG=1` also logs the game clock against the wall clock every 300 presents (`vi: game clock ...: ... (speed 1.000) at 85.1 fps`), which is how the numbers below were read. ## Tested Windows 11, Ryzen 7 5700X3D, Radeon RX 9070 XT (Vulkan 1.4, driver 0x8001a0), this branch on main 779bdcf. Kokiri Forest from a save, 5120x1440 (32:9), a scripted run measured over the 20 s after the save load. "Speed" is the game clock's advance against the wall clock, 1.000 being real time. | | runs | fps in play | speed in play | speed in the menus | worst frame | frames over 100 ms | |---|---|---|---|---|---|---| | cap 60 (default) | 3 | 60.0 / 60.0 / 60.0 | 0.999-1.008, median 1.006, at 60 fps | (60 fps) | 26 / 31 / 24 ms | 0 / 0 / 0 | | `HOS_FPS_MAX=240`, this PR | 5 | 85.0 / 86.8 / 79.1 / 84.0 / 84.2 | 0.998-1.003, median 1.000, at 70-90 fps | 1.005-1.034, median 1.010, at 237-240 fps | 33 / 26 / 94 / 23 / 26 ms | 0 in all five | | `HOS_FPS_MAX=240 HOS_FPS_STEP=0` (as main is) | 1 | 83.2 | 1.23-1.42, median 1.37, at 73-85 fps | 3.95-4.01, median 4.00, at 237-240 fps | 22 ms | 0 | - The speeds are per 300 presents. A span that holds part of the save load or a pause reads lower in every row (0.72 to 0.97) and is left out of the ranges. - A second run of the last row was rejected by my harness: with the menus running 4 times too fast its scripted save load did not come where the harness looks for it. Its log shows the same speeds (4.00 in the menus, 1.26 to 1.40 in play). - No frame over 250 ms in any run. p99 frame time: 19.9 / 21.7 / 20.1 ms at the 60 cap, 17.8 / 16.9 / 22.7 ms in the first three runs with this PR. - `tests/test_frame_rate_cap_step.py` (registered in `tests/run_all.py`): the patch on a synthetic function of the lifted shape (both places, a second run, another game version, one place missing, a rerun after one file changed), and `realtime_step` cut from `host/nn_vi.c` and run with the cap at 120 and 60, with `HOS_FPS_STEP=0` and without the hook. It fails on main's `nn_vi.c` and on main's `oot_patches.py`, each on its own. - The build's log confirms both places are found in the supported game version (`the frame's time at 1 step divide(s)` in two files). ## Not covered - Only Kokiri Forest was measured on this branch, on one PC. Linux was not run (the tests only). - The speed above is the engine's main clock. That what is on screen keeps real time too (walking, the camera, the loading screen) I measured only on an earlier form of this hook on v2.0.11, by comparing frame logs of the same scripted run at 60 fps and unlocked; I did not repeat that on main. - Anything in the game that steps once a frame whatever the time step says (cloth, hair, some particles would be candidates) is not looked at. It ran fps/60 too fast before as well, with the rest of the game. - At the 240 cap the clock reads about 1 % fast in the menus (median 1.010, up to 1.034 in the first ones); at the 60 cap main reads a median of 1.006. ## Related #1485 added the cap. #1367 (closed in its favour) and #340 (closed) stepped the game by wall time at the first of the two places; this one uses the real-time step's own average at both. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Commenting is not possible because the repository is archived.
No description provided.