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 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-09-26 04:09:14 -07:00
parent 7cbe03b148
commit 5ffd57d7f3
9 changed files with 401 additions and 11 deletions
+35
View File
@@ -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
+6
View File
@@ -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,
+15
View File
@@ -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
+11 -2
View File
@@ -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
+52
View File
@@ -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;
}
+68 -9
View File
@@ -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();
+20
View File
@@ -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<int, PSX_MAX_PLAYERS> g_mod_controller_mode_override =
[] {
std::array<int, PSX_MAX_PLAYERS> 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
@@ -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;
@@ -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)