fix(fps): keep sound and moving platforms working above a 60 fps cap #2470

Merged
hckrman101 merged 4 commits from fix/high-fps-bugs into main 2026-11-11 22:50:42 +00:00
hckrman101 commented 2026-11-11 20:29:49 +00:00 (Migrated from github.com)

Above a 60 fps cap, sounds and then the music can stop for good after a few loading zones, and moving platforms (the stone one under the spikes in the Deku Tree, #1798) slide out from under Link. Both come from the game getting a frame step shorter than one 60 fps frame.

What changed

Sound (the sound manager at 60 Hz). The game's sound manager job (f_80435300) hands the frame step to the sound engine every frame. Its fade and delay timers and parameter ramps expect at least one whole 60 fps step. When they get less (0.5 at 120 fps), some sounds are not released when an area unloads, and the voices in use climb until the game's voice budget is full. New sounds (sword, voices), then the area music, stop until a restart. Above 60 fps the job now runs at 60 Hz: each frame adds its step to a remainder, and the job runs once a whole step has built up, with a step of 1. Its other phases follow that frame's choice. Sound still follows real time on average. A step of 1 or more, or of 0 or less, runs as before.

This is a port of zortos293/Lichii PR #1289 by auz466 (GPL-3.0), which found the cause and does the same thing as a code patch for the emulator. auz466 is credited with a Co-authored-by trailer. Here it is a lifted-code hook (nx2/oot_patches.py, patch_sound_job) plus host code (host/oot_high_fps.h, host/oot_high_fps.c).

Moving platforms (physics solver steps). Each update the game sets the physics solver's step count to (int)(frame step * steps of a 60 fps frame + 0.001) (f_838ceb80 and f_838cfc10). The places logged use 8 steps per 60 fps frame. With a step under 1 the count is cut short, and because the measured step wanders around its mean, the count changes from frame to frame: 8 x 0.49 gives 3 and 8 x 0.5 gives 4 at 120 fps. It reaches 0 at about 480 fps (with 4 steps per frame, above 240). While the step is under 1 the count is now rounded up instead, with 10% leeway for frame-time jitter. It is at least 1 and never fewer than the game's own, so a solver step never covers more time than at 60 fps. Frames slower than 60 fps keep the game's count. For #1798 (closed into #1742).

At the default cap of 60 the host returns exactly what the game computed in both places, so nothing changes there. Uncapped stays uncapped.

Left out: the quick-menu crash at very high frame rates. It was reported at 640-700 fps in this game build: a null read after about two seconds in the quick menu. The faulting function is in the physics engine's collision code, near a table of Havok routines. That fits the solver count above, which would be 0 at those rates without this PR and is at least 1 with it, but I could not confirm it here. This PC reached about 300 fps at most (about 210 in the pause menu), so the crash never reproduced on unchanged main. Forcing the solver count to 0 in a local build for a minute of play and menus did not crash either. So this PR makes no claim about that crash and does not cap Uncapped.

Tests

  • tests/test_high_fps_fixes.py (new, game-free) covers:
    • The sound gate: 120 frames of 0.5 run the job 60 times, and 144 fps runs it 59-60 times. Phase 1 follows phase 0. Whole steps and a cap of 60 run every frame, and 0, negative and NaN steps run as the game has them.
    • The step handed to the sound engine.
    • The solver count against the game's at 60/90/120/144/240+ fps, with 4 and 8 steps per frame, and for slow frames.
    • The lift patches: each is placed once and declared, running them again changes nothing, and other code is left alone.
  • python -I -B tests/run_all.py: 358 passed, 4 failed, 13 skipped on Linux. The 4 failures (test_wayland_libdecor_hint, test_nup_read_name, test_bake_portability, test_builder_log_read_only) fail the same way on unchanged main on this PC, because local tools such as zstandard are missing. After merging the latest main, every check that reads nx2/oot_patches.py or nx2/chunks.py passes (18 of them).

Verified

On an RTX 3090 (Linux), in headless game runs from a real save, with HOS_FPS_LOG=1:

  • The lift applies both hooks once each, with no warnings: oot_patches: the sound job at 60 Hz above 60 fps, the solver steps rounded up above 60 fps. After the merge with the latest main, which moved the lift edits into patch_file/report, a dry run of patch_file on the lifted code still finds the sound job once and both solver places.
  • At cap 120, with 12 map loads by teleport and sword swings after each:
    • The sound job is held to 60 Hz while the step is under 1 (sound job ran 311 of the last 600 frames (step 0.498, held to 60 Hz)), and runs every frame when frames are slower than 60 fps.
    • Audio kept playing to the end of the run: the output level, checked every 10 s, never dropped to silence. The number of voices playing stayed between about 4 and 39 and came back down after each load.
  • The solver hook sees the expected values: physics solver steps 7 (the game's 6: frame step 0.778 x 8 steps a 60 fps frame). That log comes from a build that also rounded up slow frames; the PR now leaves those at the game's count.

Not tested

  • The sound loss did not reproduce on unchanged main here. This PC was shared with other jobs and the game ran at 40-110 fps in those areas, so the step was under 1 only part of the time. Twelve loads at a cap of 120 kept sound working on both builds. The gate's own behaviour is checked by the log and the unit test.
  • Link on the moving platform in the Deku Tree at caps of 120 and 240 was not run. The change is covered by the unit test and by the in-game log of the solver hook above.
  • No game run after the last merge with main; only the lift dry run above.
  • No side-by-side run at a cap of 60, where the code returns the game's own values (covered by the unit test).
  • Steady 240 fps or more in game areas: this PC did not reach it.
  • Windows; AMD and Intel GPUs.
Above a 60 fps cap, sounds and then the music can stop for good after a few loading zones, and moving platforms (the stone one under the spikes in the Deku Tree, #1798) slide out from under Link. Both come from the game getting a frame step shorter than one 60 fps frame. ## What changed **Sound (the sound manager at 60 Hz).** The game's sound manager job (f_80435300) hands the frame step to the sound engine every frame. Its fade and delay timers and parameter ramps expect at least one whole 60 fps step. When they get less (0.5 at 120 fps), some sounds are not released when an area unloads, and the voices in use climb until the game's voice budget is full. New sounds (sword, voices), then the area music, stop until a restart. Above 60 fps the job now runs at 60 Hz: each frame adds its step to a remainder, and the job runs once a whole step has built up, with a step of 1. Its other phases follow that frame's choice. Sound still follows real time on average. A step of 1 or more, or of 0 or less, runs as before. This is a port of [zortos293/Lichii PR #1289](https://github.com/zortos293/Lichii/pull/1289) by auz466 (GPL-3.0), which found the cause and does the same thing as a code patch for the emulator. auz466 is credited with a `Co-authored-by` trailer. Here it is a lifted-code hook (`nx2/oot_patches.py`, `patch_sound_job`) plus host code (`host/oot_high_fps.h`, `host/oot_high_fps.c`). **Moving platforms (physics solver steps).** Each update the game sets the physics solver's step count to `(int)(frame step * steps of a 60 fps frame + 0.001)` (f_838ceb80 and f_838cfc10). The places logged use 8 steps per 60 fps frame. With a step under 1 the count is cut short, and because the measured step wanders around its mean, the count changes from frame to frame: 8 x 0.49 gives 3 and 8 x 0.5 gives 4 at 120 fps. It reaches 0 at about 480 fps (with 4 steps per frame, above 240). While the step is under 1 the count is now rounded up instead, with 10% leeway for frame-time jitter. It is at least 1 and never fewer than the game's own, so a solver step never covers more time than at 60 fps. Frames slower than 60 fps keep the game's count. For #1798 (closed into #1742). At the default cap of 60 the host returns exactly what the game computed in both places, so nothing changes there. Uncapped stays uncapped. **Left out: the quick-menu crash at very high frame rates.** It was reported at 640-700 fps in this game build: a null read after about two seconds in the quick menu. The faulting function is in the physics engine's collision code, near a table of Havok routines. That fits the solver count above, which would be 0 at those rates without this PR and is at least 1 with it, but I could not confirm it here. This PC reached about 300 fps at most (about 210 in the pause menu), so the crash never reproduced on unchanged `main`. Forcing the solver count to 0 in a local build for a minute of play and menus did not crash either. So this PR makes no claim about that crash and does not cap Uncapped. ## Tests - `tests/test_high_fps_fixes.py` (new, game-free) covers: - The sound gate: 120 frames of 0.5 run the job 60 times, and 144 fps runs it 59-60 times. Phase 1 follows phase 0. Whole steps and a cap of 60 run every frame, and 0, negative and NaN steps run as the game has them. - The step handed to the sound engine. - The solver count against the game's at 60/90/120/144/240+ fps, with 4 and 8 steps per frame, and for slow frames. - The lift patches: each is placed once and declared, running them again changes nothing, and other code is left alone. - `python -I -B tests/run_all.py`: 358 passed, 4 failed, 13 skipped on Linux. The 4 failures (`test_wayland_libdecor_hint`, `test_nup_read_name`, `test_bake_portability`, `test_builder_log_read_only`) fail the same way on unchanged `main` on this PC, because local tools such as `zstandard` are missing. After merging the latest `main`, every check that reads `nx2/oot_patches.py` or `nx2/chunks.py` passes (18 of them). ## Verified On an RTX 3090 (Linux), in headless game runs from a real save, with `HOS_FPS_LOG=1`: - The lift applies both hooks once each, with no warnings: `oot_patches: the sound job at 60 Hz above 60 fps`, `the solver steps rounded up above 60 fps`. After the merge with the latest `main`, which moved the lift edits into `patch_file`/`report`, a dry run of `patch_file` on the lifted code still finds the sound job once and both solver places. - At cap 120, with 12 map loads by teleport and sword swings after each: - The sound job is held to 60 Hz while the step is under 1 (`sound job ran 311 of the last 600 frames (step 0.498, held to 60 Hz)`), and runs every frame when frames are slower than 60 fps. - Audio kept playing to the end of the run: the output level, checked every 10 s, never dropped to silence. The number of voices playing stayed between about 4 and 39 and came back down after each load. - The solver hook sees the expected values: `physics solver steps 7 (the game's 6: frame step 0.778 x 8 steps a 60 fps frame)`. That log comes from a build that also rounded up slow frames; the PR now leaves those at the game's count. ## Not tested - The sound loss did not reproduce on unchanged `main` here. This PC was shared with other jobs and the game ran at 40-110 fps in those areas, so the step was under 1 only part of the time. Twelve loads at a cap of 120 kept sound working on both builds. The gate's own behaviour is checked by the log and the unit test. - Link on the moving platform in the Deku Tree at caps of 120 and 240 was not run. The change is covered by the unit test and by the in-game log of the solver hook above. - No game run after the last merge with `main`; only the lift dry run above. - No side-by-side run at a cap of 60, where the code returns the game's own values (covered by the unit test). - Steady 240 fps or more in game areas: this PC did not reach it. - Windows; AMD and Intel GPUs.
Commenting is not possible because the repository is archived.
No description provided.