From 5ffd57d7f3427ef0720021fe704bf6f23496afd5 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:09:14 -0700 Subject: [PATCH 1/4] Flip-aware frame-interpolation source psx_mod_set_frame_interpolation_source(PSX_MOD_FRAME_SOURCE_FLIP) makes the OpenGL presenter rotate its blend history only when the guest really flips (the displayed VRAM origin moves or the displayed rect is redrawn) and spread one crossfade over the measured flip period. A game that flips every second VBlank (R4 races) used to blend for one VBlank and hold for the next; it now blends across its whole frame. VBLANK stays the default. reset_mod_owned_presentation() puts it back at every session start (first boot and the lobby rematch), next to the blend mode, so titles that never call the setter keep today's code path and a netplay session never inherits it. begin() is begin_phase(0, 1) and stays bit-identical (test). gl_interp reports the source, the flip period, captures and duplicate VBlanks. Co-Authored-By: Claude Opus 5.5 --- runtime/include/frame_interpolation.h | 35 ++++ runtime/include/gpu_gl_renderer.h | 6 + runtime/include/mod_plugins.h | 15 ++ runtime/src/debug_server.c | 13 +- runtime/src/frame_interpolation.c | 52 +++++ runtime/src/gpu_gl_renderer.c | 77 ++++++- runtime/src/main.cpp | 20 ++ .../tests/test_frame_interpolation_schedule.c | 193 ++++++++++++++++++ .../tests/test_mod_owned_display_controls.py | 1 + 9 files changed, 401 insertions(+), 11 deletions(-) diff --git a/runtime/include/frame_interpolation.h b/runtime/include/frame_interpolation.h index a60e453d..31fd8909 100644 --- a/runtime/include/frame_interpolation.h +++ b/runtime/include/frame_interpolation.h @@ -13,6 +13,10 @@ typedef struct FrameInterpolationSchedule { double frame_start; double frame_end; double target_period; + /* Blend-phase window of this source interval (begin_phase). begin() + * plans the whole 0..1 window, which is the historical behaviour. */ + double phase_lo; + double phase_hi; } FrameInterpolationSchedule; /* Plan one stock guest-frame interval. Presentation deadlines remain anchored @@ -29,10 +33,41 @@ int frame_interpolation_schedule_next(FrameInterpolationSchedule *schedule, uint64_t now, uint64_t *deadline, float *alpha); +/* Same as begin(), but the returned blend alphas cover only [phase_lo, + * phase_hi] of the frame-to-frame transition (both clamped to 0..1). A game + * that flips every P VBlanks presents VBlank k (0-based since the flip) with + * the window [k/P, (k+1)/P], so one crossfade spans the whole game frame + * instead of finishing after the first VBlank and holding for the rest. + * begin() is begin_phase(0, 1). */ +int frame_interpolation_schedule_begin_phase(FrameInterpolationSchedule *schedule, + uint64_t now, uint64_t frequency, + double source_hz, double target_hz, + double phase_lo, double phase_hi); + uint64_t frame_interpolation_schedule_end( const FrameInterpolationSchedule *schedule); void frame_interpolation_schedule_reset(FrameInterpolationSchedule *schedule); +/* Flip-aware blend source (psx_mod_set_frame_interpolation_source FLIP). + * Pure bookkeeping, one call per presented guest VBlank: new_frame says the + * displayed image changed (display origin moved, or the displayed rect was + * redrawn). Tracks P, the last observed flip period in VBlanks (clamped + * 1..FRAME_FLIP_PERIOD_MAX; 1 until two new frames were seen), and k, the + * VBlanks since the last new frame, and yields this VBlank's blend window + * [k/P, (k+1)/P] clamped to 0..1. A frame that arrives late holds at 1; one + * that arrives early restarts at 0 from the frame that was fully shown. */ +#define FRAME_FLIP_PERIOD_MAX 4u +typedef struct FrameFlipTracker { + uint32_t since_flip; /* VBlanks since the last new frame, before this one */ + uint32_t period; /* P */ + uint32_t frames; /* new frames seen (saturating) */ +} FrameFlipTracker; + +void frame_flip_tracker_reset(FrameFlipTracker *tracker); +/* Returns k for this VBlank. */ +uint32_t frame_flip_tracker_vblank(FrameFlipTracker *tracker, int new_frame, + double *phase_lo, double *phase_hi); + #ifdef __cplusplus } #endif diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index e9ad1263..7cf709f8 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -33,6 +33,12 @@ void gl_renderer_set_swap_interval(int interval); void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, double source_hz, int blend_mode); void gl_renderer_set_interpolation_suspended(int suspended); +/* Blend source: 0 = every guest VBlank is a source frame (default), + * 1 = only real display flips are (psx_mod_set_frame_interpolation_source). */ +void gl_renderer_set_interpolation_source(int source); +void gl_renderer_interpolation_source_diag(int *source, uint32_t *flip_period, + uint64_t *captures, + uint64_t *duplicates); int gl_renderer_interpolation_owns_cadence(void); void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index c8f4efd1..9288b945 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -239,6 +239,21 @@ enum { PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE = 1 }; int psx_mod_set_frame_interpolation_blend(uint32_t blend_mode); +/* + * Choose what the OpenGL presenter treats as a new source frame. VBLANK (the + * default, reset at every session start) treats every guest VBlank as one, + * which suits games that flip every VBlank. FLIP rotates the blend history + * only when the guest really flips (the displayed VRAM origin moves, or the + * displayed rect is redrawn) and spreads each crossfade over the measured + * flip period (1..4 VBlanks). A 30 Hz game then blends across its whole frame + * instead of blending for one VBlank and holding for the next. Guest timing + * is unchanged either way. + */ +enum { + PSX_MOD_FRAME_SOURCE_VBLANK = 0, + PSX_MOD_FRAME_SOURCE_FLIP = 1 +}; +int psx_mod_set_frame_interpolation_source(uint32_t source); int psx_mod_set_auto_skip_fmv(int enabled); /* * Draw still artwork behind the game image in OpenGL letterbox/pillarbox diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 655d03f4..37eecef1 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7934,12 +7934,21 @@ static void handle_gl_interp(int id, const char *json) int enabled = 0, suspended = 0, history = 0; double host_hz = 0.0, target_hz = 0.0; uint64_t swaps = 0; + int source = 0; + uint32_t flip_period = 0; + uint64_t captures = 0, duplicates = 0; gl_renderer_interpolation_diag(&enabled, &suspended, &history, &host_hz, &target_hz, &swaps); + gl_renderer_interpolation_source_diag(&source, &flip_period, &captures, + &duplicates); send_fmt("{\"id\":%d,\"ok\":true,\"enabled\":%d,\"suspended\":%d,\"history\":%d," - "\"host_hz\":%.3f,\"target_hz\":%.3f,\"swaps\":%llu}", + "\"host_hz\":%.3f,\"target_hz\":%.3f,\"swaps\":%llu," + "\"source\":\"%s\",\"flip_period\":%u,\"captures\":%llu," + "\"duplicates\":%llu}", id, enabled, suspended, history, host_hz, target_hz, - (unsigned long long)swaps); + (unsigned long long)swaps, source ? "flip" : "vblank", + (unsigned)flip_period, (unsigned long long)captures, + (unsigned long long)duplicates); } /* gl_wide_fast on=<0|1>: native-wide centre-blit fast path. 1 (default) = skip diff --git a/runtime/src/frame_interpolation.c b/runtime/src/frame_interpolation.c index 8cbeb7ab..3be239fc 100644 --- a/runtime/src/frame_interpolation.c +++ b/runtime/src/frame_interpolation.c @@ -10,6 +10,19 @@ void frame_interpolation_schedule_reset(FrameInterpolationSchedule *schedule) { int frame_interpolation_schedule_begin(FrameInterpolationSchedule *schedule, uint64_t now, uint64_t frequency, double source_hz, double target_hz) { + return frame_interpolation_schedule_begin_phase( + schedule, now, frequency, source_hz, target_hz, 0.0, 1.0); +} + +static double clamp01(double v) { + if (!(v > 0.0)) return 0.0; /* also maps NaN to 0 */ + return v > 1.0 ? 1.0 : v; +} + +int frame_interpolation_schedule_begin_phase(FrameInterpolationSchedule *schedule, + uint64_t now, uint64_t frequency, + double source_hz, double target_hz, + double phase_lo, double phase_hi) { double source_period; double target_period; double now_d = (double)now; @@ -44,6 +57,10 @@ int frame_interpolation_schedule_begin(FrameInterpolationSchedule *schedule, schedule->frame_end = schedule->source_deadline; schedule->target_period = target_period; + schedule->phase_lo = clamp01(phase_lo); + schedule->phase_hi = clamp01(phase_hi); + if (schedule->phase_hi < schedule->phase_lo) + schedule->phase_hi = schedule->phase_lo; /* Coalesce stale output deadlines. Keep the newest missed deadline so an * over-budget guest frame can update the window once, then resume cadence. */ @@ -72,6 +89,7 @@ int frame_interpolation_schedule_next(FrameInterpolationSchedule *schedule, a = span > 0.0 ? (d - schedule->frame_start) / span : 1.0; if (a < 0.0) a = 0.0; if (a > 1.0) a = 1.0; + a = schedule->phase_lo + a * (schedule->phase_hi - schedule->phase_lo); if (deadline) *deadline = (uint64_t)(d + 0.5); if (alpha) *alpha = (float)a; return 1; @@ -82,3 +100,37 @@ uint64_t frame_interpolation_schedule_end( if (!schedule || schedule->frame_end <= 0.0) return 0; return (uint64_t)(schedule->frame_end + 0.5); } + +void frame_flip_tracker_reset(FrameFlipTracker *tracker) { + if (!tracker) return; + tracker->since_flip = 0; + tracker->period = 1; + tracker->frames = 0; +} + +uint32_t frame_flip_tracker_vblank(FrameFlipTracker *tracker, int new_frame, + double *phase_lo, double *phase_hi) { + uint32_t k, p; + if (!tracker) { + if (phase_lo) *phase_lo = 0.0; + if (phase_hi) *phase_hi = 1.0; + return 0; + } + if (tracker->period == 0) tracker->period = 1; + if (new_frame) { + if (tracker->frames > 0) { + p = tracker->since_flip; + if (p < 1) p = 1; + if (p > FRAME_FLIP_PERIOD_MAX) p = FRAME_FLIP_PERIOD_MAX; + tracker->period = p; + } + if (tracker->frames < 0xFFFFFFFFu) tracker->frames++; + tracker->since_flip = 0; + } + k = tracker->since_flip; + p = tracker->period; + if (phase_lo) *phase_lo = clamp01((double)k / (double)p); + if (phase_hi) *phase_hi = clamp01((double)(k + 1u) / (double)p); + if (tracker->since_flip < 0xFFFFu) tracker->since_flip++; + return k; +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 7ef91e6f..9a62bb7b 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -353,6 +353,17 @@ static double s_interp_host_hz = 0.0; static double s_interp_target_hz = 0.0; static double s_interp_source_hz = 0.0; static FrameInterpolationSchedule s_interp_schedule; +/* Blend source (psx_mod_set_frame_interpolation_source). VBLANK (0, the + * default) treats every guest VBlank present as a new frame, exactly as + * before. FLIP (1) rotates history only on a real flip -- the display origin + * moved or the displayed rect was redrawn -- and spreads one crossfade over + * the measured flip period, so 30 Hz content blends for the whole frame + * instead of blending for one VBlank and holding for the next. */ +static int s_interp_source = 0; +static FrameFlipTracker s_interp_flip; +static int s_interp_origin_x = -1, s_interp_origin_y = -1; +static double s_interp_phase_lo = 0.0, s_interp_phase_hi = 1.0; +static uint64_t s_interp_duplicates = 0; static void interp_reset_history(void); static int interp_present(float alpha); static void interp_present_source_interval(void); @@ -3963,6 +3974,10 @@ static void interp_reset_history_unlocked(void) { s_interp_w = s_interp_h = 0; s_interp_source_path = -1; frame_interpolation_schedule_reset(&s_interp_schedule); + frame_flip_tracker_reset(&s_interp_flip); + s_interp_origin_x = s_interp_origin_y = -1; + s_interp_phase_lo = 0.0; + s_interp_phase_hi = 1.0; } static void interp_reset_history(void) { @@ -4002,6 +4017,24 @@ void gl_renderer_set_interpolation_suspended(int suspended) { s_interp_suspended = suspended; } +void gl_renderer_set_interpolation_source(int source) { + source = source == 1 ? 1 : 0; + if (source != s_interp_source) interp_reset_history_unlocked(); + s_interp_source = source; + if (s_interp_enabled && source) + fprintf(stdout, "psxrecomp: GL temporal blending follows guest frame " + "flips (flip-aware source)\n"); +} + +void gl_renderer_interpolation_source_diag(int *source, uint32_t *flip_period, + uint64_t *captures, + uint64_t *duplicates) { + if (source) *source = s_interp_source; + if (flip_period) *flip_period = s_interp_flip.period; + if (captures) *captures = s_interp_captures; + if (duplicates) *duplicates = s_interp_duplicates; +} + int gl_renderer_interpolation_owns_cadence(void) { return s_ctx && s_interp_enabled && !s_interp_suspended; } @@ -4021,11 +4054,32 @@ void gl_renderer_interpolation_diag(int *enabled, int *suspended, /* Copy a stable display image out of the mutable VRAM/wide render target. * Returns true when temporal blending owns this source-frame interval. */ static int interp_capture(GLuint fbo, int x, int y, int w, int h, - int linear, int force_4_3, int source_path) { + int linear, int force_4_3, int source_path, + int origin_x, int origin_y, int redrawn) { if (!s_interp_enabled || s_interp_suspended || !fbo || w <= 0 || h <= 0) return 0; int pw = w * s_scale, ph = h * s_scale; - if (pw != s_interp_w || ph != s_interp_h || - source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3) { + int geometry_changed = + pw != s_interp_w || ph != s_interp_h || + source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3; + if (s_interp_source == 1) { + /* FLIP source: a VBlank that re-presents the same displayed image is + * not a new source frame. Keep history and advance the phase window. */ + int new_frame = geometry_changed || s_interp_valid == 0 || redrawn || + origin_x != s_interp_origin_x || + origin_y != s_interp_origin_y; + s_interp_origin_x = origin_x; + s_interp_origin_y = origin_y; + (void)frame_flip_tracker_vblank(&s_interp_flip, new_frame, + &s_interp_phase_lo, &s_interp_phase_hi); + if (!new_frame) { + s_interp_duplicates++; + return 1; + } + } else { + s_interp_phase_lo = 0.0; + s_interp_phase_hi = 1.0; + } + if (geometry_changed) { s_interp_valid = 0; s_interp_w = pw; s_interp_h = ph; s_interp_prev = s_interp_cur = 0; @@ -4131,9 +4185,10 @@ static void interp_present_source_interval(void) { uint64_t deadline; float alpha; - if (!frame_interpolation_schedule_begin( + if (!frame_interpolation_schedule_begin_phase( &s_interp_schedule, now, frequency, - s_interp_source_hz, s_interp_target_hz)) + s_interp_source_hz, s_interp_target_hz, + s_interp_phase_lo, s_interp_phase_hi)) return; while (frame_interpolation_schedule_next( @@ -4486,8 +4541,10 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, if (lx != 0 || ly != 0 || lw != ww || lh != wh) { glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); } - int interp_pair = interp_capture(s_hr_fbo, disp_x, disp_y, w, h, - linear, force_4_3, GL_PRES_VRAM); + int interp_pair = interp_capture( + s_hr_fbo, disp_x, disp_y, w, h, linear, force_4_3, GL_PRES_VRAM, + disp_x, disp_y, + present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1)); if (interp_pair) { /* Temporal blending owns this stock frame interval. Capture hold-last * before pacing; every blend and Swap remains on this context/thread. */ @@ -4592,8 +4649,10 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) if (lx != 0 || ly != 0 || lw != ww || lh != wh) { glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); } - int interp_pair = interp_capture(fbo, 0, disp_y, g_wide_w, disp_h, - linear, 0, GL_PRES_WIDE); + int interp_pair = interp_capture( + fbo, 0, disp_y, g_wide_w, disp_h, linear, 0, GL_PRES_WIDE, + disp_x, disp_y, + present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1)); if (interp_pair) { hold_capture_native_fbo(fbo, 0, disp_y, g_wide_w, disp_h, 0, linear); interp_present_source_interval(); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 716829f7..29134adf 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1268,6 +1268,9 @@ static int g_frame_interpolation_blend = PSX_MOD_FRAME_INTERPOLATION_LINEAR; static int g_frame_interpolation_blend_default = PSX_MOD_FRAME_INTERPOLATION_LINEAR; +/* Mod-owned blend source; reset_mod_owned_presentation() sets VBLANK. */ +static int g_frame_interpolation_source = + PSX_MOD_FRAME_SOURCE_VBLANK; static std::array g_mod_controller_mode_override = [] { std::array modes{}; @@ -1468,6 +1471,7 @@ static void reset_mod_owned_presentation(void) { psx_mod_set_adaptive_backdrop_preload(0); g_bezel_path.clear(); g_frame_interpolation_blend = g_frame_interpolation_blend_default; + g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK; } extern "C" int psx_mod_set_native_vblank_rate( @@ -1554,6 +1558,21 @@ extern "C" int psx_mod_set_frame_interpolation_blend( return 1; } +extern "C" int psx_mod_set_frame_interpolation_source(uint32_t source) { + if (source != PSX_MOD_FRAME_SOURCE_VBLANK && + source != PSX_MOD_FRAME_SOURCE_FLIP) { + std::fprintf(stderr, + "psxrecomp: mod rejected invalid frame-interpolation source %u\n", + (unsigned)source); + return 0; + } + g_frame_interpolation_source = (int)source; + std::fprintf(stdout, "psxrecomp: frame-interpolation source = %s\n", + source == PSX_MOD_FRAME_SOURCE_FLIP + ? "guest frame flips" : "every guest VBlank"); + return 1; +} + extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { if (enabled != 0 && enabled != 1) { std::fprintf(stderr, @@ -15427,6 +15446,7 @@ session_reboot: ? 1000.0 / g_frame_period_ms : 59.94, g_frame_interpolation_blend); + gl_renderer_set_interpolation_source(g_frame_interpolation_source); } /* Vulkan backend: create the instance/device/swapchain on the * SDL_WINDOW_VULKAN window. On failure, fall back to software (vkb_init diff --git a/runtime/tests/test_frame_interpolation_schedule.c b/runtime/tests/test_frame_interpolation_schedule.c index a748e6a2..35b2400d 100644 --- a/runtime/tests/test_frame_interpolation_schedule.c +++ b/runtime/tests/test_frame_interpolation_schedule.c @@ -31,7 +31,200 @@ static int run_interval(FrameInterpolationSchedule *schedule, uint64_t now, return count; } +/* Drive `ticks` 30 Hz game frames (flip every `period` VBlanks) through the + * flip tracker + phase-window scheduler at a 59.94 Hz VBlank clock, as the GL + * presenter does in FLIP mode. Returns the number of presents; checks that + * alpha is monotonic inside each game frame and ends at 1. */ +static int run_flip_ticks(double target_hz, uint32_t period, int ticks, + int *min_per_tick, int *max_per_tick) { + FrameInterpolationSchedule schedule = {0}; + FrameFlipTracker tracker; + const uint64_t freq = 1000000000u; /* ns */ + const double vblank_hz = 59.94; + uint64_t now = 5000000000u; + int total = 0; + frame_flip_tracker_reset(&tracker); + if (min_per_tick) *min_per_tick = 1 << 30; + if (max_per_tick) *max_per_tick = 0; + for (int t = 0; t < ticks + 2; t++) { + float prev_alpha = -1.0f, last_alpha = -1.0f; + int in_tick = 0; + for (uint32_t v = 0; v < period; v++) { + double lo, hi; + uint64_t deadline; + float alpha; + uint32_t k = frame_flip_tracker_vblank(&tracker, v == 0, &lo, &hi); + CHECK(k == v, "tracker k must count VBlanks since the flip"); + CHECK(frame_interpolation_schedule_begin_phase( + &schedule, now, freq, vblank_hz, target_hz, lo, hi), + "phase interval should plan"); + while (frame_interpolation_schedule_next(&schedule, now, &deadline, + &alpha)) { + if (t >= 2) { /* after the period estimate settles */ + CHECK(alpha + 1e-6f >= prev_alpha, + "alpha must be monotonic within one game frame"); + CHECK(alpha >= (float)lo - 1e-6f && + alpha <= (float)hi + 1e-6f, + "alpha must stay inside the VBlank's phase window"); + } + prev_alpha = last_alpha = alpha; + in_tick++; + now = deadline; + } + now = frame_interpolation_schedule_end(&schedule); + } + if (t >= 2) { + /* Output deadlines are a free-running grid, so the last present + * of a game frame lands within one output period of its end. */ + double one_output = (vblank_hz / (double)period) / target_hz; + CHECK(last_alpha >= (float)(1.0 - one_output) - 1e-4f, + "the last present of a game frame should be within one " + "output period of alpha 1"); + total += in_tick; + if (min_per_tick && in_tick < *min_per_tick) *min_per_tick = in_tick; + if (max_per_tick && in_tick > *max_per_tick) *max_per_tick = in_tick; + } + } + return total; +} + +static void test_flip_tracker_table(void) { + /* new_frame sequence -> expected (k, period after the call). */ + static const struct { int new_frame; uint32_t k; uint32_t period; } steps[] = { + {1, 0, 1}, /* first frame: period unknown, assume 1 */ + {0, 1, 1}, /* late: k >= P holds at alpha 1 */ + {1, 0, 2}, /* second flip after 2 VBlanks: P = 2 */ + {0, 1, 2}, + {1, 0, 2}, + {1, 0, 1}, /* back to 60 Hz */ + {1, 0, 1}, + {0, 1, 1}, + {0, 2, 1}, + {1, 0, 3}, /* 30 -> 20 Hz */ + {0, 1, 3}, + {0, 2, 3}, + {1, 0, 3}, + {0, 1, 3}, {0, 2, 3}, {0, 3, 3}, {0, 4, 3}, {0, 5, 3}, {0, 6, 3}, + {1, 0, FRAME_FLIP_PERIOD_MAX}, /* long hold clamps to the max */ + }; + FrameFlipTracker tracker; + frame_flip_tracker_reset(&tracker); + for (size_t i = 0; i < sizeof steps / sizeof steps[0]; i++) { + double lo = -1.0, hi = -1.0; + uint32_t k = frame_flip_tracker_vblank(&tracker, steps[i].new_frame, + &lo, &hi); + char msg[96]; + snprintf(msg, sizeof msg, "flip tracker step %u: k", (unsigned)i); + CHECK(k == steps[i].k, msg); + snprintf(msg, sizeof msg, "flip tracker step %u: period", (unsigned)i); + CHECK(tracker.period == steps[i].period, msg); + double elo = (double)k / (double)tracker.period; + double ehi = (double)(k + 1) / (double)tracker.period; + if (elo > 1.0) elo = 1.0; + if (ehi > 1.0) ehi = 1.0; + snprintf(msg, sizeof msg, "flip tracker step %u: window", (unsigned)i); + CHECK(fabs(lo - elo) < 1e-9 && fabs(hi - ehi) < 1e-9, msg); + } +} + +static void test_phase_windows(void) { + FrameInterpolationSchedule schedule = {0}; + uint64_t deadline; + float alpha, first = -1.0f, last = -1.0f; + int n = 0; + /* P = 2, second VBlank of the frame: 240 Hz gives four presents that + * cover (0.5, 1]. */ + CHECK(frame_interpolation_schedule_begin_phase( + &schedule, 1000000u, 1000000u, 60.0, 240.0, 0.5, 1.0), + "phase window should plan"); + while (frame_interpolation_schedule_next(&schedule, 1000000u, &deadline, + &alpha)) { + if (n == 0) first = alpha; + last = alpha; + n++; + } + CHECK(n == 4, "240 Hz over one 60 Hz VBlank should present four times"); + CHECK(fabsf(first - 0.625f) < 1e-3f && fabsf(last - 1.0f) < 1e-3f, + "second-half window should map to 0.625 .. 1"); + + /* P = 3 windows are thirds. */ + frame_interpolation_schedule_reset(&schedule); + CHECK(frame_interpolation_schedule_begin_phase( + &schedule, 2000000u, 1000000u, 60.0, 120.0, 1.0 / 3.0, 2.0 / 3.0), + "third window should plan"); + n = 0; + while (frame_interpolation_schedule_next(&schedule, 2000000u, &deadline, + &alpha)) { + if (n == 0) first = alpha; + last = alpha; + n++; + } + CHECK(n == 2 && fabsf(first - 0.5f) < 1e-3f && + fabsf(last - 2.0f / 3.0f) < 1e-3f, + "middle third at 120 Hz should present 1/2 then 2/3"); + + /* Degenerate and reversed windows clamp instead of extrapolating. */ + frame_interpolation_schedule_reset(&schedule); + CHECK(frame_interpolation_schedule_begin_phase( + &schedule, 3000000u, 1000000u, 60.0, 120.0, 1.5, 0.25), + "clamped window should still plan"); + while (frame_interpolation_schedule_next(&schedule, 3000000u, &deadline, + &alpha)) + CHECK(alpha == 1.0f, "a window past the frame must hold at alpha 1"); + + /* begin() is the full window: identical alphas to begin_phase(0, 1). */ + { + FrameInterpolationSchedule a = {0}, b = {0}; + float aa, ab; + uint64_t da, db; + CHECK(frame_interpolation_schedule_begin(&a, 4000000u, 1000000u, + 60.0, 165.0) && + frame_interpolation_schedule_begin_phase(&b, 4000000u, 1000000u, + 60.0, 165.0, 0.0, 1.0), + "begin and begin_phase(0,1) should both plan"); + for (;;) { + int ra = frame_interpolation_schedule_next(&a, 4000000u, &da, &aa); + int rb = frame_interpolation_schedule_next(&b, 4000000u, &db, &ab); + CHECK(ra == rb, "begin and begin_phase(0,1) present equally often"); + if (!ra || !rb) break; + CHECK(aa == ab && da == db, + "begin must stay bit-identical to begin_phase(0,1)"); + } + } +} + +static void test_flip_rates(void) { + static const struct { double hz; int lo; int hi; } rates[] = { + {60.0, 2, 3}, {100.0, 3, 4}, {120.0, 4, 5}, {200.0, 6, 7}, + {240.0, 8, 9}, {300.0, 10, 11}, + }; + for (size_t i = 0; i < sizeof rates / sizeof rates[0]; i++) { + int mn = 0, mx = 0; + const int ticks = 60; + int total = run_flip_ticks(rates[i].hz, 2u, ticks, &mn, &mx); + double expect = rates[i].hz / (59.94 / 2.0) * ticks; + char msg[128]; + snprintf(msg, sizeof msg, + "%.0f Hz over 30 Hz frames: %d presents (expect ~%.1f), " + "%d..%d per frame", + rates[i].hz, total, expect, mn, mx); + CHECK(fabs((double)total - expect) <= 2.0, msg); + CHECK(mn >= rates[i].lo && mx <= rates[i].hi, msg); + } + /* A 60 Hz game (P = 1) keeps today's one-crossfade-per-VBlank shape. */ + { + int mn = 0, mx = 0; + int total = run_flip_ticks(120.0, 1u, 60, &mn, &mx); + CHECK(total == 120 && mn == 2 && mx == 2, + "120 Hz over a 60 Hz game should present twice per frame"); + } +} + int main(void) { + test_flip_tracker_table(); + test_phase_windows(); + test_flip_rates(); + FrameInterpolationSchedule schedule = {0}; float first = 0.0f, last = 0.0f; uint64_t end; diff --git a/runtime/tests/test_mod_owned_display_controls.py b/runtime/tests/test_mod_owned_display_controls.py index ddfb4f9e..0ff12595 100644 --- a/runtime/tests/test_mod_owned_display_controls.py +++ b/runtime/tests/test_mod_owned_display_controls.py @@ -126,6 +126,7 @@ for reset in ( "psx_mod_set_adaptive_backdrop_preload(0);", "g_bezel_path.clear();", "g_frame_interpolation_blend = g_frame_interpolation_blend_default;", + "g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK;", ): assert reset in after, f"mod-owned session reset is missing: {reset}" literal = re.fullmatch(r"(g_\w+) = (false|true|\d+);", reset) From ec6bd41f1a1801809f0bf52a8c71185b73ac804f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:04:37 -0700 Subject: [PATCH 2/4] docs: frame rate presentation, blend modes and the flip-aware source Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_RATE.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/FRAME_RATE.md diff --git a/docs/FRAME_RATE.md b/docs/FRAME_RATE.md new file mode 100644 index 00000000..a660e569 --- /dev/null +++ b/docs/FRAME_RATE.md @@ -0,0 +1,64 @@ +# Frame rate: presentation above the guest's cadence + +A game-owned mod can present a PS1 title at a higher rate than it renders +(60, 120, 240 Hz, the display's refresh, ...) without changing how fast the +game runs. Guest VBlanks, timers, CD, SPU and game logic stay at their stock +cadence; only what the OpenGL presenter shows between guest VBlanks changes. +Never use `psx_mod_set_native_vblank_rate` for this: it speeds up the whole +machine. + +## Enabling it (mod_plugins.h) + +From a trusted plugin's activation callback: + +```c +psx_mod_set_frame_interpolation_source(PSX_MOD_FRAME_SOURCE_FLIP); /* optional */ +psx_mod_set_frame_interpolation_blend(PSX_MOD_FRAME_INTERPOLATION_LINEAR); +psx_mod_set_frame_interpolation(120); /* 0 = follow the display refresh */ +``` + +`psx_mod_set_frame_interpolation` accepts 0 or 60..1000, selects the OpenGL +renderer and turns vsync off (the presenter paces itself with a sleep+spin +schedule on the emulation thread). Blend and source are reset to their +defaults at every session start (first boot and the lobby rematch), so a +netplay session or a later offline one never inherits them. + +## Blend modes + +| Mode | Output between two source frames | +|---|---| +| `LINEAR` (default) | crossfade `mix(prev, cur, a)` | +| `MOTION_ADAPTIVE` | crossfade small changes, switch large changes at a = 0.5 | + +Blending never invents motion: moving objects ghost, and the image is one +source frame behind the newest one. + +## Blend source: every VBlank or real flips + +The presenter plans one output schedule per guest VBlank present. + +- `PSX_MOD_FRAME_SOURCE_VBLANK` (default, and the historical behaviour): + every guest VBlank is a new source frame. Right for games that flip every + VBlank. +- `PSX_MOD_FRAME_SOURCE_FLIP`: a VBlank is a new source frame only when the + game really flipped -- the displayed VRAM origin moved, or the displayed + rect was redrawn. The presenter tracks the flip period P (VBlanks between + flips, clamped 1..4) and gives VBlank k after a flip the blend window + [k/P, (k+1)/P], so one crossfade spans the whole game frame. A 30 Hz game + (P = 2) would otherwise blend for one VBlank and hold for the next. A frame + that arrives late holds at a = 1; one that arrives early restarts from the + frame that was fully shown. + +`gl_interp` (TCP) reports `source`, `flip_period`, `captures` (new frames) +and `duplicates` (VBlanks that re-presented the same frame); a 30 Hz game in +FLIP mode shows about 30 captures and 30 duplicates per second. + +## Limits + +- OpenGL only; a command-line `--renderer` override after activation drops + it. Netplay sessions run without mods, so without interpolation. +- FMV (MDEC / 24-bit) frames suspend it, as does rewind. +- Vsync is off: on a fixed-refresh panel pick the display refresh rate. + +Tests: `frame_interpolation_schedule_test` (runtime ctest) covers the phase +windows, the flip tracker and output counts per rate at 30 Hz. From 5dfb9a91eb4b7b0253597aca135a41a9e81f6ee1 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:30:30 -0700 Subject: [PATCH 3/4] GL readback fixture: stub frame_flip_tracker_reset test_gl_readback_region.c includes gpu_gl_renderer.c and stubs each external the renderer reaches, including frame_interpolation_schedule_reset from interp_reset_history_unlocked(). The flip-aware source resets its tracker in that same function, so the MinGW link (-flto, no unresolved symbols in a PE) would fail on frame_flip_tracker_reset. Stub it beside the schedule reset. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_gl_readback_region.c | 1 + 1 file changed, 1 insertion(+) diff --git a/runtime/tests/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index 9d9f733a..a62dfd07 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -19,6 +19,7 @@ int g_ws_tex_edge_pct=0; int psx_ws_prim_is_tagged(void){return 0;} void gpu_depth24_upload_span_reset(void){} void frame_interpolation_schedule_reset(FrameInterpolationSchedule *p){memset(p,0,sizeof(*p));} +void frame_flip_tracker_reset(FrameFlipTracker *p){memset(p,0,sizeof(*p));p->period=1;} static int checks,failures; static void check(int ok,const char *label){checks++;if(!ok){fprintf(stderr,"FAIL %s\n",label);failures++;}} static void verify(const char *label){ From fb832a9563083e6ba1e8ff5dbf7e164593f9ce2d Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:30:30 -0700 Subject: [PATCH 4/4] FLIP source: test the new-frame decision; guard its wiring interp_capture's FLIP decision moves into frame_flip_is_new_frame() (frame_interpolation.c), unit-tested in frame_interpolation_schedule_test. A new source guard, frame_interpolation_flip_source (recompiler ctest), pins what no unit test reaches: main.cpp hands the session's source to the renderer at every session start, right after its interpolation settings; interp_capture feeds the origin and the redraw test to the decision and the tracker and returns before the copy on a duplicate; both present paths pass the display origin and present_dirty_test; a source change restarts the history. Deleting the hand-off, or never returning on a duplicate, now fails the guard (both passed every test before). Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_RATE.md | 6 +- recompiler/CMakeLists.txt | 1 + runtime/include/frame_interpolation.h | 8 ++ runtime/src/frame_interpolation.c | 7 ++ runtime/src/gpu_gl_renderer.c | 6 +- .../test_frame_interpolation_flip_source.py | 87 +++++++++++++++++++ .../tests/test_frame_interpolation_schedule.c | 21 +++++ 7 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 runtime/tests/test_frame_interpolation_flip_source.py diff --git a/docs/FRAME_RATE.md b/docs/FRAME_RATE.md index a660e569..e951c0d0 100644 --- a/docs/FRAME_RATE.md +++ b/docs/FRAME_RATE.md @@ -61,4 +61,8 @@ FLIP mode shows about 30 captures and 30 duplicates per second. - Vsync is off: on a fixed-refresh panel pick the display refresh rate. Tests: `frame_interpolation_schedule_test` (runtime ctest) covers the phase -windows, the flip tracker and output counts per rate at 30 Hz. +windows, the flip tracker, the new-frame decision and output counts per rate +at 30 Hz. `frame_interpolation_flip_source` (source guard, recompiler ctest) +pins the wiring no unit test reaches: the session-start hand-off of the +source to the renderer, and `interp_capture`'s FLIP branch (origin and redraw +into the decision, an early return on a duplicate). diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index b58a379d..ca6c14d5 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -819,6 +819,7 @@ if(BUILD_TESTING) fmv_quiet_guards fps_telemetry_opt_in frame_interpolation_display_rate + frame_interpolation_flip_source hybrid_dev_any_input launcher_pad_mode_wiring jalr_link_register_guards diff --git a/runtime/include/frame_interpolation.h b/runtime/include/frame_interpolation.h index 31fd8909..ea72b2b2 100644 --- a/runtime/include/frame_interpolation.h +++ b/runtime/include/frame_interpolation.h @@ -64,6 +64,14 @@ typedef struct FrameFlipTracker { } FrameFlipTracker; void frame_flip_tracker_reset(FrameFlipTracker *tracker); +/* Whether a presented VBlank shows a new guest frame (the tracker's + * new_frame): the presented geometry changed, no frame is held yet, the + * displayed rect was redrawn since the last present, or the display origin + * moved from (last_x, last_y) to (origin_x, origin_y), i.e. a flip. Anything + * else re-presents the image already held and is a duplicate. */ +int frame_flip_is_new_frame(int geometry_changed, int history_empty, + int redrawn, int origin_x, int origin_y, + int last_x, int last_y); /* Returns k for this VBlank. */ uint32_t frame_flip_tracker_vblank(FrameFlipTracker *tracker, int new_frame, double *phase_lo, double *phase_hi); diff --git a/runtime/src/frame_interpolation.c b/runtime/src/frame_interpolation.c index 3be239fc..7fbe890d 100644 --- a/runtime/src/frame_interpolation.c +++ b/runtime/src/frame_interpolation.c @@ -108,6 +108,13 @@ void frame_flip_tracker_reset(FrameFlipTracker *tracker) { tracker->frames = 0; } +int frame_flip_is_new_frame(int geometry_changed, int history_empty, + int redrawn, int origin_x, int origin_y, + int last_x, int last_y) { + return geometry_changed || history_empty || redrawn || + origin_x != last_x || origin_y != last_y; +} + uint32_t frame_flip_tracker_vblank(FrameFlipTracker *tracker, int new_frame, double *phase_lo, double *phase_hi) { uint32_t k, p; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 9a62bb7b..d8ac66f1 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -4064,9 +4064,9 @@ static int interp_capture(GLuint fbo, int x, int y, int w, int h, if (s_interp_source == 1) { /* FLIP source: a VBlank that re-presents the same displayed image is * not a new source frame. Keep history and advance the phase window. */ - int new_frame = geometry_changed || s_interp_valid == 0 || redrawn || - origin_x != s_interp_origin_x || - origin_y != s_interp_origin_y; + int new_frame = frame_flip_is_new_frame( + geometry_changed, s_interp_valid == 0, redrawn, origin_x, origin_y, + s_interp_origin_x, s_interp_origin_y); s_interp_origin_x = origin_x; s_interp_origin_y = origin_y; (void)frame_flip_tracker_vblank(&s_interp_flip, new_frame, diff --git a/runtime/tests/test_frame_interpolation_flip_source.py b/runtime/tests/test_frame_interpolation_flip_source.py new file mode 100644 index 00000000..1f8e8c9e --- /dev/null +++ b/runtime/tests/test_frame_interpolation_flip_source.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Guard the FLIP frame-interpolation source's wiring (docs/FRAME_RATE.md). + +The pure pieces (frame_flip_is_new_frame, frame_flip_tracker_vblank, +begin_phase) are exercised by frame_interpolation_schedule_test. This guard +pins where they are wired in, which no unit test reaches: + +- main.cpp hands the session's source to the GL renderer at every session + start, right after the renderer's interpolation settings; +- interp_capture's FLIP branch asks frame_flip_is_new_frame with the display + origin and the redraw test, feeds the answer to the tracker, and returns + before the copy on a duplicate, so the history is not rotated; +- both present paths pass the display origin and the redraw test. + +Without the hand-off FLIP never reaches the renderer: frame blend stays per +VBlank, and render passes (which require the FLIP source) are always refused. +""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") +GL = (ROOT / "runtime" / "src" / "gpu_gl_renderer.c").read_text(encoding="utf-8") + + +def squash(text): + return re.sub(r"\s+", " ", text).strip() + + +def function_body(source, signature): + start = source.index(signature) + return source[start:source.index("\n}\n", start)] + + +# ---- session-start hand-off (main.cpp) -------------------------------------- +HANDOFF = "gl_renderer_set_interpolation_source(g_frame_interpolation_source);" +assert MAIN.count(HANDOFF) == 1, "the source hand-off must exist exactly once" +handoff_at = MAIN.index(HANDOFF) +reboot_at = MAIN.index("session_reboot:") +assert reboot_at < handoff_at, \ + "the hand-off must run at every session start (after session_reboot:)" +settings_at = MAIN.rindex("gl_renderer_set_interpolation(", 0, handoff_at) +assert reboot_at < settings_at, "hand-off must sit beside the session's GL setup" +between = MAIN[settings_at:handoff_at] +assert between.count(";") == 1 and between.rstrip().endswith( + "g_frame_interpolation_blend);"), \ + "the hand-off must directly follow the session's interpolation settings" + +setter = function_body(GL, "void gl_renderer_set_interpolation_source(int source) {") +assert "if (source != s_interp_source) interp_reset_history_unlocked();" in setter, \ + "a source change must restart the blend history" +assert "s_interp_source = source;" in setter + +# ---- interp_capture's FLIP branch (gpu_gl_renderer.c) ----------------------- +capture = function_body(GL, "static int interp_capture(GLuint fbo,") +flip_at = capture.index("if (s_interp_source == 1) {") +flip = capture[flip_at:capture.index("} else {", flip_at)] +flat = squash(flip) +assert squash("""int new_frame = frame_flip_is_new_frame( + geometry_changed, s_interp_valid == 0, redrawn, origin_x, origin_y, + s_interp_origin_x, s_interp_origin_y);""") in flat, \ + "FLIP must decide new frames with the tested helper, from origin and redraw" +assert "s_interp_origin_x = origin_x;" in flip and \ + "s_interp_origin_y = origin_y;" in flip, "the last origin must be kept" +assert squash("""frame_flip_tracker_vblank(&s_interp_flip, new_frame, + &s_interp_phase_lo, &s_interp_phase_hi);""") in flat, \ + "the tracker must see every presented VBlank with the decision" +assert squash("""if (!new_frame) { s_interp_duplicates++; return 1; }""") in flat, \ + "a duplicate must return before the history is rotated" +assert flip.index("frame_flip_is_new_frame(") < \ + flip.index("frame_flip_tracker_vblank(") < flip.index("if (!new_frame) {") +assert flip_at < capture.index("glCopyTexSubImage2D("), \ + "the duplicate return must precede the capture copy" + +# ---- present paths ---------------------------------------------------------- +calls = [squash(m.group(0)) for m in + re.finditer(r"interp_capture\(\s*[^;]*?\);", GL[GL.index("\n}\n", + GL.index("static int interp_capture(GLuint fbo,")):])] +assert len(calls) == 2, f"expected the VRAM and wide present calls, got {len(calls)}" +assert any("GL_PRES_VRAM, disp_x, disp_y, present_dirty_test(disp_x, disp_y," + in c for c in calls), "VRAM present must pass origin and redraw" +assert any("GL_PRES_WIDE, disp_x, disp_y, present_dirty_test(0, disp_y," + in c for c in calls), "wide present must pass origin and redraw" + +print("frame interpolation FLIP source wiring guard passed") diff --git a/runtime/tests/test_frame_interpolation_schedule.c b/runtime/tests/test_frame_interpolation_schedule.c index 35b2400d..8b508347 100644 --- a/runtime/tests/test_frame_interpolation_schedule.c +++ b/runtime/tests/test_frame_interpolation_schedule.c @@ -193,6 +193,26 @@ static void test_phase_windows(void) { } } +/* interp_capture's FLIP branch asks this whether a present is a new frame; + * a duplicate keeps the history and only advances the tracker's phase. */ +static void test_flip_new_frame(void) { + /* The same origin, nothing redrawn, a frame already held: duplicate. */ + CHECK(!frame_flip_is_new_frame(0, 0, 0, 0, 240, 0, 240), + "a re-presented image is a duplicate"); + CHECK(frame_flip_is_new_frame(0, 0, 0, 0, 0, 0, 240), + "the display origin moving in y is a flip"); + CHECK(frame_flip_is_new_frame(0, 0, 0, 320, 240, 0, 240), + "the display origin moving in x is a flip"); + CHECK(frame_flip_is_new_frame(0, 0, 1, 0, 240, 0, 240), + "a redraw of the displayed rect is a new frame"); + CHECK(frame_flip_is_new_frame(0, 1, 0, 0, 240, 0, 240), + "the first frame after a history reset is new"); + CHECK(frame_flip_is_new_frame(1, 0, 0, 0, 240, 0, 240), + "a presented-geometry change is a new frame"); + CHECK(frame_flip_is_new_frame(0, 0, 0, 0, 0, -1, -1), + "the first present after a reset (origin unknown) is new"); +} + static void test_flip_rates(void) { static const struct { double hz; int lo; int hi; } rates[] = { {60.0, 2, 3}, {100.0, 3, 4}, {120.0, 4, 5}, {200.0, 6, 7}, @@ -222,6 +242,7 @@ static void test_flip_rates(void) { int main(void) { test_flip_tracker_table(); + test_flip_new_frame(); test_phase_windows(); test_flip_rates();