diff --git a/docs/FRAME_RATE.md b/docs/FRAME_RATE.md new file mode 100644 index 00000000..e951c0d0 --- /dev/null +++ b/docs/FRAME_RATE.md @@ -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). diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index a73467be..87c23ba5 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -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 diff --git a/runtime/include/frame_interpolation.h b/runtime/include/frame_interpolation.h index a60e453d..ea72b2b2 100644 --- a/runtime/include/frame_interpolation.h +++ b/runtime/include/frame_interpolation.h @@ -13,6 +13,10 @@ typedef struct FrameInterpolationSchedule { double frame_start; double frame_end; double target_period; + /* Blend-phase window of this source interval (begin_phase). begin() + * plans the whole 0..1 window, which is the historical behaviour. */ + double phase_lo; + double phase_hi; } FrameInterpolationSchedule; /* Plan one stock guest-frame interval. Presentation deadlines remain anchored @@ -29,10 +33,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 diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index e9ad1263..7cf709f8 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -33,6 +33,12 @@ void gl_renderer_set_swap_interval(int interval); void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, double source_hz, int blend_mode); void gl_renderer_set_interpolation_suspended(int suspended); +/* Blend source: 0 = every guest VBlank is a source frame (default), + * 1 = only real display flips are (psx_mod_set_frame_interpolation_source). */ +void gl_renderer_set_interpolation_source(int source); +void gl_renderer_interpolation_source_diag(int *source, uint32_t *flip_period, + uint64_t *captures, + uint64_t *duplicates); int gl_renderer_interpolation_owns_cadence(void); void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index c8f4efd1..9288b945 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -239,6 +239,21 @@ enum { PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE = 1 }; int psx_mod_set_frame_interpolation_blend(uint32_t blend_mode); +/* + * Choose what the OpenGL presenter treats as a new source frame. VBLANK (the + * default, reset at every session start) treats every guest VBlank as one, + * which suits games that flip every VBlank. FLIP rotates the blend history + * only when the guest really flips (the displayed VRAM origin moves, or the + * displayed rect is redrawn) and spreads each crossfade over the measured + * flip period (1..4 VBlanks). A 30 Hz game then blends across its whole frame + * instead of blending for one VBlank and holding for the next. Guest timing + * is unchanged either way. + */ +enum { + PSX_MOD_FRAME_SOURCE_VBLANK = 0, + PSX_MOD_FRAME_SOURCE_FLIP = 1 +}; +int psx_mod_set_frame_interpolation_source(uint32_t source); int psx_mod_set_auto_skip_fmv(int enabled); /* * Draw still artwork behind the game image in OpenGL letterbox/pillarbox diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 59ddfdf8..3b9def17 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -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 diff --git a/runtime/src/frame_interpolation.c b/runtime/src/frame_interpolation.c index 8cbeb7ab..7fbe890d 100644 --- a/runtime/src/frame_interpolation.c +++ b/runtime/src/frame_interpolation.c @@ -10,6 +10,19 @@ void frame_interpolation_schedule_reset(FrameInterpolationSchedule *schedule) { int frame_interpolation_schedule_begin(FrameInterpolationSchedule *schedule, uint64_t now, uint64_t frequency, double source_hz, double target_hz) { + return frame_interpolation_schedule_begin_phase( + schedule, now, frequency, source_hz, target_hz, 0.0, 1.0); +} + +static double clamp01(double v) { + if (!(v > 0.0)) return 0.0; /* also maps NaN to 0 */ + return v > 1.0 ? 1.0 : v; +} + +int frame_interpolation_schedule_begin_phase(FrameInterpolationSchedule *schedule, + uint64_t now, uint64_t frequency, + double source_hz, double target_hz, + double phase_lo, double phase_hi) { double source_period; double target_period; double now_d = (double)now; @@ -44,6 +57,10 @@ int frame_interpolation_schedule_begin(FrameInterpolationSchedule *schedule, schedule->frame_end = schedule->source_deadline; schedule->target_period = target_period; + schedule->phase_lo = clamp01(phase_lo); + schedule->phase_hi = clamp01(phase_hi); + if (schedule->phase_hi < schedule->phase_lo) + schedule->phase_hi = schedule->phase_lo; /* Coalesce stale output deadlines. Keep the newest missed deadline so an * over-budget guest frame can update the window once, then resume cadence. */ @@ -72,6 +89,7 @@ int frame_interpolation_schedule_next(FrameInterpolationSchedule *schedule, a = span > 0.0 ? (d - schedule->frame_start) / span : 1.0; if (a < 0.0) a = 0.0; if (a > 1.0) a = 1.0; + a = schedule->phase_lo + a * (schedule->phase_hi - schedule->phase_lo); if (deadline) *deadline = (uint64_t)(d + 0.5); if (alpha) *alpha = (float)a; return 1; @@ -82,3 +100,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; +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 65c47568..4d393687 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -353,6 +353,17 @@ static double s_interp_host_hz = 0.0; static double s_interp_target_hz = 0.0; static double s_interp_source_hz = 0.0; static FrameInterpolationSchedule s_interp_schedule; +/* Blend source (psx_mod_set_frame_interpolation_source). VBLANK (0, the + * default) treats every guest VBlank present as a new frame, exactly as + * before. FLIP (1) rotates history only on a real flip -- the display origin + * moved or the displayed rect was redrawn -- and spreads one crossfade over + * the measured flip period, so 30 Hz content blends for the whole frame + * instead of blending for one VBlank and holding for the next. */ +static int s_interp_source = 0; +static FrameFlipTracker s_interp_flip; +static int s_interp_origin_x = -1, s_interp_origin_y = -1; +static double s_interp_phase_lo = 0.0, s_interp_phase_hi = 1.0; +static uint64_t s_interp_duplicates = 0; static void interp_reset_history(void); static int interp_present(float alpha); static void interp_present_source_interval(void); @@ -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(); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 21ee30b0..6800a9f1 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1268,6 +1268,9 @@ static int g_frame_interpolation_blend = PSX_MOD_FRAME_INTERPOLATION_LINEAR; static int g_frame_interpolation_blend_default = PSX_MOD_FRAME_INTERPOLATION_LINEAR; +/* Mod-owned blend source; reset_mod_owned_presentation() sets VBLANK. */ +static int g_frame_interpolation_source = + PSX_MOD_FRAME_SOURCE_VBLANK; static std::array g_mod_controller_mode_override = [] { std::array modes{}; @@ -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 diff --git a/runtime/tests/test_frame_interpolation_flip_source.py b/runtime/tests/test_frame_interpolation_flip_source.py new file mode 100644 index 00000000..1f8e8c9e --- /dev/null +++ b/runtime/tests/test_frame_interpolation_flip_source.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Guard the FLIP frame-interpolation source's wiring (docs/FRAME_RATE.md). + +The pure pieces (frame_flip_is_new_frame, frame_flip_tracker_vblank, +begin_phase) are exercised by frame_interpolation_schedule_test. This guard +pins where they are wired in, which no unit test reaches: + +- main.cpp hands the session's source to the GL renderer at every session + start, right after the renderer's interpolation settings; +- interp_capture's FLIP branch asks frame_flip_is_new_frame with the display + origin and the redraw test, feeds the answer to the tracker, and returns + before the copy on a duplicate, so the history is not rotated; +- both present paths pass the display origin and the redraw test. + +Without the hand-off FLIP never reaches the renderer: frame blend stays per +VBlank, and render passes (which require the FLIP source) are always refused. +""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") +GL = (ROOT / "runtime" / "src" / "gpu_gl_renderer.c").read_text(encoding="utf-8") + + +def squash(text): + return re.sub(r"\s+", " ", text).strip() + + +def function_body(source, signature): + start = source.index(signature) + return source[start:source.index("\n}\n", start)] + + +# ---- session-start hand-off (main.cpp) -------------------------------------- +HANDOFF = "gl_renderer_set_interpolation_source(g_frame_interpolation_source);" +assert MAIN.count(HANDOFF) == 1, "the source hand-off must exist exactly once" +handoff_at = MAIN.index(HANDOFF) +reboot_at = MAIN.index("session_reboot:") +assert reboot_at < handoff_at, \ + "the hand-off must run at every session start (after session_reboot:)" +settings_at = MAIN.rindex("gl_renderer_set_interpolation(", 0, handoff_at) +assert reboot_at < settings_at, "hand-off must sit beside the session's GL setup" +between = MAIN[settings_at:handoff_at] +assert between.count(";") == 1 and between.rstrip().endswith( + "g_frame_interpolation_blend);"), \ + "the hand-off must directly follow the session's interpolation settings" + +setter = function_body(GL, "void gl_renderer_set_interpolation_source(int source) {") +assert "if (source != s_interp_source) interp_reset_history_unlocked();" in setter, \ + "a source change must restart the blend history" +assert "s_interp_source = source;" in setter + +# ---- interp_capture's FLIP branch (gpu_gl_renderer.c) ----------------------- +capture = function_body(GL, "static int interp_capture(GLuint fbo,") +flip_at = capture.index("if (s_interp_source == 1) {") +flip = capture[flip_at:capture.index("} else {", flip_at)] +flat = squash(flip) +assert squash("""int new_frame = frame_flip_is_new_frame( + geometry_changed, s_interp_valid == 0, redrawn, origin_x, origin_y, + s_interp_origin_x, s_interp_origin_y);""") in flat, \ + "FLIP must decide new frames with the tested helper, from origin and redraw" +assert "s_interp_origin_x = origin_x;" in flip and \ + "s_interp_origin_y = origin_y;" in flip, "the last origin must be kept" +assert squash("""frame_flip_tracker_vblank(&s_interp_flip, new_frame, + &s_interp_phase_lo, &s_interp_phase_hi);""") in flat, \ + "the tracker must see every presented VBlank with the decision" +assert squash("""if (!new_frame) { s_interp_duplicates++; return 1; }""") in flat, \ + "a duplicate must return before the history is rotated" +assert flip.index("frame_flip_is_new_frame(") < \ + flip.index("frame_flip_tracker_vblank(") < flip.index("if (!new_frame) {") +assert flip_at < capture.index("glCopyTexSubImage2D("), \ + "the duplicate return must precede the capture copy" + +# ---- present paths ---------------------------------------------------------- +calls = [squash(m.group(0)) for m in + re.finditer(r"interp_capture\(\s*[^;]*?\);", GL[GL.index("\n}\n", + GL.index("static int interp_capture(GLuint fbo,")):])] +assert len(calls) == 2, f"expected the VRAM and wide present calls, got {len(calls)}" +assert any("GL_PRES_VRAM, disp_x, disp_y, present_dirty_test(disp_x, disp_y," + in c for c in calls), "VRAM present must pass origin and redraw" +assert any("GL_PRES_WIDE, disp_x, disp_y, present_dirty_test(0, disp_y," + in c for c in calls), "wide present must pass origin and redraw" + +print("frame interpolation FLIP source wiring guard passed") diff --git a/runtime/tests/test_frame_interpolation_schedule.c b/runtime/tests/test_frame_interpolation_schedule.c index a748e6a2..8b508347 100644 --- a/runtime/tests/test_frame_interpolation_schedule.c +++ b/runtime/tests/test_frame_interpolation_schedule.c @@ -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; diff --git a/runtime/tests/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index 9d9f733a..a62dfd07 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -19,6 +19,7 @@ int g_ws_tex_edge_pct=0; int psx_ws_prim_is_tagged(void){return 0;} void gpu_depth24_upload_span_reset(void){} void frame_interpolation_schedule_reset(FrameInterpolationSchedule *p){memset(p,0,sizeof(*p));} +void frame_flip_tracker_reset(FrameFlipTracker *p){memset(p,0,sizeof(*p));p->period=1;} static int checks,failures; static void check(int ok,const char *label){checks++;if(!ok){fprintf(stderr,"FAIL %s\n",label);failures++;}} static void verify(const char *label){ diff --git a/runtime/tests/test_mod_owned_display_controls.py b/runtime/tests/test_mod_owned_display_controls.py index ba78ad03..336c739e 100644 --- a/runtime/tests/test_mod_owned_display_controls.py +++ b/runtime/tests/test_mod_owned_display_controls.py @@ -126,6 +126,7 @@ for reset in ( "psx_mod_set_adaptive_backdrop_preload(0);", "g_bezel_path.clear();", "g_frame_interpolation_blend = g_frame_interpolation_blend_default;", + "g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK;", ): assert reset in after, f"mod-owned session reset is missing: {reset}" literal = re.fullmatch(r"(g_\w+) = (false|true|\d+);", reset)