mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
Merge pull request #423 from RetroPortingToolKit/feat/frame-rate-blend
Add a flip-aware frame-interpolation source (opt-in)
This commit is contained in:
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user