fix(vi): the time of day passes again above a 60 fps cap #2093

Merged
jordanella merged 1 commit from fix/time-of-day-clock into main 2026-11-12 03:26:21 +00:00
jordanella commented 2026-11-11 02:17:36 +00:00 (Migrated from github.com)

Above a Frame rate cap of 60, the time of day runs slow and above 120 fps it stops: the sun swings back and forth over a frozen hour. This is another of the above-60 side effects tracked in #1742.

Builds on #2006 (merged): it adds a site to that PR's hos_frame_whole table, next to #2409's water ripples. Rebased onto main, so it is one commit.

Cause

Since #1612, above a 60 cap the game's frame step is the frame's own time (0.42 at 144 fps instead of 1).

The time of day is kept by f_803edf70:

  • Each frame it adds (int)(2 * step) game seconds (+60) to the seconds of the day (+40). At 86400 those wrap and carry into the day count (+44).
  • At 60 fps that's 2 seconds a frame, so a game day lasts about 12 real minutes.
  • Between 60 and 120 fps the cut is 1, so the day runs slow: 51% at 61 fps, 83% at 100.
  • Above 120 fps it is 0, so the clock stands still. At a cap of exactly 120 the step is about 0.5, so any frame measured a little short also adds 0.

Fix

  • The patch: f_803edf70 joins WHOLE_SITES in nx2/oot_patches.py. Its cut goes through hos_frame_whole, keyed by the clock's +60. Above a 60 cap that carries the fraction between frames, so the clock gains its 120 game seconds per real second at any frame rate.
  • At a cap of 60: the helper cuts exactly as the game does, so nothing changes there.
  • The anchor: the function makes the same cut into x8 a second time, in the branch that sets the time from another source. So the anchor is the cut plus the store to +60 after it, and the other cut is left alone (a test covers this).
  • Missing site: the build's existing warning counts this site too.

How it was found

  • Found by a cut watch: a test-only build counted, at each of the lift's 3595 float-to-integer cuts, how often it dropped a value between -1 and 1. One normal play session and one with the step forced to 1/144 s, over the same stretch, were compared. #2006's pickup counter showed up only in the forced run, which checked the method. The clock's cut went from 3 to 303 per 600 frames.
  • Confirmed by a log: with the step forced to 144 fps, a test-only log of the clock showed it stuck at 46627 seconds (about 12:57) with 0 added each frame, while the input was 0.4167.
  • The fix tested: with the fraction carried, the clock gained 400 seconds every 480 frames. That's 0.83 a frame, exactly 2 × 0.4167.

Tests

  • tests/test_frame_whole.py:
    • The rate: 144 frames of 2 × 60/144 make 120 seconds, a real second's worth at 60.
    • The other cut: the second cut into x8, in the branch that sets the time from another source, is left as the game's.
    • Shared checks: each site is patched once, re-applying changes nothing, and other code is left alone. These now run for this site too.
  • python -I -B tests/run_all.py after the rebase onto 8568959d: 404 passed, 13 failed. All 13 fail the same way on a clean checkout of that main on this PC (which fails two more besides), so none come from this PR.
  • In game (Lake Hylia, by day, step forced to 144 fps):
    • Before (main): the clock stood still and the sun swung east → west → east.
    • With this branch's build: the sun moved steadily east to west.
    • At a cap of 60 the helper cuts as the game does (covered by the test). That wasn't re-checked in game with this build.

🤖 Generated with Claude Code

Above a Frame rate cap of 60, the time of day runs slow and above 120 fps it stops: the sun swings back and forth over a frozen hour. This is another of the above-60 side effects tracked in #1742. **Builds on #2006** (merged): it adds a site to that PR's `hos_frame_whole` table, next to #2409's water ripples. Rebased onto main, so it is one commit. ## Cause Since #1612, above a 60 cap the game's frame step is the frame's own time (0.42 at 144 fps instead of 1). The time of day is kept by `f_803edf70`: - Each frame it adds `(int)(2 * step)` game seconds (`+60`) to the seconds of the day (`+40`). At 86400 those wrap and carry into the day count (`+44`). - At 60 fps that's 2 seconds a frame, so a game day lasts about 12 real minutes. - Between 60 and 120 fps the cut is 1, so the day runs slow: 51% at 61 fps, 83% at 100. - Above 120 fps it is 0, so the clock stands still. At a cap of exactly 120 the step is about 0.5, so any frame measured a little short also adds 0. ## Fix - **The patch:** `f_803edf70` joins `WHOLE_SITES` in `nx2/oot_patches.py`. Its cut goes through `hos_frame_whole`, keyed by the clock's `+60`. Above a 60 cap that carries the fraction between frames, so the clock gains its 120 game seconds per real second at any frame rate. - **At a cap of 60:** the helper cuts exactly as the game does, so nothing changes there. - **The anchor:** the function makes the same cut into `x8` a second time, in the branch that sets the time from another source. So the anchor is the cut plus the store to `+60` after it, and the other cut is left alone (a test covers this). - **Missing site:** the build's existing warning counts this site too. ## How it was found - **Found by a cut watch:** a test-only build counted, at each of the lift's 3595 float-to-integer cuts, how often it dropped a value between -1 and 1. One normal play session and one with the step forced to 1/144 s, over the same stretch, were compared. #2006's pickup counter showed up only in the forced run, which checked the method. The clock's cut went from 3 to 303 per 600 frames. - **Confirmed by a log:** with the step forced to 144 fps, a test-only log of the clock showed it stuck at 46627 seconds (about 12:57) with 0 added each frame, while the input was 0.4167. - **The fix tested:** with the fraction carried, the clock gained 400 seconds every 480 frames. That's 0.83 a frame, exactly 2 × 0.4167. ## Tests - `tests/test_frame_whole.py`: - **The rate:** 144 frames of `2 × 60/144` make 120 seconds, a real second's worth at 60. - **The other cut:** the second cut into `x8`, in the branch that sets the time from another source, is left as the game's. - **Shared checks:** each site is patched once, re-applying changes nothing, and other code is left alone. These now run for this site too. - `python -I -B tests/run_all.py` after the rebase onto 8568959d: 404 passed, 13 failed. All 13 fail the same way on a clean checkout of that main on this PC (which fails two more besides), so none come from this PR. - **In game (Lake Hylia, by day, step forced to 144 fps):** - Before (main): the clock stood still and the sun swung east → west → east. - With this branch's build: the sun moved steadily east to west. - At a cap of 60 the helper cuts as the game does (covered by the test). That wasn't re-checked in game with this build. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) approved these changes 2026-11-11 02:25:35 +00:00
hckrman101 (Migrated from github.com) left a comment

Read the new commit (7bc9019) against the patched function; looks good to me.

Relation to #2006: this is a clean stack, not a copy. The first three commits are #2006's approved head (3bf6dcf) unchanged, and this PR adds only a WHOLE_SITES entry, the warning text and a test. host/frame_whole.h and hos_frame_whole aren't duplicated anywhere. Merge #2006 first. The repo uses merge commits, so after that this PR shrinks to its one commit with no conflict. If #2006 gets more commits, rebase this on top.

The fix: the anchored cut is the one that matters. It's (int)(step + step) stored to +60, and the rest of the clock (the seconds of the day in +40, wrapping at 86400 into +44, and the values derived from them) is built from that integer. So carrying the fraction at that one cut keeps the whole clock consistent. The rates I checked:

  • Cap of 60 or lower: hos_frame_seconds_bits is 0, so frame_whole takes the plain cut. That's the game's own behaviour, bit for bit.
  • 120: the amount wobbles around 1.0. The carry stops the short frames from adding 0, and it averages out to 2 × step.
  • 144: 0.83 a frame on average, which matches your log.
  • Uncapped: fine up to 240 fps. Above that, realtime_step floors the step at 1/240 s, so the clock runs fast along with everything else the step drives. That's already the case on main and isn't caused by this PR.
  • Clock stopped: the game zeroes +60 right after the store when the clock is stopped. The remainder still moves on those frames, but it stays under 1, so there's no catch-up jump when the clock starts again.
  • Paused, or a step of 0: !(amount > 0) returns 0 and leaves the remainder alone.
  • Cutscenes that set the time: they write +40 directly, and the leftover remainder (under 1 game second) doesn't matter there.

CI: still running. The only red check so far is actionlint, on .github/workflows/linux-appimage.yml:163 (SC2012). That comes from main's workflow and this PR doesn't touch it. #2006's run was green apart from the known Windows timing test.

Nit on the wording only, see inline.

Read the new commit (7bc9019) against the patched function; looks good to me. **Relation to #2006:** this is a clean stack, not a copy. The first three commits are #2006's approved head (3bf6dcf) unchanged, and this PR adds only a `WHOLE_SITES` entry, the warning text and a test. `host/frame_whole.h` and `hos_frame_whole` aren't duplicated anywhere. Merge #2006 first. The repo uses merge commits, so after that this PR shrinks to its one commit with no conflict. If #2006 gets more commits, rebase this on top. **The fix:** the anchored cut is the one that matters. It's `(int)(step + step)` stored to `+60`, and the rest of the clock (the seconds of the day in `+40`, wrapping at 86400 into `+44`, and the values derived from them) is built from that integer. So carrying the fraction at that one cut keeps the whole clock consistent. The rates I checked: - **Cap of 60 or lower:** `hos_frame_seconds_bits` is 0, so `frame_whole` takes the plain cut. That's the game's own behaviour, bit for bit. - **120:** the amount wobbles around 1.0. The carry stops the short frames from adding 0, and it averages out to 2 × step. - **144:** 0.83 a frame on average, which matches your log. - **Uncapped:** fine up to 240 fps. Above that, `realtime_step` floors the step at 1/240 s, so the clock runs fast along with everything else the step drives. That's already the case on main and isn't caused by this PR. - **Clock stopped:** the game zeroes `+60` right after the store when the clock is stopped. The remainder still moves on those frames, but it stays under 1, so there's no catch-up jump when the clock starts again. - **Paused, or a step of 0:** `!(amount > 0)` returns 0 and leaves the remainder alone. - **Cutscenes that set the time:** they write `+40` directly, and the leftover remainder (under 1 game second) doesn't matter there. **CI:** still running. The only red check so far is actionlint, on `.github/workflows/linux-appimage.yml:163` (SC2012). That comes from main's workflow and this PR doesn't touch it. #2006's run was green apart from the known Windows timing test. Nit on the wording only, see inline.
hckrman101 (Migrated from github.com) commented 2026-11-11 02:25:35 +00:00

Nit: as far as I can tell the other x8 cut isn't the minutes. It sits in the branch that sets the time from another source. The minutes come from the integer in +60, so they're already covered by this cut. The anchor (cut + store to +60) is still the right one, so this is just the wording here, in the test and in the PR text.

Nit: as far as I can tell the other `x8` cut isn't the minutes. It sits in the branch that sets the time from another source. The minutes come from the integer in `+60`, so they're already covered by this cut. The anchor (cut + store to `+60`) is still the right one, so this is just the wording here, in the test and in the PR text.
hckrman101 commented 2026-11-11 15:50:56 +00:00 (Migrated from github.com)

Heads-up: this conflicts with main now. Could you rebase onto main and resolve the conflicts? Thanks!

Heads-up: this conflicts with main now. Could you rebase onto main and resolve the conflicts? Thanks!
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 21:46:43 +00:00
Joey-Magnus (Migrated from github.com) left a comment

This isn't on main yet (f_803edf70 isn't in WHOLE_SITES), and it doesn't overlap #2470. That draft handles the sound job, the physics solver steps and the quick menu, and doesn't touch the clock or WHOLE_SITES, so the two only meet as nearby text in nx2/oot_patches.py and host/nn_vi.c.

With #2006 merged, the conflict is small but has to be resolved by hand. #2409 (water ripples) added f_800672e0 to WHOLE_SITES and its comment list, and raised FRAME_WHOLE_SLOTS in host/frame_whole.h from 16 to 64 (each water surface takes a slot). Keep both sites and main's 64 slots and slot comment: going back to 16 would bring the ripple freeze back in busy scenes. tests/test_frame_whole.py is an add/add conflict, so merge your rate and minutes-cut checks into main's version rather than replacing it. apply() in oot_patches.py also picked up heap_sites from #2451, so that hunk should keep main's line. After the rebase, hckrman101's approval should still hold, as long as the warning count in nn_vi.c includes both new sites.

This isn't on main yet (`f_803edf70` isn't in `WHOLE_SITES`), and it doesn't overlap #2470. That draft handles the sound job, the physics solver steps and the quick menu, and doesn't touch the clock or `WHOLE_SITES`, so the two only meet as nearby text in `nx2/oot_patches.py` and `host/nn_vi.c`. With #2006 merged, the conflict is small but has to be resolved by hand. #2409 (water ripples) added `f_800672e0` to `WHOLE_SITES` and its comment list, and raised `FRAME_WHOLE_SLOTS` in `host/frame_whole.h` from 16 to 64 (each water surface takes a slot). Keep both sites and main's 64 slots and slot comment: going back to 16 would bring the ripple freeze back in busy scenes. `tests/test_frame_whole.py` is an add/add conflict, so merge your rate and minutes-cut checks into main's version rather than replacing it. `apply()` in oot_patches.py also picked up `heap_sites` from #2451, so that hunk should keep main's line. After the rebase, hckrman101's approval should still hold, as long as the warning count in `nn_vi.c` includes both new sites.
jordanella commented 2026-11-12 03:25:44 +00:00 (Migrated from github.com)

Rebased onto main (8568959d): the PR is now its one commit (087ba5dd).

  • Conflicts, resolved as @Joey-Magnus described:
    • #2409's f_800672e0 site is kept next to the clock's in WHOLE_SITES, with both comment entries;
    • FRAME_WHOLE_SLOTS stays at main's 64 (this PR doesn't touch host/frame_whole.h);
    • tests/test_frame_whole.py merged with main's version, so the ripple checks and the clock's rate and other-cut checks are all there;
    • apply() is main's;
    • the missing-site warning names all three counters.
  • Wording (@hckrman101's nit): you're right. The second cut into x8 is in the block at L_803ee05c, taken on bit 25 of the flags, and the minutes are read later from +60. The patch's comment, the test and the PR text now say "in the branch that sets the time from another source".
  • Tests: test_frame_whole.py passes. In the full run, all 13 failures fail the same way on a clean checkout of main here, which fails two more besides.
Rebased onto main (8568959d): the PR is now its one commit (087ba5dd). - **Conflicts,** resolved as @Joey-Magnus described: - #2409's `f_800672e0` site is kept next to the clock's in `WHOLE_SITES`, with both comment entries; - `FRAME_WHOLE_SLOTS` stays at main's 64 (this PR doesn't touch `host/frame_whole.h`); - `tests/test_frame_whole.py` merged with main's version, so the ripple checks and the clock's rate and other-cut checks are all there; - `apply()` is main's; - the missing-site warning names all three counters. - **Wording (@hckrman101's nit):** you're right. The second cut into `x8` is in the block at `L_803ee05c`, taken on bit 25 of the flags, and the minutes are read later from `+60`. The patch's comment, the test and the PR text now say "in the branch that sets the time from another source". - **Tests:** `test_frame_whole.py` passes. In the full run, all 13 failures fail the same way on a clean checkout of main here, which fails two more besides.
Commenting is not possible because the repository is archived.
No description provided.