fix(vi): a cutscene's cues stay with its movie above a 60 cap (#1742) #1875
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!1875
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/movie-frame-count-above-60"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
With the Game frame rate cap above 60, a cutscene that shows a movie runs its narration, its rumble and its end ahead of the picture: at 120 fps the narrator's lines in the new-game prologue start over each other (#1742). This makes the movie's frame count, which those cues are placed by, follow the frames' time. It is a fix for a live bug above a 60 cap; at the default cap of 60 nothing changes.
Why it happens
f_83b7c710: a count at +760 of the player, one more each game frame). A cutscene that shows a movie takes that count as its timeline's frame (f_83b5ec10,f_83b5e9a0), and the narration, the rumble and the cutscene's end are placed on that timeline.host/nn_video.c). #1612 made the game's own clock keep real time above 60, but this count is not made from the time step, so at 120 fps it still ran twice as fast as the movie.What changes
nx2/oot_patches.pypatch_movie_frames: the count's step in the liftedf_83b7c710ishos_movie_frames(player)instead of 1. A lift where the place is not found once is left as it is, with a warning (a cap above 60 then behaves as it does now).host/nn_vi.chos_movie_frames: 1 while the host gives no frame time, which is at a cap of 60, withHOS_FPS_STEP=0, and in a build without #1612's hook. Otherwise the frames' time in 60ths, the fraction carried to that player's next update. At a cap of 60 this costs one load and a branch per movie update.realtime_stepnow writes the frame time with atomic stores, because the new reader is on another thread.HOS_FPS_MOVIE=0keeps the game's own count of one an update, for comparisons.Tested
Windows 11, Ryzen 7 5700X3D, Radeon RX 9070 XT (Vulkan 1.4, driver 0x8001a0). A new game from an empty save: the prologue movie (154 s) at 1280x720, nothing pressed after New Game. All runs on one binary: main
88b554e+ #1696 + this change.How it was read: the game's sound is dumped (
HOS_AUDIO_DUMP); the centre channel carries the narration over the movie's own sound. Each 2 s piece of the first cap-60 run is looked for in the other run, after the two are aligned on the movie's sound. A narration line found at another time shows as a lag (negative: earlier). The first rumble value is the number of the first non-zero value the game sends; it sends about 200 a second.HOS_FPS_MOVIE=0(as main is)HOS_FPS_MOVIE=0. No fatal, device-lost or validation line in any of the eight logs.HOS_FRAME_SLEEP_MS=25, one run each. Under a cap of 120 the count's step is 1 or 2 at every update: all narration pieces found, +0.00 to +0.83 s, first rumble value #16218. Under a cap of 60 the game's own count runs at 36 a second: the lines come 4.1, 9.7, 17.7 and 20.6 s late and the rumble at #24508 (about 41 s late). So no cue is lost to a step of 2, and holding the step at 1 would bring that lateness to caps above 60.HOS_FPS_MOVIE=0. Most are in the movie's first seconds. This change neither causes nor cures them.tests/test_movie_frame_count.py(it registers itself fortests/run_all.py): the patch on a synthetic function of the lifted shape (applied once, a second run, the same lines in another function, three other shapes left alone, the build's warning), andhos_movie_framescut fromhost/nn_vi.cand run at 60, 120, 240 and 40 fps, with two movies at once, with a movie that plays while others come and go, and with 9 to 377 players for 16 places. Negative controls: it fails on main'soot_patches.pyand on main'snn_vi.c, each on its own, and the game's own step of one an update fails the same 120 fps checks. Passes with gcc, MSYS2 clang and MSVC-target clang.the movie's frame count by the frames' time in code_046.c).python -I -B tests/run_all.pyon this branch (main1392ea3, Windows, MSYS2 gcc and g++): 272 passed, 6 failed, 43 skipped. The 6 arenx2/test_nup_out_of_space.py,nx2/test_nup_paths.py,tests/test_fpx_sticky.py,tests/test_precompile_compute_guard.py,tests/test_pipeline_play_cache.pyandtests/test_wayland_libdecor_hint.py; all six also failed on this PC on main without this change (checked at9edb3a5and51de681: a path form and headers this PC's compilers do not find).host/nn_vi.cand the otherhost/*.csyntax-compile as whole files (notnn_codec.candnn_video.c: my check has no Opus and FFmpeg headers).117eae7, which repairs the two source checks that failed in this PR's first CI run (nx2/test_draw_parameters.py,tests/test_antilag.py; they failed on main1392ea3too). The new test,test_frame_rate_cap_step.pyandtest_mark_groups.pypass on the rebased commit.Not covered
Review
gpt-6.1-sol, five rounds on the source. It found an unsynchronised read of the frame time, a racy first-call initialisation, counts that stood still with more players than table places (twice: first with a start of 0, then with a fixed step between starts for one player in 233), and a playing movie losing its place to newcomers. All are fixed, each with a test that fails on the version before. Last round: none found.
Part of #1742 (the narration and the other cues of cutscenes that show a movie; Gohma and #1726 stay open there). Related: #1726, #1612, #1805.
🤖 Generated with Claude Code
Thanks, this is a nicely targeted fix: stepping the movie player's count by the same frame time #1612 already gives the game clock is the right lever, and the measurements (narration and first rumble back within run-to-run noise of cap 60, and
HOS_FPS_MOVIE=0reproducing the drift) are convincing. At a 60 caphos_frame_seconds_bitsis 0, so it returns 1 straight away and the lifted code behaves as before. Detection doesn't hang on a fragile flag either: the hook is just the player's own per-update increment, so pausing, skipping or stopping a movie only changes whether it gets called. The relaxed atomics on the bits and the SRWLOCK around the table are fine (SRWLOCK is already used in this file, so the Linux shim covers it). The test runs the realhos_movie_framesand the real patch function, and declaresRUN_ALL_ARGS.Two things before merge:
Steps above 1 (inline). At a cap above 60, a frame that runs slower than 60 fps gives a step of 2 to 4, because
realtime_stepclamps at 4/60 and not at 1/60. Then whole values of the count get skipped. Any cue the cutscene fires onframe == Nrather than on a range would be lost for good, not just late. In the N64 OoT decomp a lot of the one-shot cutscene commands (rumble, misc, destination/end) testcurFrame == startFrame. I haven't checked this game's timeline code, but it's likely the same pattern. You saw 470 to 750 ms frames in the movie's first seconds, so this can happen in practice. Cappingnat 1 and dropping the excess (the remainder below 1 stays carried) would give the same behaviour as a 60 cap during a slowdown, where cues are late but every one fires. If you've confirmed the timeline only uses ranges, a line saying so in the comment would settle it.Fixes #1742would close the issue with Gohma still open. The issue now also covers Gohma running into the wall at 100 fps, and #1726 was folded into it. The PR says both of those are untouched. Could you change it toPart of #1742(or split Gohma into its own issue) so it doesn't get closed automatically?Nit: the PR body still ends with an AI attribution line. Fine for the description, but please keep it out of the commit message on squash.
CI hasn't run on
a7cc747yet (all checks pending, probably waiting on approval for a fork workflow run). Once it runs, failures that also show up on main (thepresent_commit/present_defer_intolink error and the known Python checks) aren't from this PR.@ -227,0 +265,4 @@}carry[i].used = ++calls;carry[i].part += (double)secs * 60.0;n = (uint32_t)carry[i].part;realtime_stepclamps the frame time at 4/60, so below 60 fps at a cap above 60 this can be 2 to 4, and the count skips values. A cue tested withframe == startwould then never fire, where at cap 60 it just fires late. Keeping thecarry[i].part -= n;line as it is and addingif (n > 1) n = 1;after it (so only the fraction is carried and the excess is dropped) would keep every value hit during a stall and still track the clock at 60+ fps, wherenis 0 or 1 anyway.@ -227,0 +265,4 @@}carry[i].used = ++calls;carry[i].part += (double)secs * 60.0;n = (uint32_t)carry[i].part;Kept the step as it is and added the comment you offered (
244a71e), because the timeline fires on ranges:f_8124cfc0walks a list sorted by time and fires every event between its last frame and the new one, and the in-engine timelines run the same code with a rate times the time step. Measured on the same binary slowed to 36 fps (HOS_FRAME_SLEEP_MS=25, one run each): under a cap of 120 the step is 1 or 2 at every update and every narration line is found, +0.00 to +0.83 s, with the first rumble value at #16218 (cap 60 at full rate: #16186 and #16258); under a cap of 60 the game's own one an update brings the lines 4.1 to 20.6 s late and the rumble about 41 s late, so a step held at 1 would bring that lateness to caps above 60. The test's 40 fps check fails if the step is held at 1. On your other points: the description now says "Part of #1742", and the commit message carries no attribution line beyond the co-author trailer.🤖 Addressed by Claude Code
Thanks for the follow-up. Everything from my last review is settled:
frame == start, so a step of 2 to 4 skips no cue. Your 36 fps measurement agrees, and the new comment explains it well. Holding the step at 1 would just add lag, as you said.Co-Authored-Bytrailer. Whoever squashes should edit those out of the squash message.The only change since
a7cc747is a comment. CI on244a71eis still running; every check passed ona7cc747, so I don't expect anything new. Approving.