Merge pull request #423 from RetroPortingToolKit/feat/frame-rate-blend

Add a flip-aware frame-interpolation source (opt-in)
This commit is contained in:
Matt Stanley
2026-09-29 11:21:11 -07:00
committed by GitHub
13 changed files with 594 additions and 11 deletions
+68
View File
@@ -0,0 +1,68 @@
# 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, 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).
+1
View File
@@ -886,6 +886,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
+43
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,49 @@ 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);
/* 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);
#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
@@ -7898,12 +7898,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
+59
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,44 @@ 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;
}
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;
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);
@@ -4023,6 +4034,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) {
@@ -4062,6 +4077,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;
}
@@ -4081,11 +4114,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 = 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,
&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;
@@ -4191,9 +4245,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(
@@ -4546,8 +4601,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. */
@@ -4652,8 +4709,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{};
@@ -1465,6 +1468,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;
}
/* The disc a session mounts: a disc-patching mod's private patched image when
@@ -1564,6 +1568,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,
@@ -15442,6 +15461,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
@@ -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")
@@ -31,7 +31,221 @@ 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)");
}
}
}
/* 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},
{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_flip_new_frame();
test_phase_windows();
test_flip_rates();
FrameInterpolationSchedule schedule = {0};
float first = 0.0f, last = 0.0f;
uint64_t end;
+1
View File
@@ -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){
@@ -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)