vi, nx2: the Frame rate menu row: held 30 or 60, or Unlimited with the world stepping by the wall clock #1367

Closed
bibigurl-yum wants to merge 1 commit from pr/vk-frame-rate-row into main
bibigurl-yum commented 2026-11-09 23:25:38 +00:00 (Migrated from github.com)

What

The Frame rate menu row (Gameplay tab), split out from #1330 as requested: Off / 30 / 60 / Unlimited, with HOS_FPS_LOCK=<fps>|unlimited winning for test runs. It holds the present-to-present period, so the acquire that paces the game loop waits a full 1/fps (a steady 30 for an outside frame generator to build on). Option id 10.

Default path

Row Off, the game's rate slot is written exactly as upstream writes it: the smoothed loop rate clamped to 15..60. An earlier revision of this PR passed the unclamped measured rate through (a smoothed 61 on a paced-60 loop), which the engine's integer 60/fps fields floor to 0 — above 60 the written value is now used only when the unlimited step is actually armed. With the step absent (an older lift, or HOS_UNL_STEP=0) the clamp holds no matter what the row says.

Unlimited

Unlimited never waits between presents (1 ms floor). Above 60 fps the engine fast-forwards, because every one of its rate fields stores 60/fps with an integer divide (f_8348c5c0: +0xfc = 60 / w20, f_83b6f370 likewise for the vsync controller) — nothing above 60 is expressible and the per-frame step floors at 1/60 s. nx2/oot_patches.py patch_unlimited_step replaces the step divide of f_80706be0 with a host float: the world then advances by the wall time between frames (clamped to [0.2 ms, 20 ms] per frame), which is exact whatever the loop's pacing does (the sum of elapsed times is the elapsed time; a count-based rate broke on the bursty menu loops). The float is written whenever Unlimited (or a rate above 60) is chosen and the step is on — the step is on by default, so the menu row works without any environment variable; HOS_UNL_STEP=0 puts it away, and a game version whose lift the patch did not apply keeps the engine's own integer step (the host float is inert without the patch).

Tests

tests/test_unlimited_step_patch.py (auto-discovered by tests/run_all.py): the patch applies once, is idempotent, returns None when the lifted file has no such function, and leaves a function whose code differs (another game version) untouched, externs included. The present-trace fixture stubs fps_lock_wanted.

Measurements (RTX 4090, 5120x1440, TOTK)

HOS_FPS_LOCK=30 holds the loop at a measured 30.0 fps (log: wrote 30, measured 30.0). Unlimited at ~120–135 fps in real gameplay keeps the world clock at 1.00x (log: clock keeps 59.93x wall median over 68 windows, unit 1/60 s); 30/60 locks leave the engine's own step untouched, and the world clock is not read there.

Notes

  • The Frame lock comments now say Frame rate, matching the row.
  • The stats line's +0xd58 read is guarded: it samples the accumulator only after the step's probe recorded the object, and only prints the address otherwise.

Depends on nothing from #1330; both touch the menu rows list, so the second of the two to merge needs a trivial rebase.

## What The Frame rate menu row (Gameplay tab), split out from #1330 as requested: `Off / 30 / 60 / Unlimited`, with `HOS_FPS_LOCK=<fps>|unlimited` winning for test runs. It holds the present-to-present period, so the acquire that paces the game loop waits a full 1/fps (a steady 30 for an outside frame generator to build on). Option id 10. ### Default path Row Off, the game's rate slot is written exactly as upstream writes it: the smoothed loop rate clamped to **15..60**. An earlier revision of this PR passed the unclamped measured rate through (a smoothed 61 on a paced-60 loop), which the engine's integer `60/fps` fields floor to 0 — above 60 the written value is now used only when the unlimited step is actually armed. With the step absent (an older lift, or `HOS_UNL_STEP=0`) the clamp holds no matter what the row says. ### Unlimited Unlimited never waits between presents (1 ms floor). Above 60 fps the engine fast-forwards, because every one of its rate fields stores `60/fps` with an **integer** divide (`f_8348c5c0: +0xfc = 60 / w20`, `f_83b6f370` likewise for the vsync controller) — nothing above 60 is expressible and the per-frame step floors at 1/60 s. `nx2/oot_patches.py patch_unlimited_step` replaces the step divide of `f_80706be0` with a host float: the world then advances by the **wall time between frames** (clamped to [0.2 ms, 20 ms] per frame), which is exact whatever the loop's pacing does (the sum of elapsed times *is* the elapsed time; a count-based rate broke on the bursty menu loops). The float is written whenever Unlimited (or a rate above 60) is chosen and the step is on — **the step is on by default**, so the menu row works without any environment variable; `HOS_UNL_STEP=0` puts it away, and a game version whose lift the patch did not apply keeps the engine's own integer step (the host float is inert without the patch). ## Tests `tests/test_unlimited_step_patch.py` (auto-discovered by `tests/run_all.py`): the patch applies once, is idempotent, returns None when the lifted file has no such function, and leaves a function whose code differs (another game version) untouched, externs included. The present-trace fixture stubs `fps_lock_wanted`. ## Measurements (RTX 4090, 5120x1440, TOTK) `HOS_FPS_LOCK=30` holds the loop at a measured 30.0 fps (log: `wrote 30, measured 30.0`). Unlimited at ~120–135 fps in real gameplay keeps the world clock at 1.00x (log: `clock keeps 59.93x wall` median over 68 windows, unit 1/60 s); 30/60 locks leave the engine's own step untouched, and the world clock is not read there. ## Notes - The `Frame lock` comments now say `Frame rate`, matching the row. - The stats line's `+0xd58` read is guarded: it samples the accumulator only after the step's probe recorded the object, and only prints the address otherwise. Depends on nothing from #1330; both touch the menu rows list, so the second of the two to merge needs a trivial rebase.
bibigurl-yum commented 2026-11-09 23:25:57 +00:00 (Migrated from github.com)

Split out of #1330 per @hckrman101's review. The default-path clamp (write 15..60 unless the unlimited step is armed), the Unlimited row arming the wall-time step without any environment variable, the patch_unlimited_step test registered in run_all.py (auto-discovered), and the Frame rate comment wording are all in this branch.

Split out of #1330 per @hckrman101's review. The default-path clamp (write 15..60 unless the unlimited step is armed), the Unlimited row arming the wall-time step without any environment variable, the `patch_unlimited_step` test registered in `run_all.py` (auto-discovered), and the Frame rate comment wording are all in this branch.
NotRexed commented 2026-11-10 02:07:39 +00:00 (Migrated from github.com)

Superseded by the v2.0.12 branch, which adds its own Frame rate cap row (60 to 240, with the game keeping real time past 60; HOS_FPS_MAX). Two frame-rate rows would clash, so closing. Thanks for splitting it out of #1330!

Superseded by the v2.0.12 branch, which adds its own Frame rate cap row (60 to 240, with the game keeping real time past 60; `HOS_FPS_MAX`). Two frame-rate rows would clash, so closing. Thanks for splitting it out of #1330!
NotRexed commented 2026-11-10 04:48:01 +00:00 (Migrated from github.com)

Correction to the closing note: the Frame rate cap row that replaces this is now on main, in #1485 (60 to 240, the game keeping real time past 60; HOS_FPS_MAX), not only in a test branch. Two frame-rate rows would clash, so this stays closed. If held 30 or Unlimited is still wanted on top of the cap row, a follow-up PR against main is welcome. Thanks again for splitting it out of #1330.

Correction to the closing note: the Frame rate cap row that replaces this is now on **main**, in #1485 (60 to 240, the game keeping real time past 60; `HOS_FPS_MAX`), not only in a test branch. Two frame-rate rows would clash, so this stays closed. If held 30 or Unlimited is still wanted on top of the cap row, a follow-up PR against main is welcome. Thanks again for splitting it out of #1330.
Commenting is not possible because the repository is archived.
No description provided.