Keep movies with their sound at any frame rate: one movie frame per update, and the pacer keeps the movie's slots through a late frame.

The game's movie player adds the time manager's step to an accumulator at each update and advances the movie by whole
pictures of it, at most the player's limit per update, which is one for the Triforce movie. While a movie plays the
game is held at 60 updates a second, but every late frame cost an update, the pacer started its slots again from the
late one, and the steady step's payback of the lost time came as steps over one frame, which the player cannot turn
into more than one picture. So each late frame put the picture further behind the sound, which plays by the clock, and
it never caught up.

While a movie plays the step is now exactly one movie frame, and the pacer keeps the movie's slots through a lateness
of up to a second, presenting the frames after a late one at once until the game is back on them. The updates then
average the movie's rate, and the picture, the event's update count and the game's clock all stay with the sound.
movie_clock = 0 gives the earlier behaviour. The movie log now measures the picture on screen against real time where
the game releases it (the game fetches pictures ahead into a list of its own) and reports it at the end of each movie.
test_hitch = <every>:<ms> makes a frame that much late every so many presents, for test runs.

Measured on the Triforce movie (event Dm_Kokiri_0095, 7,040 pictures, 117 s), one exe with movie_clock = 0 against 1,
and a 70 ms late frame every two seconds (test_hitch = 120:70), as a run with natural hitches had shown a drift of 40 ms
every ten seconds. Drift of the picture on screen behind real time, from the movie's first 10 s to its end:

  fps_limit   before (updates/s, drift at the end)   after (updates/s, drift over the movie)
  unlimited   58.86, 2,187 ms behind                  60.22, flat at 53 to 55 ms
  144         58.86, 2,196 ms behind                  60.04, flat at -11 to -15 ms
  120         58.98, 2,257 ms behind                  60.20, flat at -1 to 0 ms
  60          58.88, 2,220 ms behind                  59.98, flat at -21 to -22 ms

Without the late frames both kept time (unlimited: before flat at 21 ms, after flat at -13 ms). The selftest,
--probe-cfg, --probe-kernel, --probe-movie and tests/frame_step_check.py pass; the check now plays a movie started at
240 fps with late and short frames and needs exactly one frame every update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-11 14:11:15 -05:00
committed by tangerineturtle1
parent 1fb2877f56
commit d3e50328bf
6 changed files with 93 additions and 11 deletions

View File

@@ -55,7 +55,7 @@ static const char *const known[] = {
"HDR_HIGHLIGHTS", "HDR_HUE", "HDR_HUE_CLIP", "HDR_MATCH_SLOPE", "HDR_MIDGRAY", "HDR_SATURATION", "HDR_SHADOWS",
"HDR_TONEMAP", "HDR_TONE_SLOPE", "HIGHFPS_FIX", "INPUT", "INPUT_RECORD", "INPUT_SCRIPT", "LANG", "LIB_PREBUILD",
"BIG_BUILD_FREE_MB", "BIG_BUILD_MAX_MB", "BUILD_FREE_MB", "BUILD_RESERVE_MB", "LIB_PREBUILD_BIG", "LIB_PREBUILD_BIG_BG", "LIB_PREBUILD_KEEP", "LIB_PREBUILD_THREADS", "LIB_WARM", "PIPE_LINK_MS", "FRAME_HOLD_MS", "PACE_SMOOTH", "OQ_BATCH", "GATE_WAIT_MS", "LOCALE", "LOD_GUARD", "LSTICK_DEADZONE", "MEMORY", "MEM_CENSUS", "MEM_NEED_MB", "MENU_LANG", "MENU_SOUND_VOLUME", "MENU_TEST",
"MENU_CURSOR", "MENU_CURSOR_LOG", "MENU_TEST_CLICK", "MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MICROPHONE", "MIRROR_GAP", "MODS_DIR", "MODS_DISABLED", "MODS_ORDER", "MODS_REBUILD", "MONITOR", "MOTION", "MOUSE_MODE", "MOUSE_DIRECT_PATCH", "MOUSE_SENS", "MOUSE_TRACE", "MOVIE_HOST", "MOVIE_PACE",
"MENU_CURSOR", "MENU_CURSOR_LOG", "MENU_TEST_CLICK", "MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MICROPHONE", "MIRROR_GAP", "MODS_DIR", "MODS_DISABLED", "MODS_ORDER", "MODS_REBUILD", "MONITOR", "MOTION", "MOUSE_MODE", "MOUSE_DIRECT_PATCH", "MOUSE_SENS", "MOUSE_TRACE", "MOVIE_HOST", "MOVIE_PACE", "MOVIE_CLOCK", "TEST_HITCH",
"MVOBJ_ALL", "MVOBJ_BANKS", "MVOBJ_EQUAL", "MVOBJ_EXACT", "MVOBJ_EXEC", "MVOBJ_NOC4", "MVOBJ_ONLY", "MVOBJ_TIMES", "MVPREV_CHECK", "MVPREV_GROW", "MVPREV_SKIP", "MVPREV_TRUST", "MV_CHECK",
"MV_SURVEY",
"ONE_PER_CORE", "OPERATION_MODE", "OUTPUT_RES", "PAD_LAYOUT", "PACE_HZ", "PASS_CRUMBS", "PATCH_OUTPUT", "PATCH_RES_FIXED", "PATCH_SCENE", "PATCH_WIDE_ASPECT", "PATCH_WIDE_HUD", "PATCH_WIDE_MOVIE", "PATCH_WIDE_OUT", "PATCH_FREECAM", "PATCH_WIDE_SCENE", "PCSS", "PCSS_K", "PERF_OVERLAY", "PERF_SLOW_MS", "PIPELINE_CACHE", "PIPELINE_CACHE_MAX_MB", "PIPELINE_CACHE_PLAY", "PIPELINE_CACHE_PLAY_MB", "PIPE_BINARIES", "PIPE_BINARIES_MAX_MB", "PIPE_FEEDBACK", "PIPE_MEMO", "PIPE_PREBUILD", "PIPE_SKIP", "PIPE_SYNC", "PIPE_WAIT", "POWER_FULL", "TEX_SAME", "PRESENT_HELD", "HELD_SYNC",

View File

@@ -235,6 +235,8 @@ double frame_step_game_hz(void) {
// RivaTuner Statistics Server (rtss_limit.c) gets the same limit; while it limits this process the pacer holds no
// frames, so the two never stack. NX2NAVI_RTSS=0 and frame generation (RTSS would count the generated frames) keep it out.
static double paced_hz; // the game frames a second the newest frame_step_cap_ns paces at (ocarina_step); 0: none
static int movie_paced; // the movie rate the newest frame_step_cap_ns paces at (ocarina_step, frame_step_movie_hz); 0: none
int frame_step_movie_hz(void) { return movie_paced; }
uint64_t frame_step_cap_ns(void) {
frame_limit_live();
static int rtss_allowed = -1;
@@ -245,6 +247,12 @@ uint64_t frame_step_cap_ns(void) {
int rtss = rtss_allowed && !vk_fg_wanted() && !vk_dlssg_active();
int gen = generated_factor();
int movie_hz = movie_cap_hz();
static int movie_clock = -1; // NX2NAVI_MOVIE_CLOCK=0: the steady step and the usual pacing through a movie too
if (movie_clock < 0) {
const char *e = cfg_get("NX2NAVI_MOVIE_CLOCK");
movie_clock = !(e && !strcmp(e, "0"));
}
movie_paced = movie_clock ? movie_hz : 0;
static int movie_was, movie_logs;
if (!!movie_hz != movie_was) {
movie_was = !!movie_hz;
@@ -392,12 +400,33 @@ static void ocarina_step_reset(void) {
os_owed = 0;
}
// While a movie plays (frame_step_cap_ns paces the game at the movie's rate) the step is exactly one movie frame, whatever
// the display intervals; NX2NAVI_MOVIE_CLOCK=0 leaves movies to the steady step (comparison runs). The game's movie
// player (main+0x3b5ca60) adds the time manager's seconds times 60 to an accumulator at each update and advances the
// movie by its whole pictures, at most the player's limit per update (+0x20, set at main+0x3b5d094: 1, or 2 in one of
// its modes), keeping the rest. The Triforce movie showed one picture per update (600 pictures in 600 updates at 59.6
// updates a second), so the time a late frame left owed, which the steady step pays back as steps over one frame, never
// became pictures: the picture fell behind its sound, which plays by the clock, by each late frame for good. The
// movie's event counts updates as 60 Hz frames too (frame_step_cap_ns). So the updates themselves keep the clock
// (nvn_api_obj.c pace keeps the movie's slots through a late frame) and each counts as one movie frame, for the
// picture, the event and the game's clock alike. The filter starts again at a movie's start and end, so neither rate's
// intervals reach the other.
static double ocarina_step(double dt) {
static int steady = -1;
static int steady = -1, movie_was;
if (steady < 0) {
const char *e = cfg_get("NX2NAVI_STEP_STEADY");
steady = !(e && !strcmp(e, "0"));
}
if (movie_paced != movie_was) {
movie_was = movie_paced;
ocarina_step_reset();
}
if (movie_paced > 0) {
double step = 1.0 / movie_paced;
if (os_step_lo <= 0 || step < os_step_lo) os_step_lo = step;
if (step > os_step_hi) os_step_hi = step;
return step;
}
if (dt > STEP_MAX_S) dt = STEP_MAX_S;
os_ring[os_next++ % OSTEP_WINDOW] = dt;
if (os_count < OSTEP_WINDOW) os_count++;

View File

@@ -384,6 +384,7 @@ void *rt_patch_page(void); // the page the patch
void frame_step_init(void); // after rt_patches_init: the real-time step's first value
int frame_step_realtime(void); // the real-time step is on: presents ignore the interval
uint64_t frame_step_cap_ns(void); // with the real-time step: the shortest frame in ns
int frame_step_movie_hz(void); // the movie rate frame_step_cap_ns paces at; 0: no movie
int ov_movie_live(void); // ov_movie2.c: a movie plays (frame_rate.c movie_cap_hz)
int frame_limit_adjustable(void); // the frame limit setting changes the rate (always)
int frame_free_running(void); // no fps60/realtime patch set: the game steps by the clock

View File

@@ -773,12 +773,20 @@ static uint64_t present_period(const win_t *o) {
// The present slot is the previous slot plus one period. Early: wait for the slot. Late by
// less than a period: present at once and keep the phase. Behind by a period or more: restart from now, no catch-up
// burst. Returns the ns to wait.
// burst, except while a movie plays (frame_step_movie_hz): then the slots are kept through a lateness up to
// MOVIE_CATCHUP_NS and the frames after a late one go at once until the game is back on them. A movie shows one
// picture per game update and its sound plays by the clock (frame_rate.c ocarina_step), so every period dropped here
// was a picture the movie fell behind for good: with a 70 ms frame every two seconds (NX2NAVI_TEST_HITCH=120:70) the
// updates averaged 58.9 a second and the Triforce movie's picture fell 190 ms behind its sound every ten seconds, 2.2 s
// by the end of its 117 s, at every frame limit from 60 to unlimited; with the slots kept it stayed within 22 ms. A
// stall of a second or more (a load) restarts the slots as before. Returns the ns to wait.
#define MOVIE_CATCHUP_NS 1000000000ull
static uint64_t pace(uint64_t *target, uint64_t now, uint64_t period) {
if (!period || !*target) { *target = now; return 0; }
uint64_t next = *target + period;
if (now <= next) { *target = next; return next - now; }
*target = now - next < period ? next : now;
uint64_t keep = frame_step_movie_hz() > 0 ? MOVIE_CATCHUP_NS : period;
*target = now - next < keep ? next : now;
return 0;
}
@@ -873,6 +881,15 @@ NVN_API(nvnQueuePresentTexture) {
o->presents++;
__atomic_store_n(&nvn_submits_in_frame, 0, __ATOMIC_RELAXED);
const tex_t *t = (const tex_t *)o->tex[idx];
static int hitch_every = -1, hitch_ms; // NX2NAVI_TEST_HITCH=<every>:<ms>: a late frame every so many presents
if (hitch_every < 0) {
const char *e = cfg_get("NX2NAVI_TEST_HITCH");
int every = 0, ms = 0;
hitch_every = e && sscanf(e, "%d:%d", &every, &ms) == 2 && every > 0 && ms > 0 ? every : 0;
hitch_ms = ms;
if (hitch_every) rt_log("nvn: a %d ms late frame every %d presents (NX2NAVI_TEST_HITCH)", hitch_ms, hitch_every);
}
if (hitch_every && o->presents % (uint64_t)hitch_every == 0) plat_wait_ns((uint64_t)hitch_ms * 1000000);
uint64_t period = present_period(o), now = kern_now_ns();
uint64_t slot = now + pace(&o->pace, now, period);
gfx_backend()->present(t, o->crop, slot);

View File

@@ -82,6 +82,11 @@ typedef struct {
int64_t ts0;
uint64_t shown0_ns;
int clock_on;
// The game fetches pictures ahead into a list of its own (main+0x3b61df0) and lets go of each when the next one
// takes its place on screen (ReleaseVideoFrame), so the distance to real time is measured there too: the newest
// released picture's, and how far it moved since the first released one (the drift), in ns
int64_t rel_lag, rel_lag0;
int rel_on;
} Video;
static Video *video_of(void *obj) { return obj ? *(Video **)obj : NULL; }
@@ -588,7 +593,7 @@ static void ov_initialize(Cpu *c) {
Video *v = *slot;
finalize(v);
set_live(v, 1);
v->clock_on = 0;
v->clock_on = v->rel_on = 0;
Dav1dSettings s;
dav1d_default_settings(&s);
// Frame threading on (dav1d picks the delay): heavy stretches of the title movie took longer than a frame to
@@ -674,7 +679,7 @@ static void ov_flush(Cpu *c) {
if (v) {
plat_mutex_lock(&v->lock);
v->generation++; // the decoder and convert threads drop what they are making
v->clock_on = 0; // a seek: the report's real-time reference starts again from the next picture
v->clock_on = v->rel_on = 0; // a seek: the report's real-time reference starts again from the next picture
clear_queues(v);
clear_converts(v);
plat_mutex_unlock(&v->lock);
@@ -805,9 +810,10 @@ static void ov_acquire(Cpu *c) {
rt_log("movie: pacing over 600 frames: acquire gap avg %.2f ms, max %.2f ms; %d pictures waiting; "
"per frame shown: decoder thread waiting for samples %.2f ms, send %.2f ms, get %.2f ms; convert thread "
"%.2f ms per picture (%llu pictures); "
"picture %.0f ms behind real time since the first picture",
"picture %.0f ms behind real time since the first picture; on screen %.0f ms behind, %+.0f ms since its start",
sum_gap / 600 / 1e6, max_gap / 1e6, v->qn, t_wait_ns / 600 / 1e6, t_send_ns / 600 / 1e6,
t_get_ns / 600 / 1e6, n_made ? t_make_ns / n_made / 1e6 : 0.0, (unsigned long long)n_made, lag / 1e6);
t_get_ns / 600 / 1e6, n_made ? t_make_ns / n_made / 1e6 : 0.0, (unsigned long long)n_made, lag / 1e6, v->rel_lag / 1e6,
(v->rel_lag - v->rel_lag0) / 1e6);
t_wait_ns = t_send_ns = t_get_ns = t_make_ns = n_made = 0;
sum_gap = max_gap = 0;
}
@@ -816,7 +822,12 @@ static void ov_acquire(Cpu *c) {
update_events(v);
plat_mutex_unlock(&v->lock);
if (p.rgba) gfx_movie_frame_own(p.rgba, p.w, p.h), p.rgba = NULL; // the overlay takes the buffer
if (p.eos) gfx_movie_stop(), set_live(v, 0);
if (p.eos) { // how the movie kept time: the picture on screen against the clock, at its start and its end
if (v->rel_on)
rt_log("movie: end of stream after %llu pictures: on screen %.0f ms behind real time, %+.0f ms since its start",
(unsigned long long)v->shown, v->rel_lag / 1e6, (v->rel_lag - v->rel_lag0) / 1e6);
gfx_movie_stop(), set_live(v, 0);
}
picture_free(&p);
*out = v->frame[f];
ret_x(c, 0);
@@ -828,7 +839,15 @@ static void ov_release(Cpu *c) {
if (v) {
plat_mutex_lock(&v->lock);
for (int i = 0; i < v->nframe; i++)
if (v->frame[i] == fr) v->busy[i] = 0;
if (v->frame[i] == fr) {
v->busy[i] = 0;
int64_t ts;
memcpy(&ts, (uint8_t *)(uintptr_t)fr + 0x78, 8);
if (v->clock_on && !((uint8_t *)(uintptr_t)fr)[0x88]) {
v->rel_lag = (int64_t)(plat_now_ns() - v->shown0_ns) - (ts - v->ts0);
if (!v->rel_on) v->rel_lag0 = v->rel_lag, v->rel_on = 1;
}
}
update_events(v);
plat_mutex_unlock(&v->lock);
}

View File

@@ -28,7 +28,8 @@ void rt_patch_switch(int set, int on) { (void)set, (void)on; }
void *rt_patch_page(void) { return page; }
void rt_log(const char *fmt, ...) { (void)fmt; }
double gfx_window_refresh_hz(void) { return 144; }
int ov_movie_live(void) { return 0; }
static int movie; // a movie plays (frame_rate.c movie_cap_hz)
int ov_movie_live(void) { return movie; }
int vk_fg_generating(void) { return 0; }
float dbg_speed_factor(void) { return 1.0f; } // the debug menu's game speed: normal
int vk_fg_active(void) { return 0; }
@@ -128,6 +129,21 @@ int main(int argc, char **argv) {
for (int i = 0; i < 12; i++) f = present(2 * p240);
CHECK(f > 0.49f, "240 -> 120 fps: %.4f frames after 12", f);
// A movie started at 240 fps: exactly one frame from its first update, late and short frames included, as the game
// shows one picture per update; at its end the step follows the frame limit again within 12 frames.
set_limit("unlimited");
for (int i = 0; i < 300; i++) present(p240);
movie = 1;
for (int i = 0; i < 600; i++) {
float g = present(i % 120 == 7 ? 0.070 : i % 120 == 8 ? 0.004 : p60);
CHECK(g == 1.0f, "movie, frame %d: %.6f frames", i, g);
}
CHECK(frame_step_movie_hz() == 60, "the movie's rate: %d", frame_step_movie_hz());
movie = 0;
float after = 0;
for (int i = 0; i < 12; i++) after = present(p240);
CHECK(fabsf(after - 0.25f) < 0.002f, "after the movie: %.4f frames", after);
// A frame longer than 1/15 s slows the game down; the step never leaves 1/480..1/15 s.
set_limit("unlimited");
for (int i = 0; i < 100; i++) {