fix(vi): the pick-up counter fades again above a 60 fps cap (#1832) #2006

Merged
jordanella merged 3 commits from fix/pickup-indicator into main 2026-11-11 02:33:13 +00:00
jordanella commented 2026-11-10 23:27:17 +00:00 (Migrated from github.com)

Fixes #1832 (one of the above-60 side effects tracked in #1742).

Cause

Above a Frame rate cap of 60, #1612 makes the game's frame step the frame's own time, so at 144 fps each frame steps about 0.42 of a 60th.

The counter that pops up above Link's head after a pickup (rupees, Deku Sticks and the other stacking items) is updated by f_80a87e50:

  • It moves the count it shows (+864) toward the new total (+860) by (int)(step * max(distance / 50, 1)).
  • It fades only when the two meet.

At a step of 0.42 that cut is 0 every frame. The shown count never moves, so the counter stays up until a door or a talk replaces it. At 60 fps the step is 1 and the count moves normally.

Fix

  • The patch: nx2/oot_patches.py routes that one cut through hos_frame_whole (host/frame_whole.h, called from host/nn_vi.c).
  • Above a 60 cap: while the frames' own time is the step, the helper carries the fractions per counter, so the count moves at the game's rate whatever the frame rate.
  • At a cap of 60: the amount is cut exactly as the game cuts it, so nothing changes there.
  • Only this counter: I scanned every reader of the frame step for a cut to a whole number, following it through arithmetic. One more, f_832b32b0 ((unsigned)step added to a counter), has the same flaw. It never ran in any of the logged pickup sessions, though, and what it drives is unknown, so it's left out of this PR. A separate look at the other above-60 truncations (#1742) will add sites only once they're seen to misbehave in game. Two others (f_83423a90, f_8337b110) are correct with fractional steps.
  • Missing site: the build warns if the site isn't found, for example on another game version. The patch finds the cut itself, so it still matches when the function is built as hot code (A64_HOT_FUNCS).
  • Alongside #1875: this sits next to hos_movie_frames and follows the same carry-the-fraction approach.

How it was found

  • Reproduced without a 144 fps PC: a test-only switch forced the step to 1/144 s, and the counter stuck.
  • Located the value: I put the frame-time keeper's fields back to their 60 fps values one at a time. Only the step mattered.
  • Located the function: logging the step's readers as they first ran showed f_80a87e50 starting at the pickup.

Tests

  • tests/test_frame_whole.py covers two things:
    • The helper: 144 frames at 144 fps make 60 counted frames, a step times 3 keeps its fraction, a cap of 60 cuts as the game does, counters keep separate remainders, and NaN or negative amounts give 0.
    • The patch: each site is replaced once, re-applying changes nothing, and other code is left alone.
  • test_frame_rate_cap_step, test_text_speed and test_free_view_anchor still pass.
  • In game, with the step forced to 144 fps:
    • A rupee counts up and fades.
    • The first-ever Deku Stick (with the new-item scene) goes 0/10 → 1/10, the next one 1/10 → 2/10, and both fade.
    • At normal timing nothing changed.
  • Not tested in game: bombs, seeds, nuts and arrows. They use the same counter.
  • Saves already hit by the bug: a save from a session that had hit the stuck counter before the fix later stopped adding sticks, while a clean save works. The fix prevents the stuck count, but it can't repair a save that's already in that state.

🤖 Generated with Claude Code

Fixes #1832 (one of the above-60 side effects tracked in #1742). ## Cause Above a Frame rate cap of 60, #1612 makes the game's frame step the frame's own time, so at 144 fps each frame steps about 0.42 of a 60th. The counter that pops up above Link's head after a pickup (rupees, Deku Sticks and the other stacking items) is updated by `f_80a87e50`: - It moves the count it shows (`+864`) toward the new total (`+860`) by `(int)(step * max(distance / 50, 1))`. - It fades only when the two meet. At a step of 0.42 that cut is 0 every frame. The shown count never moves, so the counter stays up until a door or a talk replaces it. At 60 fps the step is 1 and the count moves normally. ## Fix - **The patch:** `nx2/oot_patches.py` routes that one cut through `hos_frame_whole` (`host/frame_whole.h`, called from `host/nn_vi.c`). - **Above a 60 cap:** while the frames' own time is the step, the helper carries the fractions per counter, so the count moves at the game's rate whatever the frame rate. - **At a cap of 60:** the amount is cut exactly as the game cuts it, so nothing changes there. - **Only this counter:** I scanned every reader of the frame step for a cut to a whole number, following it through arithmetic. One more, `f_832b32b0` (`(unsigned)step` added to a counter), has the same flaw. It never ran in any of the logged pickup sessions, though, and what it drives is unknown, so it's left out of this PR. A separate look at the other above-60 truncations (#1742) will add sites only once they're seen to misbehave in game. Two others (`f_83423a90`, `f_8337b110`) are correct with fractional steps. - **Missing site:** the build warns if the site isn't found, for example on another game version. The patch finds the cut itself, so it still matches when the function is built as hot code (`A64_HOT_FUNCS`). - **Alongside #1875:** this sits next to `hos_movie_frames` and follows the same carry-the-fraction approach. ## How it was found - **Reproduced without a 144 fps PC:** a test-only switch forced the step to 1/144 s, and the counter stuck. - **Located the value:** I put the frame-time keeper's fields back to their 60 fps values one at a time. Only the step mattered. - **Located the function:** logging the step's readers as they first ran showed `f_80a87e50` starting at the pickup. ## Tests - `tests/test_frame_whole.py` covers two things: - **The helper:** 144 frames at 144 fps make 60 counted frames, a step times 3 keeps its fraction, a cap of 60 cuts as the game does, counters keep separate remainders, and NaN or negative amounts give 0. - **The patch:** each site is replaced once, re-applying changes nothing, and other code is left alone. - `test_frame_rate_cap_step`, `test_text_speed` and `test_free_view_anchor` still pass. - **In game, with the step forced to 144 fps:** - A rupee counts up and fades. - The first-ever Deku Stick (with the new-item scene) goes 0/10 → 1/10, the next one 1/10 → 2/10, and both fade. - At normal timing nothing changed. - **Not tested in game:** bombs, seeds, nuts and arrows. They use the same counter. - **Saves already hit by the bug:** a save from a session that had hit the stuck counter before the fix later stopped adding sticks, while a clean save works. The fix prevents the stuck count, but it can't repair a save that's already in that state. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) approved these changes 2026-11-11 02:17:45 +00:00
hckrman101 (Migrated from github.com) left a comment

Read the patch, the helper and the test; looks good to me. The fix is narrow: one cut in f_80a87e50 goes through hos_frame_whole, the helper only carries fractions while hos_frame_seconds_bits is non-zero (so a cap of 60 is cut exactly as before), the lock makes sense for the game's update threads, and the patch only matches when the function and the one cut are found, with a build warning otherwise. The hot-code case is covered in the test, and the description says plainly what was left out (f_832b32b0) and what wasn't tried in game.

CI: the only red check is the Windows test_log_batch.py (GetTickCount64() - t0 < 200, a timing check), which this PR doesn't touch; main's own source-regression runs are failing too. Everything else, including the Windows Vulkan build, is green.

Nit, no change needed: the per-counter remainder survives the fade, so a leftover fraction (under 1) is added to the next pickup's first frame. Harmless, just noting it.

Read the patch, the helper and the test; looks good to me. The fix is narrow: one cut in f_80a87e50 goes through hos_frame_whole, the helper only carries fractions while hos_frame_seconds_bits is non-zero (so a cap of 60 is cut exactly as before), the lock makes sense for the game's update threads, and the patch only matches when the function and the one cut are found, with a build warning otherwise. The hot-code case is covered in the test, and the description says plainly what was left out (f_832b32b0) and what wasn't tried in game. CI: the only red check is the Windows `test_log_batch.py` (`GetTickCount64() - t0 < 200`, a timing check), which this PR doesn't touch; main's own source-regression runs are failing too. Everything else, including the Windows Vulkan build, is green. Nit, no change needed: the per-counter remainder survives the fade, so a leftover fraction (under 1) is added to the next pickup's first frame. Harmless, just noting it.
Commenting is not possible because the repository is archived.
No description provided.