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)