From c5d945b0e15c7eb114cca8559c836ac8c7b6703b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:06:41 -0700 Subject: [PATCH 01/57] Change the GL internal scale at run time (opt-in) gl_renderer_set_dynamic_resolution(1) keeps the hr surface and the native-wide surfaces allocated at the scale context init gave them (the ceiling) and lets the internal scale step to any integer level from 1 to it while the game runs. A lower level renders into the surfaces' lower-left VRAM*S corner; every viewport, scissor, pack, copy and capture already takes the scale at use, so only allocation and the present's normalised UVs read the allocated size. Off (the default) nothing is allocated differently and no step can run. A step runs after a present, never inside a render pass: - it takes the top-left sample of every native block of the old surface (the sample the pack reads) into a 1x image and reseeds the new corner from it, so the guest-visible VRAM is unchanged byte for byte; - it rescales the displayed rect and the draw area from their old pixels, so neither the frame on screen nor the one being drawn shows 1x blocks; - it rescales the native-wide margins in place, in row bands ordered so no source row is overwritten before it is read; - it marks the mask stencil stale (rebuilt at once while mask checking is on) and drops the render-pass backup and images. Reallocating per step instead measured 30-106 ms at 7x-10x on an Apple M4, mostly the first touch of the new surface. Steps are not offered in the windowed high-resolution mode or with dual raster. gl_scale_invariance_test gains "steps" runs: rendering after a step into a ceiling-sized surface equals the fixed-scale run at that level; a chain of steps (9x down to 1x and back, 5x) leaves the native VRAM unchanged at every step, the two rects and the wide margins exact nearest rescales of their old pixels, everything else the old top-left samples, and mask checking right after a step correct; the window mode refuses steps. Co-Authored-By: Claude Opus 5.5 --- runtime/include/gpu_gl_renderer.h | 46 +- runtime/src/gpu_gl_renderer.c | 569 +++++++++++++++++++++-- runtime/tests/run_gl_scale_invariance.py | 40 ++ runtime/tests/test_gl_scale_invariance.c | 292 ++++++++++++ 4 files changed, 918 insertions(+), 29 deletions(-) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 6b9b90c0..b97199cd 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -268,7 +268,8 @@ void gl_renderer_set_wide_fast(int on); /* Internal-resolution scale state of the live GL context (0 before init). */ typedef struct GlScaleInfo { int requested; /* scale requested before context init (gr_set_scale) */ - int effective; /* scale the hr surface was allocated at */ + int effective; /* scale rendered at now (the hr surface's allocation + * unless dynamic resolution stepped below it) */ int max_scale; /* largest scale this context could allocate */ int max_dim; /* min(GL_MAX_TEXTURE_SIZE, _RENDERBUFFER_SIZE, _VIEWPORT_DIMS) */ int clamp_reason; /* PSX_GL_SCALE_* mask (gl_scale_limits.h) */ @@ -288,9 +289,52 @@ typedef struct GlScaleInfo { int window_grows; /* tile allocations so far */ int window_tiles; int window_mib; /* all tiles */ + /* Dynamic resolution: the scale the hr and native-wide surfaces are + * allocated at (the ceiling a step never exceeds); 0 when steps are not + * available (off, the window mode, dual raster). */ + int alloc_scale; } GlScaleInfo; int gl_renderer_scale_info(GlScaleInfo *out); +/* ---- Dynamic internal resolution ([video] dynamic_resolution) --------------- + * Opt-in. On, the hr and native-wide surfaces stay allocated at the scale + * context init gave them (the ceiling) and the internal scale may step to any + * integer level from 1 to it while the game runs; off (the default), nothing + * changes. A step keeps the guest-visible VRAM exact (the pack of every native + * block is preserved) and is presentation-only. Not available in the windowed + * high-resolution mode or with dual raster (netplay): the scale stays fixed. */ +void gl_renderer_set_dynamic_resolution(int on); /* any time; before init too */ +/* The ceiling a level may step up to, or 0 when steps are not available. */ +int gl_renderer_dynamic_resolution_ceiling(void); +/* Ask for a level: applied at the next post-present point outside a render + * pass (clamped to 1..ceiling). Returns 0 when steps are not available. */ +int gl_renderer_request_internal_scale(int scale); +/* Step now (debug probes and tests): 1 when the level is `scale` on return, + * 0 when refused or deferred (inside a render pass; it then applies after + * the next present). Never call it inside a render pass. */ +int gl_renderer_step_internal_scale_now(int scale); +typedef struct GlDynresStats { + int level, ceiling, pending; /* pending: requested level, 0 = none */ + uint64_t steps, deferred, refused; + int last_from, last_to; + /* The last step's wall time (ms, glFinish'ed when timing is on) and its + * parts: draining queued work, the 1x top-left image and the saved rects, + * the reseed, the rects back, the native-wide surfaces. */ + double last_ms, last_prep_ms, last_seed_ms, last_rects_ms, last_wide_ms; + int last_rects; /* rects kept at full detail */ +} GlDynresStats; +void gl_renderer_dynres_stats(GlDynresStats *out); +/* Host time the renderer spent, as running totals in performance-counter + * ticks (only kept while dynamic resolution is on): waits for the frame + * blend's next present, render passes, blend presents' own work, and time + * blocked in the swap. The dynamic-resolution controller differences them per + * guest interval. */ +typedef struct GlHostLedger { + uint64_t idle_ticks, pass_ticks, interp_work_ticks, swap_ticks; + uint64_t interp_presents, swaps; +} GlHostLedger; +void gl_renderer_host_ledger(GlHostLedger *out); + /* Narrow a native-wide display aspect num:den to the widest whose surface * this context can allocate at its internal scale (psx_gl_fit_wide_aspect; * disp_w is the display width the margins derive from). Returns 1 when it diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index a1ce10c2..64356732 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -370,6 +370,24 @@ static int s_hr_scale = 1; #if defined(__GNUC__) || defined(__clang__) #pragma GCC poison s_scale #endif +/* Dynamic internal resolution ([video] dynamic_resolution; the step block + * near the end of this file). 0 = off, and then every surface is allocated at + * the scale it renders at, exactly as before. Otherwise the scale the hr + * surface and the native-wide surfaces were allocated at (the ceiling): the + * current level (s_hr_scale == s_out_scale, never above it) renders into + * their lower-left VRAM*S corner. Only allocation and the present's + * normalised UVs read it; everything else keeps taking the two scales above + * at use. */ +static int s_alloc_scale = 0; +/* The scale a surface rendering at `cur` is (to be) allocated at. */ +static inline int alloc_scale_for(int cur) { + return s_alloc_scale > cur ? s_alloc_scale : cur; +} +static void dyn_context_ready(void); /* after init_gpu_raster */ +static void dyn_after_present(void); /* a step's post-present point */ +static int s_dyn_on = 0; /* gl_renderer_set_dynamic_resolution */ +static GlHostLedger s_dyn_ledger; /* host time totals (s_dyn_on only) */ +static uint64_t s_dyn_last_swap_ticks = 0; /* Netplay: SW@1× + GPU@s_out_scale dual write; CPU VRAM always authoritative. */ static int s_cpu_auth_dual = 0; /* Independent of netplay: the replacement surface must never become VRAM. */ @@ -728,6 +746,9 @@ static GLuint s_wide_rb[WIDE_MAX_SURF]; /* depth-stencil RB per surface (ma * collapsed to 12fps); with the RB * attached the pass costs ~2us. */ static int s_wide_base[WIDE_MAX_SURF]; /* base_x per surface (-1 = free) */ +/* Native rows [y0, y1) each surface has presented (dynamic resolution: the + * rows a scale step rescales; y1 <= y0 = none yet, then all of them). */ +static int s_dyn_wide_y0[WIDE_MAX_SURF], s_dyn_wide_y1[WIDE_MAX_SURF]; static int g_wide_w = 0; /* wide width (native px); 0 = disabled */ static int g_wide_off = 0; /* centering OFFSET (native px) */ static GLuint g_wide_cur = 0; /* active mirror FBO (0 = no mirror) */ @@ -1028,7 +1049,7 @@ static void hold_invalidate(void) { } /* Defined after present_target_quad / letterbox helpers. */ -static void present_target_quad(GLuint tex, int tex_w, int tex_h, +static void present_target_quad(GLuint tex, float tex_w, float tex_h, int x, int y, int w, int h, int linear, int lx, int ly, int lw, int lh, int v_flip, int apply_gamma, int src_scale); @@ -4365,15 +4386,15 @@ static void present_set_gamma(GLint uniform, int apply) { * hold-last drawable) to force the effect off — program uniforms persist, so * every content draw must state its own choice. */ static void present_set_scanline(GLint uOn, GLint uStr, GLint uLines, - GLint uScale, int pitch_lines, int disp_lines, + GLint uScale, float pitch_lines, int disp_lines, int out_h) { if (uOn < 0) return; - int on = (s_scanline_on && pitch_lines > 0 && disp_lines > 0 && out_h > 0) + int on = (s_scanline_on && pitch_lines > 0.0f && disp_lines > 0 && out_h > 0) ? 1 : 0; p_glUniform1i(uOn, on); if (!on) return; if (uStr >= 0) p_glUniform1f(uStr, s_scanline_strength); - if (uLines >= 0) p_glUniform1f(uLines, (float)pitch_lines); + if (uLines >= 0) p_glUniform1f(uLines, pitch_lines); if (uScale >= 0) p_glUniform1f(uScale, (float)out_h / (float)disp_lines); } #define PRESENT_SCANLINE(pitch, disp, oh) \ @@ -4786,6 +4807,7 @@ static int init_gpu_raster(void) { g_wide_w = 0; g_wide_off = 0; g_wide_cur = 0; g_wide_cur_base = 0; s_raster_ok = 1; + dyn_context_ready(); gl_perf_init(); /* frame_perf GPU/CPU phase timing (no-op if queries absent) */ if (s_cpu_auth_dual) { fprintf(stdout, "psxrecomp: GL GPU pipeline ready (dual-raster, " @@ -4803,7 +4825,9 @@ int gl_renderer_texture_banks_supported(void) { return s_raster_ok && !s_cpu_aut int gl_renderer_fit_wide_aspect(int disp_w, int *num, int *den) { if (!s_raster_ok || s_gl_max_dim <= 0) return 0; - int max_w = psx_gl_max_wide_width(s_out_scale, s_gl_max_dim); + /* Against the allocation (the dynamic-resolution ceiling): the aspect + * must not change when the level steps. */ + int max_w = psx_gl_max_wide_width(alloc_scale_for(s_out_scale), s_gl_max_dim); return psx_gl_fit_wide_aspect(disp_w, max_w > 0 ? max_w : 1, num, den); } @@ -4816,8 +4840,8 @@ int gl_renderer_scale_info(GlScaleInfo *out) { out->clamp_reason = s_scale_clamp_reason; out->alloc_retries = s_scale_alloc_retries; out->budget_mib = (int)(s_vram_budget >> 20); - out->fbo_w = s_raster_ok ? VRAM_W * s_hr_scale : 0; - out->fbo_h = s_raster_ok ? VRAM_H * s_hr_scale : 0; + out->fbo_w = s_raster_ok ? VRAM_W * alloc_scale_for(s_hr_scale) : 0; + out->fbo_h = s_raster_ok ? VRAM_H * alloc_scale_for(s_hr_scale) : 0; out->hr_scale = s_raster_ok ? s_hr_scale : 0; out->windowed = s_hiw; { @@ -4836,6 +4860,7 @@ int gl_renderer_scale_info(GlScaleInfo *out) { out->window_mib = (int)(b >> 20); } out->window_grows = s_hiw_grows; + out->alloc_scale = s_raster_ok ? s_alloc_scale : 0; out->max_scale = psx_gl_clamp_full_vram_scale(GL_MAX_INTERNAL_SCALE, GL_MAX_INTERNAL_SCALE, s_gl_max_dim, s_vram_budget, NULL); @@ -5432,20 +5457,23 @@ static GLuint wide_fbo_for(int base_x) { * Refuse that with one log line instead of failing mid-draw: the frame * then presents 4:3 through the CPU path at 1x, without the native-wide * margins. */ + /* Dynamic resolution: allocated at the ceiling (alloc_scale_for), like + * the hr surface, so a step never reallocates it. */ + const int AS = alloc_scale_for(s_out_scale); if (s_gl_max_dim > 0 && - ((int64_t)g_wide_w * s_out_scale > s_gl_max_dim || - (int64_t)VRAM_H * s_out_scale > s_gl_max_dim)) { + ((int64_t)g_wide_w * AS > s_gl_max_dim || + (int64_t)VRAM_H * AS > s_gl_max_dim)) { if (!s_wide_refused_logged) { fprintf(stdout, "psxrecomp: GL native-wide surface %dx%d at %dx exceeds " - "the GPU limit %d; presenting 4:3\n", g_wide_w * s_out_scale, - VRAM_H * s_out_scale, s_out_scale, s_gl_max_dim); + "the GPU limit %d; presenting 4:3\n", g_wide_w * AS, + VRAM_H * AS, AS, s_gl_max_dim); s_wide_refused_logged = 1; } return 0; } for (int i = 0; i < WIDE_MAX_SURF; i++) { if (!s_wide_fbo[i]) { - int w = g_wide_w * s_out_scale, h = VRAM_H * s_out_scale; + int w = g_wide_w * AS, h = VRAM_H * AS; s_cw_fbo_creates++; s_wide_tex[i] = make_tex(GL_RGBA8, w, h, GL_RGBA, GL_UNSIGNED_BYTE); /* Depth-stencil RB, same as the hr FBO: the stencil carries the @@ -5469,6 +5497,7 @@ static GLuint wide_fbo_for(int base_x) { glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); s_wide_base[i] = base_x; + s_dyn_wide_y0[i] = s_dyn_wide_y1[i] = 0; /* nothing presented yet */ return s_wide_fbo[i]; } } @@ -6171,9 +6200,18 @@ static int interp_present_pair(GLuint a, GLuint b, float t, int blend_mode) { interp_draw_textures(a, b, t, blend_mode, lx, ly, lw, lh); pres_record(GL_PRES_INTERP, 0, 0, s_interp_w, s_interp_h, lx, ly, lw, lh); + s_dyn_last_swap_ticks = 0; gl_swap_with_osd(); s_interp_swaps++; - (void)s_present_ticks_accum_fwd(SDL_GetPerformanceCounter() - present_t0); + { + uint64_t spent = SDL_GetPerformanceCounter() - present_t0; + (void)s_present_ticks_accum_fwd(spent); + if (s_dyn_on) { /* the blend's own work, its swap wait apart */ + s_dyn_ledger.interp_work_ticks += spent > s_dyn_last_swap_ticks + ? spent - s_dyn_last_swap_ticks : 0; + s_dyn_ledger.interp_presents++; + } + } return 1; } @@ -6193,6 +6231,7 @@ static void interp_wait_until(uint64_t deadline, uint64_t frequency) { if (now >= deadline) { /* Idle host time feeds the render-pass budget. */ (void)s_idle_ticks_accum_fwd(now - start); + if (s_dyn_on) s_dyn_ledger.idle_ticks += now - start; return; } uint64_t remain = deadline - now; @@ -6527,6 +6566,7 @@ void gl_renderer_pass_note_cost(uint64_t ticks) { render_pass_cost_add(&s_pass_cost, (double)ticks, s_pass_allocs != s_pass_allocs_begin); s_pass_ticks_accum += ticks; + if (s_dyn_on) s_dyn_ledger.pass_ticks += ticks; } uint32_t gl_renderer_pass_leaks(void) { return s_pass_leaks; } @@ -7718,6 +7758,16 @@ static void gl_swap_with_osd(void) { present_shot_done(wrote); } } + if (s_dyn_on) { + /* Dynamic resolution's ledger: time blocked in the swap is the + * driver's vsync wait unless the frame was late (main.cpp). */ + uint64_t t0 = SDL_GetPerformanceCounter(); + SDL_GL_SwapWindow(s_win); + s_dyn_last_swap_ticks = SDL_GetPerformanceCounter() - t0; + s_dyn_ledger.swap_ticks += s_dyn_last_swap_ticks; + s_dyn_ledger.swaps++; + return; + } SDL_GL_SwapWindow(s_win); } @@ -7729,8 +7779,11 @@ static void gl_swap_with_osd(void) { /* tex_w/tex_h and x/y/w/h are in the texture's NATIVE units; src_scale is how * many texels one native unit spans (the internal scale for the hr FBO and the * wide surfaces, 1 for textures that hold native or already-composed pixels). - * src_scale <= 1 is exactly the historical behaviour. */ -static void present_target_quad(GLuint tex, int tex_w, int tex_h, + * src_scale <= 1 is exactly the historical behaviour. tex_w/tex_h are the + * texture's extent at src_scale, which is fractional only for a surface + * allocated above the scale it renders at (dynamic resolution, + * present_alloc_extent). */ +static void present_target_quad(GLuint tex, float tex_w, float tex_h, int x, int y, int w, int h, int linear, int lx, int ly, int lw, int lh, int v_flip, int apply_gamma, int src_scale) { @@ -7758,8 +7811,8 @@ static void present_target_quad(GLuint tex, int tex_w, int tex_h, if (area && s_present_uSharp >= 0) { p_glUniform1i(s_present_uSharp, 3); if (s_present_uTexSize >= 0) - p_glUniform2f(s_present_uTexSize, (float)(tex_w * src_scale), - (float)(tex_h * src_scale)); + p_glUniform2f(s_present_uTexSize, tex_w * (float)src_scale, + tex_h * (float)src_scale); if (s_present_uSharpScale >= 0) /* output px per source texel */ p_glUniform2f(s_present_uSharpScale, (float)lw / (float)(w * src_scale), @@ -7772,7 +7825,7 @@ static void present_target_quad(GLuint tex, int tex_w, int tex_h, * the phase pitch; h is the displayed line count for the output-scale gate. * v_flip=0 is the already-composed hold-last drawable (scanlines, if any, * are already baked) — skip it. */ - PRESENT_SCANLINE(v_flip ? tex_h : 0, v_flip ? h : 0, lh); + PRESENT_SCANLINE(v_flip ? tex_h : 0.0f, v_flip ? h : 0, lh); /* Half-texel inset: with GL_LINEAR, corner-mapped UVs make the outermost * dest pixels blend the border texel with VRAM outside the content rect * (visible edge stripe with AA on). Center-mapped UVs keep edge samples @@ -7782,10 +7835,10 @@ static void present_target_quad(GLuint tex, int tex_w, int tex_h, * (about 4 px at 9x) and zoomed the image. src_scale <= 1: unchanged. */ { float in = src_scale > 1 ? 0.5f / (float)src_scale : 0.5f; - u0 = ((float)x + in) / (float)tex_w; - v0 = ((float)y + in) / (float)tex_h; - u1 = ((float)(x + w) - in) / (float)tex_w; - v1 = ((float)(y + h) - in) / (float)tex_h; + u0 = ((float)x + in) / tex_w; + v0 = ((float)y + in) / tex_h; + u1 = ((float)(x + w) - in) / tex_w; + v1 = ((float)(y + h) - in) / tex_h; } /* PRESENT_VS always samples with mix(v0,v1,1-p.y). For CPU/FBO guest * bands that is the correct PSX top-down → GL mapping (v_flip=1). For a @@ -7910,7 +7963,16 @@ int gl_renderer_present_hold_last(void) { return 1; } -void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, +/* The extent, in native units at `scale`, of a surface allocated at + * alloc_scale_for(scale) that holds `units` native columns or rows: units + * itself unless dynamic resolution renders it below its allocation. */ +static float present_alloc_extent(int units, int scale) { + const int as = alloc_scale_for(scale); + if (as <= scale || scale < 1) return (float)units; + return (float)units * (float)as / (float)scale; +} + +static void present_vram_impl(int disp_x, int disp_y, int w, int h, int linear, int force_4_3) { if (!s_ctx || !s_raster_ok) return; flush_flat_batch(); @@ -7984,8 +8046,17 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, return; } present_bezel(ww, wh, lx, ly, lw, lh); - present_target_quad(src_tex, src_tw, VRAM_H, - src_x, disp_y, w, h, linear, lx, ly, lw, lh, 1, 1, src_scale); + { + /* The hr surface may be allocated above the level it renders at + * (dynamic resolution); window tiles never are. */ + float tw = (float)src_tw, th = (float)VRAM_H; + if (src_tex == s_hr_tex) { + tw = present_alloc_extent(src_tw, s_hr_scale); + th = present_alloc_extent(VRAM_H, s_hr_scale); + } + present_target_quad(src_tex, tw, th, + src_x, disp_y, w, h, linear, lx, ly, lw, lh, 1, 1, src_scale); + } pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); gpu_timeline_note(GTL_PRESENT, GTL_PATH_VRAM_FBO, (uint32_t)disp_x | ((uint32_t)disp_y << 16)); @@ -8072,12 +8143,23 @@ static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h * disp_y+disp_h] region into the letterbox, V-flipped like present_vram (FBO y * bottom-origin → window top). Returns 0 if there's no wide surface for base_x * (caller falls back). disp_x is the displayed buffer base (the wide-surface key). */ -int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) { +static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) { if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; GLuint fbo = 0, tex = 0; for (int i = 0; i < WIDE_MAX_SURF; i++) if (s_wide_fbo[i] && s_wide_base[i] == disp_x) { - fbo = s_wide_fbo[i]; tex = s_wide_tex[i]; break; + fbo = s_wide_fbo[i]; tex = s_wide_tex[i]; + if (s_alloc_scale) { /* the rows a scale step rescales */ + int y0 = disp_y < 0 ? 0 : disp_y; + int y1 = disp_y + disp_h > VRAM_H ? VRAM_H : disp_y + disp_h; + if (s_dyn_wide_y1[i] <= s_dyn_wide_y0[i]) { + s_dyn_wide_y0[i] = y0; s_dyn_wide_y1[i] = y1; + } else { + if (y0 < s_dyn_wide_y0[i]) s_dyn_wide_y0[i] = y0; + if (y1 > s_dyn_wide_y1[i]) s_dyn_wide_y1[i] = y1; + } + } + break; } if (!fbo) return 0; flush_flat_batch(); @@ -8126,7 +8208,8 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) s_last_dw = g_wide_w; s_last_dh = disp_h; return 1; } - present_target_quad(tex, g_wide_w, VRAM_H, + present_target_quad(tex, present_alloc_extent(g_wide_w, s_out_scale), + present_alloc_extent(VRAM_H, s_out_scale), 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh, 1, 1, s_out_scale); pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); gpu_timeline_note(GTL_PRESENT, GTL_PATH_WIDE_FBO, @@ -8145,6 +8228,436 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) return 1; } +/* ==== Dynamic internal resolution: runtime scale steps ======================= + * [video] dynamic_resolution (docs/ENHANCEMENTS.md, IR3). Opt-in: off, + * s_alloc_scale stays 0 and nothing here runs. + * + * The hr surface and the native-wide surfaces are allocated once, at the + * scale context init gave them (the ceiling); a lower level renders into + * their lower-left VRAM*S corner (see s_alloc_scale). A step runs between + * frames, after a present and never inside an open render pass: + * 1. land every queued draw and upload (the pack and the raw mirror are + * then current); + * 2. take the top-left sample of every native block of the old surface -- + * the sample the pack reads -- into a 1x RGBA8 image, and copy the + * displayed rect and the current draw area aside at the old scale; + * 3. reseed the whole corner at the new scale from the 1x image (nearest): + * every new block's top-left sample is the old one byte for byte, so the + * pack, and everything the guest can read back, cannot change; + * 4. rescale the two saved rects back (each block's top-left exact, the rest + * the nearest old sample): the frame on screen and the one being drawn + * keep their detail instead of showing 1x blocks for a frame; + * 5. rescale the native-wide surfaces' presented rows in place, through the + * scratch in row bands ordered so no source row is overwritten before it + * is read (only the margins while the centre is spliced from hr at each + * present); + * 6. switch the scale (s_hr_scale, s_out_scale, the u_shift uniforms), mark + * the mask stencil stale (rebuilt from alpha as for any deferred encoding, + * at once while mask checking is on) and drop what was built at the old + * size (render-pass backup and images; the blend history restarts at the + * next capture). + * Integer scales only: pack exactness, the u_shift grid and one-native-pixel + * lines depend on them. Reallocating per step instead measured 30-106 ms at + * 7x-10x on an Apple M4 (the first touch of the new surface), a visible hitch; + * the reseed is a single pass. */ +static GLuint s_dyn_tl_tex = 0, s_dyn_tl_fbo = 0; +static GLuint s_dyn_tl_prog = 0, s_dyn_seed_prog = 0, s_dyn_rs_prog = 0; +static GLint s_dyn_tl_uHr = -1, s_dyn_tl_uS = -1; +static GLint s_dyn_seed_uSrc = -1, s_dyn_seed_uS = -1; +static GLint s_dyn_rs_uSrc = -1, s_dyn_rs_uOld = -1, s_dyn_rs_uNew = -1; +static GLint s_dyn_rs_uOff = -1, s_dyn_rs_uSrcOff = -1; +static int s_dyn_pending = 0; +static int s_dyn_timing = -1; /* PSX_DYNRES_TIMING: glFinish per part */ +static GlDynresStats s_dyn_stats; + +static const char *DYN_TL_FS = + "#version 330\n" + "uniform sampler2D u_hr;\n" + "uniform int u_s;\n" + "out vec4 frag;\n" + "void main(){ frag = texelFetch(u_hr, ivec2(gl_FragCoord.xy) * u_s, 0); }\n"; +static const char *DYN_SEED_FS = + "#version 330\n" + "uniform sampler2D u_src;\n" + "uniform int u_s;\n" + "out vec4 frag;\n" + "void main(){ frag = texelFetch(u_src, ivec2(gl_FragCoord.xy) / u_s, 0); }\n"; +/* Destination origin u_off (new px) in the target, source origin u_src_off + * (old px) in u_src. Block b, offset r: old texel b*old + r*old/new, so r = 0 + * (the top-left, the pack's sample) is copied exactly. */ +static const char *DYN_RESCALE_FS = + "#version 330\n" + "uniform sampler2D u_src;\n" + "uniform int u_old;\n" + "uniform int u_new;\n" + "uniform ivec2 u_off;\n" + "uniform ivec2 u_src_off;\n" + "out vec4 frag;\n" + "void main(){\n" + " ivec2 d = ivec2(gl_FragCoord.xy) - u_off;\n" + " ivec2 b = d / u_new;\n" + " ivec2 r = d - b * u_new;\n" + " frag = texelFetch(u_src, u_src_off + b * u_old + (r * u_old) / u_new, 0);\n" + "}\n"; + +static int dyn_resources(void) { + if (!s_dyn_tl_prog) { + s_dyn_tl_prog = build_program(PACK_VS, DYN_TL_FS); + if (!s_dyn_tl_prog) return 0; + s_dyn_tl_uHr = p_glGetUniformLocation(s_dyn_tl_prog, "u_hr"); + s_dyn_tl_uS = p_glGetUniformLocation(s_dyn_tl_prog, "u_s"); + } + if (!s_dyn_seed_prog) { + s_dyn_seed_prog = build_program(PACK_VS, DYN_SEED_FS); + if (!s_dyn_seed_prog) return 0; + s_dyn_seed_uSrc = p_glGetUniformLocation(s_dyn_seed_prog, "u_src"); + s_dyn_seed_uS = p_glGetUniformLocation(s_dyn_seed_prog, "u_s"); + } + if (!s_dyn_rs_prog) { + s_dyn_rs_prog = build_program(PACK_VS, DYN_RESCALE_FS); + if (!s_dyn_rs_prog) return 0; + s_dyn_rs_uSrc = p_glGetUniformLocation(s_dyn_rs_prog, "u_src"); + s_dyn_rs_uOld = p_glGetUniformLocation(s_dyn_rs_prog, "u_old"); + s_dyn_rs_uNew = p_glGetUniformLocation(s_dyn_rs_prog, "u_new"); + s_dyn_rs_uOff = p_glGetUniformLocation(s_dyn_rs_prog, "u_off"); + s_dyn_rs_uSrcOff = p_glGetUniformLocation(s_dyn_rs_prog, "u_src_off"); + } + if (!s_dyn_tl_fbo) { + s_dyn_tl_tex = make_tex(GL_RGBA8, VRAM_W, VRAM_H, GL_RGBA, GL_UNSIGNED_BYTE); + if (!make_fbo(&s_dyn_tl_fbo, s_dyn_tl_tex, 0)) { + glDeleteTextures(1, &s_dyn_tl_tex); + s_dyn_tl_tex = s_dyn_tl_fbo = 0; + return 0; + } + /* First touch now, not at the first step. */ + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_dyn_tl_fbo); + glDisable(GL_SCISSOR_TEST); + glClearColor(0, 0, 0, 0); + glClear(GL_COLOR_BUFFER_BIT); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + } + return 1; +} + +/* Steps are available: the full-VRAM regime above 1x, CPU VRAM not + * authoritative over the GL surface. */ +static int dyn_eligible(void) { + return s_ctx && s_raster_ok && !s_hiw && !s_cpu_auth_dual && s_hr_scale > 1; +} + +/* After init_gpu_raster (every context): the allocation is the ceiling. */ +static void dyn_context_ready(void) { + /* The old context took its objects with it. */ + s_dyn_tl_tex = s_dyn_tl_fbo = 0; + s_dyn_tl_prog = s_dyn_seed_prog = s_dyn_rs_prog = 0; + s_dyn_pending = 0; + s_alloc_scale = 0; + for (int i = 0; i < WIDE_MAX_SURF; i++) s_dyn_wide_y0[i] = s_dyn_wide_y1[i] = 0; + if (s_dyn_on && dyn_eligible()) { + if (dyn_resources()) { + s_alloc_scale = s_hr_scale; + fprintf(stdout, "psxrecomp: GL dynamic resolution: levels 1x..%dx " + "(surfaces allocated at %dx)\n", s_alloc_scale, s_alloc_scale); + } else { + fprintf(stdout, "psxrecomp: GL dynamic resolution unavailable (step " + "resources failed); the scale stays %dx\n", s_hr_scale); + } + } +} + +void gl_renderer_set_dynamic_resolution(int on) { + s_dyn_on = on ? 1 : 0; + if (!s_ctx || !s_raster_ok) return; /* dyn_context_ready decides */ + if (s_dyn_on) { + /* Turned on while running: the surfaces are allocated at the current + * scale, which becomes the ceiling. */ + if (!s_alloc_scale && dyn_eligible() && dyn_resources()) + s_alloc_scale = s_hr_scale; + } else if (s_alloc_scale) { + /* Back to the ceiling; the allocation stays (nothing reads it once + * the level equals it). */ + s_dyn_pending = s_alloc_scale; + } +} + +int gl_renderer_dynamic_resolution_ceiling(void) { + return (s_dyn_on && s_raster_ok) ? s_alloc_scale : 0; +} + +static double dyn_ms(uint64_t t0, uint64_t t1) { + return (double)(t1 - t0) * 1000.0 / (double)SDL_GetPerformanceFrequency(); +} +static uint64_t dyn_mark(void) { + if (s_dyn_timing) glFinish(); + return SDL_GetPerformanceCounter(); +} + +/* Fullscreen-triangle pass of `prog` into dst_fbo over the viewport + * (x, y, w, h), no scissor/blend/stencil. */ +static void dyn_pass_begin(GLuint prog, GLuint dst_fbo, int x, int y, int w, int h, + GLuint src_tex) { + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, dst_fbo); + glViewport(x, y, w, h); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_BLEND); + glDisable(GL_STENCIL_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + p_glUseProgram(prog); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, src_tex); + p_glBindVertexArray(s_empty_vao); +} +static void dyn_pass_end(void) { + glDrawArrays(GL_TRIANGLES, 0, 3); + p_glBindVertexArray(0); + p_glUseProgram(0); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); +} + +/* Draw native rect (nx, ny, nw, nh) of dst_fbo at snew from src_tex, whose + * copy of that rect at sold starts at (sx, sy) texels. */ +static void dyn_rescale_rect(GLuint src_tex, int sx, int sy, int sold, GLuint dst_fbo, + int nx, int ny, int nw, int nh, int snew) { + dyn_pass_begin(s_dyn_rs_prog, dst_fbo, nx * snew, ny * snew, nw * snew, nh * snew, + src_tex); + p_glUniform1i(s_dyn_rs_uSrc, 0); + p_glUniform1i(s_dyn_rs_uOld, sold); + p_glUniform1i(s_dyn_rs_uNew, snew); + p_glUniform2i(s_dyn_rs_uOff, nx * snew, ny * snew); + p_glUniform2i(s_dyn_rs_uSrcOff, sx, sy); + dyn_pass_end(); +} + +/* Rescale native columns [cx0, cx1) x rows [ry0, ry1) of a surface in place, + * in bands of rows through the scratch. Down: bands bottom-up (from ry0); + * up: top-down. A band's destination rows [y0*snew, y1*snew) then never + * reach a row a later band still has to read: for a step down every later + * band's source starts at y >= y1, i.e. at y1*sold >= y1*snew; a step up is + * the mirror image. Within a band the source is staged before it is drawn. + * Columns ranges (up to two, e.g. both margins) share the band staging. */ +static void dyn_rescale_in_place(GLuint fbo, int surf_w, const int (*cols)[2], int ncols, + int ry0, int ry1, int sold, int snew) { + const int BAND = 64; /* native rows per band */ + if (ry1 <= ry0 || ncols <= 0) return; + if (!scratch_ensure(surf_w * sold, BAND * sold)) return; + int nb = (ry1 - ry0 + BAND - 1) / BAND; + for (int k = 0; k < nb; k++) { + int b = snew < sold ? k : nb - 1 - k; + int y0 = ry0 + b * BAND, bh = (y0 + BAND <= ry1) ? BAND : ry1 - y0; + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer(0, y0 * sold, surf_w * sold, (y0 + bh) * sold, + 0, 0, surf_w * sold, bh * sold, GL_COLOR_BUFFER_BIT, GL_NEAREST); + for (int c = 0; c < ncols; c++) + if (cols[c][1] > cols[c][0]) + dyn_rescale_rect(s_scratch_tex, cols[c][0] * sold, 0, sold, fbo, + cols[c][0], y0, cols[c][1] - cols[c][0], bh, snew); + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); +} + +static int dyn_apply(int snew) { + if (!s_alloc_scale || !s_raster_ok || !s_ctx) { s_dyn_stats.refused++; return 0; } + if (snew < 1) snew = 1; + if (snew > s_alloc_scale) snew = s_alloc_scale; + if (snew == s_hr_scale) return 1; + if (s_pass_active) { /* never inside a render pass: after its present */ + s_dyn_pending = snew; + s_dyn_stats.deferred++; + return 0; + } + if (s_dyn_timing < 0) { + const char *e = getenv("PSX_DYNRES_TIMING"); + s_dyn_timing = (e && e[0] && e[0] != '0') ? 1 : 0; + } + const int sold = s_hr_scale; + const uint64_t t0 = SDL_GetPerformanceCounter(); + /* 1. Land queued work: the old surface is complete. */ + flush_flat_batch(); + flush_tex_batch(); + flush_cpu_upload(); + hiw_flush_queue(); + pack_flush(); + const uint64_t t1 = dyn_mark(); + /* 2a. The pack's samples, 1x. */ + dyn_pass_begin(s_dyn_tl_prog, s_dyn_tl_fbo, 0, 0, VRAM_W, VRAM_H, s_hr_tex); + p_glUniform1i(s_dyn_tl_uHr, 0); + p_glUniform1i(s_dyn_tl_uS, sold); + dyn_pass_end(); + /* 2b. The displayed rect and the draw area (front and back buffer) at the + * old scale, side by side in the scratch. A rect the scratch cannot + * hold at the GPU limit is left to the reseed (1x detail until it is + * redrawn). */ + int qn = 0, qr[2][4]; + { + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (!di.disabled && di.width > 0 && di.height > 0) { + qr[qn][0] = (int)di.display_x; qr[qn][1] = (int)di.display_y; + qr[qn][2] = (int)di.width; qr[qn][3] = (int)di.height; + qn++; + } + int ax = s_area_x1, ay = s_area_y1; + int aw = s_area_x2 - s_area_x1 + 1, ah = s_area_y2 - s_area_y1 + 1; + if (aw > 0 && ah > 0 && !(qn && ax >= qr[0][0] && ay >= qr[0][1] && + ax + aw <= qr[0][0] + qr[0][2] && + ay + ah <= qr[0][1] + qr[0][3])) { + qr[qn][0] = ax; qr[qn][1] = ay; qr[qn][2] = aw; qr[qn][3] = ah; + qn++; + } + int sw = 0, sh = 0, keep = 0; + for (int i = 0; i < qn; i++) { + int *r = qr[i]; + if (r[0] < 0) { r[2] += r[0]; r[0] = 0; } + if (r[1] < 0) { r[3] += r[1]; r[1] = 0; } + if (r[0] + r[2] > VRAM_W) r[2] = VRAM_W - r[0]; + if (r[1] + r[3] > VRAM_H) r[3] = VRAM_H - r[1]; + if (r[2] <= 0 || r[3] <= 0) continue; + int nsw = sw + r[2] * sold, nsh = r[3] * sold > sh ? r[3] * sold : sh; + if (s_gl_max_dim > 0 && (nsw > s_gl_max_dim || nsh > s_gl_max_dim)) continue; + sw = nsw; sh = nsh; + if (keep != i) memcpy(qr[keep], r, sizeof qr[0]); + keep++; + } + qn = keep; + if (qn && !scratch_ensure(sw, sh)) qn = 0; + int ox = 0; + for (int i = 0; i < qn; i++) { + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer(qr[i][0] * sold, qr[i][1] * sold, + (qr[i][0] + qr[i][2]) * sold, (qr[i][1] + qr[i][3]) * sold, + ox, 0, ox + qr[i][2] * sold, qr[i][3] * sold, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + ox += qr[i][2] * sold; + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + } + const uint64_t t2 = dyn_mark(); + /* 3. Reseed the corner at the new scale. */ + dyn_pass_begin(s_dyn_seed_prog, s_hr_fbo, 0, 0, VRAM_W * snew, VRAM_H * snew, + s_dyn_tl_tex); + p_glUniform1i(s_dyn_seed_uSrc, 0); + p_glUniform1i(s_dyn_seed_uS, snew); + dyn_pass_end(); + const uint64_t t3 = dyn_mark(); + /* 4. The saved rects back at full detail (where they overlap, both hold + * the same old pixels). */ + for (int i = 0, ox = 0; i < qn; i++) { + dyn_rescale_rect(s_scratch_tex, ox, 0, sold, s_hr_fbo, + qr[i][0], qr[i][1], qr[i][2], qr[i][3], snew); + ox += qr[i][2] * sold; + } + const uint64_t t4 = dyn_mark(); + /* 5. Native-wide surfaces: presentation-only, rescaled in place over the + * rows they have presented (all rows before the first present). While + * the centre is spliced from hr at every present (wide_blit_center), + * only the margins hold anything of their own. */ + if (g_wide_w > 0) { + int cols[2][2], nc = 0; + int native_w = g_wide_w - 2 * g_wide_off; + if (s_wide_fast && !view_enabled && native_w > 0 && g_wide_off > 0) { + cols[0][0] = 0; cols[0][1] = g_wide_off; + cols[1][0] = g_wide_off + native_w; cols[1][1] = g_wide_w; + nc = 2; + } else { + cols[0][0] = 0; cols[0][1] = g_wide_w; + nc = 1; + } + for (int i = 0; i < WIDE_MAX_SURF; i++) { + if (!s_wide_fbo[i]) continue; + int y0 = s_dyn_wide_y0[i], y1 = s_dyn_wide_y1[i]; + if (y1 <= y0) { y0 = 0; y1 = VRAM_H; } + dyn_rescale_in_place(s_wide_fbo[i], g_wide_w, (const int (*)[2])cols, nc, + y0, y1, sold, snew); + } + } + const uint64_t t5 = dyn_mark(); + /* 6. The new scale. */ + s_hr_scale = s_out_scale = snew; + s_shift_hr = s_shift_hi = 0.5f / (float)snew - 1.0f / 64.0f; + p_glUseProgram(s_geo_prog); p_glUniform1f(s_geo_uShift, s_shift_hr); + p_glUseProgram(s_tex_prog); p_glUniform1f(s_tex_uShift, s_shift_hr); + p_glUseProgram(s_blit_prog); + p_glUniform1f(p_glGetUniformLocation(s_blit_prog, "u_shift"), s_shift_hr); + p_glUseProgram(0); + /* Mask stencil: rebuilt from alpha, the deferred path; at once while + * mask checking is on (no E6h transition will ask for it). */ + s_stencil_valid = 0; + rect_add(&s_stencil_stale, 0, 0, VRAM_W - 1, VRAM_H - 1); + if (s_mask_check) rebuild_mask_stencils(); + s_pb_valid = 0; + pass_gens_invalidate(); + for (int r = 0; r < PRES_ROWS; r++) s_present_dirty[r] = ~0ull; + const uint64_t t6 = SDL_GetPerformanceCounter(); + s_dyn_stats.steps++; + s_dyn_stats.last_from = sold; + s_dyn_stats.last_to = snew; + s_dyn_stats.last_ms = dyn_ms(t0, t6); + s_dyn_stats.last_prep_ms = dyn_ms(t0, t1); + s_dyn_stats.last_seed_ms = dyn_ms(t1, t2) + dyn_ms(t2, t3); + s_dyn_stats.last_rects_ms = dyn_ms(t3, t4); + s_dyn_stats.last_wide_ms = dyn_ms(t4, t5); + s_dyn_stats.last_rects = qn; + if (s_dyn_timing) + fprintf(stdout, "psxrecomp: dynamic resolution %dx -> %dx in %.2f ms (drain %.2f, " + "1x+rects %.2f, reseed %.2f, rects back %.2f, wide %.2f)\n", sold, snew, + s_dyn_stats.last_ms, dyn_ms(t0, t1), dyn_ms(t1, t2), dyn_ms(t2, t3), + dyn_ms(t3, t4), dyn_ms(t4, t5)); + return 1; +} + +static void dyn_after_present(void) { + if (!s_dyn_pending) return; + int s = s_dyn_pending; + s_dyn_pending = 0; + (void)dyn_apply(s); /* inside a pass: re-queued */ +} + +int gl_renderer_request_internal_scale(int scale) { + if (!s_alloc_scale || !s_raster_ok) return 0; + if (scale < 1) scale = 1; + if (scale > s_alloc_scale) scale = s_alloc_scale; + s_dyn_pending = scale == s_hr_scale ? 0 : scale; + return 1; +} + +int gl_renderer_step_internal_scale_now(int scale) { + if (!s_alloc_scale || !s_raster_ok) return 0; + if (scale > s_alloc_scale) scale = s_alloc_scale; + if (scale < 1) scale = 1; + s_dyn_pending = 0; + return dyn_apply(scale) && s_hr_scale == scale; +} + +void gl_renderer_dynres_stats(GlDynresStats *out) { + if (!out) return; + *out = s_dyn_stats; + out->level = s_raster_ok ? s_hr_scale : 0; + out->ceiling = gl_renderer_dynamic_resolution_ceiling(); + out->pending = s_dyn_pending; +} + +void gl_renderer_host_ledger(GlHostLedger *out) { + if (out) *out = s_dyn_ledger; +} + +void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, + int force_4_3) { + present_vram_impl(disp_x, disp_y, w, h, linear, force_4_3); + dyn_after_present(); +} + +int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) { + int r = present_wide_fbo_impl(disp_x, disp_y, disp_h, linear); + dyn_after_present(); + return r; +} + static const GpuRenderBackend GL_BACKEND = { .name = "opengl", .init = glb_init, .set_scale = glb_set_scale, .scale = glb_scale, diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index a3c9bd09..b5783586 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -33,6 +33,14 @@ from the VRAM, so a decode error both ways share still fails. Pass runs (mode passes, where the renderer has the frame-rate stack's render passes) check that the window mode refuses them and the full-VRAM surface offers them. +Step runs (mode steps, dynamic resolution) keep the surfaces at a ceiling and +change the scale at run time. "fresh" steps before drawing: the native VRAM, +the frame at S and the wide surface must equal the fixed-scale run at the +final level. "chain" draws, then steps through a list: each step must leave +the native VRAM unchanged and rescale the displayed rect, the draw area and +the wide margins from their own pixels (the fixture checks every pixel); the +scene drawn again at the final level must equal the fixed-scale run there. +The window mode refuses steps. macOS/Linux/Windows (MinGW): pass the SDL3 include directory and static library (for example from a runtime build tree's _deps/sdl3-src/include and @@ -322,6 +330,38 @@ def main(): r.stderr.strip()[-600:]) if r.returncode or not parsed or parsed[1]: ok = False + # Dynamic resolution steps: (ceiling, levels, kind, env). The final level + # must be one of --scales (its fixed-scale run is the reference). + step_runs = ( + (9, "5", "fresh", {}), (9, "3,9", "fresh", {}), (9, "1", "fresh", {}), + (5, "2", "fresh", {}), + (9, "8,5,9,3,1,2,9", "chain", {}), (5, "3,5,1,2", "chain", {}), + (9, "5", "chain", {"PSX_GL_HIRES_WINDOW": "1"}), + ) + for ceiling, levels, kind, extra in step_runs: + e = dict(env) + e.update(extra) + r = run([dest / "probe", ceiling, "steps", levels, kind], env=e) + parsed = parse_run(r.stdout) + label = f"steps {kind} {ceiling}x [{levels}]" + (" window" if extra else "") + print(f"{label}: exit={r.returncode}", + [ln for ln in r.stdout.strip().splitlines() if ln.startswith("step ")][-8:], + r.stdout.strip().splitlines()[-1:], r.stderr.strip()[-600:]) + if r.returncode or not parsed or parsed[1]: + ok = False + if extra: + continue # the window mode only has to refuse + final = int(levels.split(",")[-1]) + if parsed: + digests[("steps", ceiling, levels, kind)] = parsed[2] + got = (parse_hires(r.stdout), parse_hires(r.stdout, "wide")) + if final not in hires_full or None in got or got != hires_full[final]: + print(f"FAIL {label}: frame/wide surface differ from the fixed {final}x run:", + got, hires_full.get(final)) + ok = False + if not digests_agree(digests): + print("FAIL native VRAM digest differs across steps:", digests) + ok = False for label, s, budget in (("over-limit", 32, None), ("budget", 12, "40")): e = dict(env) if budget is not None: diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 464270d1..964b3af1 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -61,6 +61,21 @@ * and the window mode refuses them (PSX_MOD_RENDER_PASS_BACKEND), and * gl_renderer_pass_begin opens nothing there: a pass backs up and restores * only the authoritative surface, never the window's tiles. + * - mode "steps" (dynamic resolution, argv: scale = the ceiling, then a + * comma list of levels, then "fresh" or "chain"): the surfaces stay + * allocated at the ceiling and the scale steps at run time. + * "fresh" steps before anything is drawn, then renders the scene: the + * runner requires the native digest, the frame at S and the wide surface + * to equal the fixed-scale run at the final level (rendering into the + * corner of a larger surface is rendering at that scale). + * "chain" renders the scene and the native-wide margins at the ceiling, + * then steps through the list. Every step must leave the native VRAM + * bit-identical; the displayed rect and the draw area must come back as + * the nearest rescale of their old pixels (each block's top-left exact); + * everything else must be the old top-left samples (blocks of one + * colour); the wide margins must be the nearest rescale of the old ones. + * Then the scene is drawn again and must equal the fixed-scale run at the + * final level. In the window mode steps are refused and the scale stays. * Original source-owned scene; no retail payload. */ #include "gpu_gl_renderer.c" #include "gpu_hd_texture_stubs.inc" @@ -780,6 +795,276 @@ static int sbs_main(int scale) { return failures ? 1 : 0; } +/* ---- mode "steps": dynamic internal resolution -------------------------- */ +#define STEPS_BACK_Y 256 /* the "back buffer": the draw area during steps */ +static void steps_wide_part(void) { + /* The scene's native-wide part (see main). */ + glb_wide_configure(426, 53); + glb_wide_set_target(0); + glb_set_draw_area(0, 0, 319, 239); + glb_draw_flat_rect(-40, 20, 90, 30, 0x5ad6); + glb_draw_gouraud_triangle(250, 60, 0x001f, 372, 90, 0x7c00, 280, 150, 0x03e0); + glb_draw_textured_rect(300, 160, 64, 40, 0, 0, 512, 256, 0x0008); + glb_draw_line(-30, 225, 350, 225, 0x7fff); + glb_set_draw_area(0, 0, 1023, 511); +} + +static uint32_t *steps_read(int x, int y, int w, int h) { + int S = s_out_scale, ow = 0, oh = 0; + uint32_t *img = (uint32_t *)malloc((size_t)w * S * h * S * 4); + int n = img ? gl_renderer_read_display_hires(x, y, w, h, img, w * S * h * S, &ow, &oh) : 0; + if (!n) { free(img); return NULL; } + return img; +} + +/* Pixels of `after` (w x h native at snew) that are not the nearest rescale of + * `before` (at sold): block b, offset r -> old b*sold + r*sold/snew. */ +static long steps_rescale_mismatch(const uint32_t *before, int sold, const uint32_t *after, + int snew, int w, int h) { + long bad = 0; + int ow = w * sold, nw = w * snew; + for (int Y = 0; Y < h * snew; Y++) { + int oy = (Y / snew) * sold + ((Y % snew) * sold) / snew; + for (int X = 0; X < nw; X++) { + int ox = (X / snew) * sold + ((X % snew) * sold) / snew; + if ((after[(size_t)Y * nw + X] & 0xFFFFFFu) != (before[(size_t)oy * ow + ox] & 0xFFFFFFu)) + bad++; + } + } + return bad; +} + +/* Blocks of `after` (at snew) that are not one colour equal to the top-left + * sample of the same block of `before` (at sold). */ +static long steps_seed_mismatch(const uint32_t *before, int sold, const uint32_t *after, + int snew, int w, int h) { + long bad = 0; + int ow = w * sold, nw = w * snew; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) { + uint32_t want = before[(size_t)y * sold * ow + (size_t)x * sold] & 0xFFFFFFu; + int same = 1; + for (int j = 0; j < snew && same; j++) + for (int i = 0; i < snew; i++) + if ((after[((size_t)y * snew + j) * nw + (size_t)x * snew + i] & 0xFFFFFFu) != want) { + same = 0; break; + } + bad += !same; + } + return bad; +} + +static uint64_t steps_native_digest(int mask_lines) { + gl_renderer_sync_cpu(); + check(gl_renderer_fbo_peek(0, 0, 1024, 512, peek), "native peek"); + if (mask_lines) + for (int y = LINE_Y0; y < LINE_Y1; y++) + for (int x = LINE_X0; x < LINE_X1; x++) peek[y * 1024 + x] = 0; + return fnv(peek, sizeof peek, 0xcbf29ce484222325ull); +} + +static uint64_t steps_hires_digest(int x, int y, int w, int h) { + uint32_t *img = steps_read(x, y, w, h); + check(img != NULL, "hires readback"); + uint64_t d = img ? fnv(img, (size_t)w * s_out_scale * h * s_out_scale * 4, + 0xcbf29ce484222325ull) : 0; + free(img); + return d; +} + +static uint64_t steps_wide_digest(uint32_t **keep) { + int ww = 426 * s_out_scale, wh = 512 * s_out_scale, gw = 0, gh = 0; + uint32_t *wb = (uint32_t *)malloc((size_t)ww * wh * 4); + int got = wb ? glb_wide_dump_full(wb, ww * wh, &gw, &gh, 0) : 0; + check(got == ww * wh && gw == ww && gh == wh, "wide surface dump size"); + uint64_t d = got ? fnv(wb, (size_t)ww * wh * 4, 0xcbf29ce484222325ull) : 0; + if (keep) *keep = got ? wb : (free(wb), (uint32_t *)NULL); + else free(wb); + return d; +} + +/* The native-wide surface is allocated at the ceiling, whatever the level + * when it was created: a later step up must not outgrow it. */ +static void steps_check_wide_alloc(int ceiling) { + GLint tw = 0, th = 0; + glBindTexture(GL_TEXTURE_2D, s_wide_tex[0]); + glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &tw); + glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &th); + glBindTexture(GL_TEXTURE_2D, 0); + check(tw == 426 * ceiling && th == 512 * ceiling, "wide surface allocated at the ceiling"); +} + +static int steps_main(int ceiling, int window, const char *chain, int fresh) { + int levels[32], n = 0; + for (const char *c = chain; c && *c && n < 32;) { + levels[n++] = atoi(c); + while (*c && *c != ',') c++; + if (*c == ',') c++; + } + if (window) { + /* The window mode keeps a fixed scale. */ + check(gl_renderer_dynamic_resolution_ceiling() == 0, "window mode: no steps"); + check(!gl_renderer_step_internal_scale_now(n ? levels[0] : 1) && + s_out_scale == ceiling, "window mode: a step is refused, the scale stays"); + printf("checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; + } + if (gl_renderer_dynamic_resolution_ceiling() != ceiling) + fprintf(stderr, "ceiling=%d want %d\n", gl_renderer_dynamic_resolution_ceiling(), ceiling); + check(gl_renderer_dynamic_resolution_ceiling() == ceiling, "steps up to the ceiling"); + GlScaleInfo si; + gl_renderer_scale_info(&si); + check(si.alloc_scale == ceiling && si.fbo_w == 1024 * ceiling, "allocated at the ceiling"); + if (fresh) { + for (int i = 0; i < n; i++) + check(gl_renderer_step_internal_scale_now(levels[i]) && s_out_scale == levels[i], + "step applied"); + scene(); + uint64_t digest = steps_native_digest(1); + uint64_t hires = steps_hires_digest(0, 0, FRAME_W, FRAME_H); + steps_wide_part(); + steps_check_wide_alloc(ceiling); /* created below the ceiling */ + uint64_t wide = steps_wide_digest(NULL); + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("level=%d\n", s_out_scale); + printf("digest=%016llx\n", (unsigned long long)digest); + printf("hires=%016llx\n", (unsigned long long)hires); + printf("wide=%016llx\n", (unsigned long long)wide); + printf("checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; + } + scene(); + steps_wide_part(); + steps_check_wide_alloc(ceiling); + /* A back buffer with its own content, the draw area while stepping. */ + glb_set_draw_area(0, STEPS_BACK_Y, FRAME_W - 1, STEPS_BACK_Y + FRAME_H - 1); + glb_wide_disable_target(); + glb_fill_rect(0, STEPS_BACK_Y, FRAME_W, FRAME_H, 0x0c63); + glb_draw_gouraud_triangle(10, STEPS_BACK_Y + 10, 0x7c00, 300, STEPS_BACK_Y + 40, 0x03e0, + 120, STEPS_BACK_Y + 220, 0x001f); + glb_draw_textured_rect(180, STEPS_BACK_Y + 100, 64, 48, 0, 0, 512, 256, 0x0008); + /* Outside both rects, with detail below one native pixel (sloped edges, + * shading): a step keeps only each block's top-left sample there. */ + const int OX = 680, OY = 280, OW = 256, OH = 192; + glb_set_draw_area(0, 0, 1023, 511); + glb_draw_gouraud_triangle(OX + 10, OY + 5, 0x7c1f, OX + 250, OY + 60, 0x03ff, + OX + 60, OY + 185, 0x7fe0); + glb_draw_flat_triangle(OX + 120, OY + 20, OX + 240, OY + 170, OX + 20, OY + 120, 0x2d6b); + glb_set_draw_area(0, STEPS_BACK_Y, FRAME_W - 1, STEPS_BACK_Y + FRAME_H - 1); + glb_wide_set_target(0); + for (int i = 0; i < n; i++) { + int sold = s_out_scale, snew = levels[i]; + /* The last step runs with mask checking on: the stencil must follow + * the new scale at once (no GP0(E6h) will ask for it). */ + if (i == n - 1) glb_set_mask_bits(0, 1); + uint64_t d0 = steps_native_digest(0); + uint32_t *disp0 = steps_read(0, 0, FRAME_W, FRAME_H); + uint32_t *back0 = steps_read(0, STEPS_BACK_Y, FRAME_W, FRAME_H); + uint32_t *out0 = steps_read(OX, OY, OW, OH); + uint32_t *wide0 = NULL; + (void)steps_wide_digest(&wide0); + int ok = gl_renderer_step_internal_scale_now(snew); + char label[128]; + snprintf(label, sizeof label, "step %dx -> %dx applied", sold, snew); + check(ok && s_out_scale == snew && s_hr_scale == snew, label); + GlDynresStats st; + gl_renderer_dynres_stats(&st); + uint64_t d1 = steps_native_digest(0); + snprintf(label, sizeof label, "step %dx -> %dx: native VRAM unchanged", sold, snew); + check(d1 == d0, label); + uint32_t *disp1 = steps_read(0, 0, FRAME_W, FRAME_H); + uint32_t *back1 = steps_read(0, STEPS_BACK_Y, FRAME_W, FRAME_H); + uint32_t *out1 = steps_read(OX, OY, OW, OH); + uint32_t *wide1 = NULL; + (void)steps_wide_digest(&wide1); + long bd = (disp0 && disp1) ? steps_rescale_mismatch(disp0, sold, disp1, snew, FRAME_W, FRAME_H) : -1; + long bb = (back0 && back1) ? steps_rescale_mismatch(back0, sold, back1, snew, FRAME_W, FRAME_H) : -1; + long bo = (out0 && out1) ? steps_seed_mismatch(out0, sold, out1, snew, OW, OH) : -1; + long bw = -1; + if (wide0 && wide1) { + /* Margins only (the centre is the frame, spliced at present). */ + long l = 0; + int ow = 426 * sold, nw = 426 * snew; + for (int Y = 0; Y < 512 * snew; Y++) { + int oy = (Y / snew) * sold + ((Y % snew) * sold) / snew; + for (int X = 0; X < nw; X++) { + int nx = X / snew; + if (nx >= 53 && nx < 53 + 320) continue; + int ox = nx * sold + ((X % snew) * sold) / snew; + if ((wide1[(size_t)Y * nw + X] & 0xFFFFFFu) != (wide0[(size_t)oy * ow + ox] & 0xFFFFFFu)) + l++; + } + } + bw = l; + } + printf("step %dx -> %dx: rects=%d display=%ld back=%ld outside=%ld wide=%ld (%.2f ms)\n", + sold, snew, st.last_rects, bd, bb, bo, bw, st.last_ms); + snprintf(label, sizeof label, "step %dx -> %dx: displayed rect rescaled from its pixels", sold, snew); + check(bd == 0, label); + snprintf(label, sizeof label, "step %dx -> %dx: draw area rescaled from its pixels", sold, snew); + check(bb == 0, label); + snprintf(label, sizeof label, "step %dx -> %dx: elsewhere the old top-left samples", sold, snew); + check(bo == 0, label); + snprintf(label, sizeof label, "step %dx -> %dx: wide margins rescaled", sold, snew); + check(bw == 0, label); + free(disp0); free(disp1); free(back0); free(back1); free(out0); free(out1); + free(wide0); free(wide1); + if (i == n - 1) { + /* Rows 150..159 around the mask-set rect (250..279) hold pixels + * with bit 15 (that rect, and masked texels of the textured + * triangle). A rect drawn over them with the check on must keep + * every masked pixel and cover every other one. */ + static uint16_t pre[1024 * 512]; + glb_set_draw_area(0, 0, 1023, 511); + glb_wide_disable_target(); + gl_renderer_sync_cpu(); + check(gl_renderer_fbo_peek(0, 0, 1024, 512, pre), "native peek"); + glb_draw_flat_rect(240, 150, 50, 10, 0x7fff); + gl_renderer_sync_cpu(); + check(gl_renderer_fbo_peek(0, 0, 1024, 512, peek), "native peek"); + int kept = 0, drawn = 0, masked = 0, wrong = 0; + for (int y = 150; y < 160; y++) + for (int x = 240; x < 290; x++) { + uint16_t a = pre[y * 1024 + x], b = peek[y * 1024 + x]; + if (a & 0x8000) { masked++; kept += b == a; } + else drawn += b == 0x7fff; + } + wrong = (masked - kept) + (500 - masked - drawn); + int hk = 0; + uint32_t *img = steps_read(250, 150, 30, 10); + if (img) { + for (int k = 0; k < 30 * snew * 10 * snew; k++) + hk += (img[k] & 0xFFFFFFu) != 0xFFFFFFu; /* not the white rect */ + free(img); + } + printf("mask after step: masked=%d kept=%d drawn=%d at S kept=%d\n", + masked, kept, drawn, hk); + check(masked >= 300 && wrong == 0, "mask check right after a step: native VRAM"); + check(hk == 300 * snew * snew, "mask check right after a step: frame at S"); + glb_set_mask_bits(0, 0); + } + } + /* Draw everything again at the final level: a fresh render at that scale. + * The steps' own content goes first (VRAM there is 0 in the scene). */ + glb_wide_disable_target(); + glb_set_draw_area(0, 0, 1023, 511); + glb_fill_rect(0, STEPS_BACK_Y, FRAME_W, FRAME_H, 0); + glb_fill_rect(OX, OY, OW, OH, 0); + scene(); + uint64_t digest = steps_native_digest(1); + uint64_t hires = steps_hires_digest(0, 0, FRAME_W, FRAME_H); + glb_wide_clear(0, 0, 512, 0); + steps_wide_part(); + uint64_t wide = steps_wide_digest(NULL); + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("level=%d\n", s_out_scale); + printf("digest=%016llx\n", (unsigned long long)digest); + printf("hires=%016llx\n", (unsigned long long)hires); + printf("wide=%016llx\n", (unsigned long long)wide); + printf("checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; +} + int main(int argc, char **argv) { int scale = argc > 1 ? atoi(argv[1]) : 1; const char *mode = argc > 2 ? argv[2] : "scene"; @@ -816,6 +1101,7 @@ int main(int argc, char **argv) { glb_init(vram); glb_set_scale(scale); gl_renderer_set_swap_interval(0); + if (!strcmp(mode, "steps")) gl_renderer_set_dynamic_resolution(1); if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } GlScaleInfo si; gl_renderer_scale_info(&si); @@ -846,6 +1132,12 @@ int main(int argc, char **argv) { } check(si.effective == scale, "requested scale allocated"); + if (!strcmp(mode, "steps")) { + int rc = steps_main(scale, si.windowed, argc > 3 ? argv[3] : "", + argc > 4 && !strcmp(argv[4], "fresh")); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return rc; + } if (!strcmp(mode, "sbs")) { int rc = sbs_main(scale); gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); From 108962248cccf1fcba38649586a75dbb4b76f0f9 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:17:49 -0700 Subject: [PATCH 02/57] Add the opt-in dynamic resolution controller, config and telemetry A step controller (dynamic_resolution.c) lowers or raises the GL internal scale from frame-time telemetry between a floor and the configured ceiling; off unless a title or the player enables it. Tested by dynamic_resolution_test (synthetic load) and the real-GL gl_scale_invariance_test step modes. Co-Authored-By: Claude Opus 5.5 --- recompiler/src/config_loader.cpp | 52 ++++ recompiler/src/config_loader.h | 13 + runtime/CMakeLists.txt | 10 + runtime/include/dynamic_resolution.h | 138 ++++++++++ runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 85 ++++++- runtime/src/dynamic_resolution.c | 301 ++++++++++++++++++++++ runtime/src/main.cpp | 284 ++++++++++++++++++++- runtime/tests/test_dynamic_resolution.c | 320 ++++++++++++++++++++++++ 9 files changed, 1201 insertions(+), 3 deletions(-) create mode 100644 runtime/include/dynamic_resolution.h create mode 100644 runtime/src/dynamic_resolution.c create mode 100644 runtime/tests/test_dynamic_resolution.c diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index e18da39b..8d708810 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -653,6 +653,28 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } rt.video_internal_resolution = value; } + if (video.contains("dynamic_resolution")) { + rt.video_dynamic_resolution = toml::find(video, "dynamic_resolution"); + } + if (video.contains("dynamic_resolution_min")) { + const toml::value& ir = toml::find(video, "dynamic_resolution_min"); + int value = 0; + bool ok = false; + if (ir.is_string()) { + ok = psx_ir_parse(ir.as_string().str.c_str(), &value) != 0 && + value != PSX_IR_DISPLAY; + } else if (ir.is_integer()) { + const auto n = ir.as_integer(); + ok = n >= PSX_IR_MIN_LINES && n <= PSX_IR_MAX_LINES; + value = static_cast(n); + } + if (!ok) { + throw std::runtime_error( + "[video] dynamic_resolution_min must be native, 720p, 1080p, " + "1440p, 4k, 5k, 8k, or a number of lines"); + } + rt.video_dynamic_resolution_min = value; + } if (video.contains("resolution_reference_lines")) { const auto n = toml::find(video, "resolution_reference_lines"); if (n < 120 || n > 1024) { @@ -2679,6 +2701,25 @@ UserSettings load_user_settings(const fs::path& path) { } } }); + if (v.contains("dynamic_resolution")) try_get([&]{ + s.dynamic_resolution = toml::find(v, "dynamic_resolution"); + s.has_dynamic_resolution = true; + }); + if (v.contains("dynamic_resolution_min")) try_get([&]{ + const toml::value& ir = toml::find(v, "dynamic_resolution_min"); + int value = 0; + if (ir.is_string()) { + if (psx_ir_parse(ir.as_string().str.c_str(), &value) && + value != PSX_IR_DISPLAY) { + s.dynamic_resolution_min = value; s.has_dynamic_resolution_min = true; + } + } else if (ir.is_integer()) { + const auto n = ir.as_integer(); + if (n >= PSX_IR_MIN_LINES && n <= PSX_IR_MAX_LINES) { + s.dynamic_resolution_min = (int)n; s.has_dynamic_resolution_min = true; + } + } + }); if (v.contains("window_width")) try_get([&]{ const auto n = toml::find(v, "window_width"); if (n >= 640 && n <= 7680) { s.window_width = (int)n; s.has_window_width = true; } @@ -3063,6 +3104,17 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { else f << "internal_resolution = " << s.internal_resolution << "\n"; } + if (s.has_dynamic_resolution) + f << "dynamic_resolution = " << (s.dynamic_resolution ? "true" : "false") << "\n"; + if (s.has_dynamic_resolution_min && + psx_ir_value_valid(s.dynamic_resolution_min) && + s.dynamic_resolution_min != PSX_IR_DISPLAY) { + const char* id = psx_ir_id_for(s.dynamic_resolution_min); + if (id) + f << "dynamic_resolution_min = \"" << id << "\"\n"; + else + f << "dynamic_resolution_min = " << s.dynamic_resolution_min << "\n"; + } if (s.has_window_width) f << "window_width = " << s.window_width << "\n"; if (s.has_antialiasing) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 60abeb92..6c384a0f 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -427,6 +427,14 @@ struct RuntimeConfig { // game.toml it is the shipped default ("native", "720p", "1080p", // "1440p", "4k", "5k", "8k", "display", or a number of lines). int video_internal_resolution = 0; + // dynamic_resolution: OpenGL only. Keep the internal resolution above as + // the ceiling and step the scale down (whole integer levels) only while + // the game would otherwise miss frames, back up when there is headroom + // (runtime/include/dynamic_resolution.h). Off by default; a title opts + // in. dynamic_resolution_min: the lowest level, a preset value as for + // internal_resolution (720 = "720p"; 1 = native). + bool video_dynamic_resolution = false; + int video_dynamic_resolution_min = 720; // resolution_reference_lines: the title's usual display height, which a // preset divides into (S = ceil(target / reference)). 240 for NTSC // 320x240 games; 120..1024. @@ -1380,6 +1388,11 @@ struct UserSettings { // it wins over supersampling, which is still written (capped at 4) so an // older runtime reading the same file degrades gracefully. bool has_internal_resolution = false; int internal_resolution = 0; + // Dynamic resolution (RuntimeConfig::video_dynamic_resolution, _min). + // Written only once the player changed them, so a title's default can + // still move in a later release. + bool has_dynamic_resolution = false; bool dynamic_resolution = false; + bool has_dynamic_resolution_min = false; int dynamic_resolution_min = 720; // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to // both the launcher and the emulator window so they boot at the same size. bool has_window_width = false; int window_width = 1280; // -> 1280x960 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 014d978c..7882f8c1 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1171,6 +1171,16 @@ if(BUILD_TESTING) target_link_libraries(render_pass_projection_test PRIVATE m) endif() add_test(NAME render_pass_projection_test COMMAND render_pass_projection_test) + # Dynamic resolution's controller against synthetic load: step rules, + # hysteresis, transient spikes, CPU-bound stretches, holds, the floor. + add_executable(dynamic_resolution_test + tests/test_dynamic_resolution.c + src/dynamic_resolution.c) + target_include_directories(dynamic_resolution_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(dynamic_resolution_test PRIVATE m) + endif() + add_test(NAME dynamic_resolution_test COMMAND dynamic_resolution_test) # Render-pass time freeze against mock devices (psx_cycles.c only). add_executable(render_pass_freeze_test diff --git a/runtime/include/dynamic_resolution.h b/runtime/include/dynamic_resolution.h new file mode 100644 index 00000000..afffd026 --- /dev/null +++ b/runtime/include/dynamic_resolution.h @@ -0,0 +1,138 @@ +/* dynamic_resolution.h — the controller behind [video] dynamic_resolution + * (docs/ENHANCEMENTS.md, IR3). + * + * Pure C: no SDL, no GL and no clock of its own, so a unit test drives it + * with synthetic time (tests/test_dynamic_resolution.c). The host feeds it one + * sample per guest interval (one VBlank) and applies the level it returns; + * the GL backend changes the internal scale between frames + * (gl_renderer_step_internal_scale_now). + * + * LOAD. A sample's work is the interval's wall time minus the host's idle + * waits (wall-clock pacer, the frame blend's waits for its next present, the + * driver's vsync block in the swap), render passes and the frame blend's extra + * presents: emulation, submission, the frame's own present and any driver + * blocking. load = work / the guest's nominal interval, averaged over a + * window (0.5 s). In-between frames only spend idle time and the load leaves + * them out, so when a scene gets heavier they are shed first; the resolution + * drops only when the game's own frames would be late. + * + * LEVELS are the integer scales floor..ceiling. A level's load is predicted + * from the current one as load * ((1 - f) + f * (S'/S)^2), f being the share + * of the load that scales with the pixel count. f starts at a prior and is + * learned from what each step actually changed. + * + * RULES (DynresParams; defaults in dynres_default_params): + * - down: a window at >= down_load with >= down_late late intervals, or two + * windows above down_load_sustained. Target: the highest level predicted at + * <= target_load (at least one lower). No second down step within + * cooldown_down_s unless a window has >= burst_late late intervals. + * - verify: the second window after a down step must show at least + * verify_fraction of the predicted fall. Otherwise the pressure was not the + * resolution (a CPU-bound stretch): the step is undone after verify_undo_s + * and down steps are blocked for verify_block_s, doubling per failure. + * - up: one level, after up_after_s of windows predicted at <= up_load at the + * next level with no late interval, the cooldown since the last step over, + * the level not blocked, and only at an interval whose idle time covers the + * measured step cost. + * - relapse: a level reached by an up step and left by a down step within + * relapse_s is blocked for up steps for relapse_block_s, doubling up to + * relapse_block_max_s; an up step that holds the level for + * relapse_forget_s resets that. + * - holds: the host's holds (turbo, loads, FMV, compiles, savestate loads, + * resizes, the first seconds of the game), and any interval longer than + * gap_factor intervals, discard the window in progress; nothing is sampled + * or decided until the hold's tail has passed. A single slow interval never + * steps: every decision needs a whole window. + */ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#define DYNRES_MAX_LEVEL 32 + +typedef struct DynresParams { + double window_s; /* decision window, guest time */ + double late_factor; /* interval > period * this is late */ + double gap_factor; /* interval > period * this: a gap, held */ + double gap_hold_s; + double down_load; /* with down_late late intervals */ + int down_late; + double down_load_sustained; /* two windows above it */ + double target_load; + double cooldown_down_s; + int burst_late; + double verify_fraction; + double verify_undo_s; + double verify_block_s, verify_block_max_s, verify_forget_s; + double up_load; + double up_after_s; + double cooldown_up_after_up_s, cooldown_up_after_down_s; + double relapse_s; + double relapse_block_s, relapse_block_max_s, relapse_forget_s; + double prior_scaled; /* f before any step was measured */ + double learn_rate; + double step_cost_s; /* step cost before one was measured */ +} DynresParams; + +void dynres_default_params(DynresParams *p); + +typedef struct DynresSample { + double period_s; /* the guest's nominal interval (1 / 59.94 for NTSC) */ + double wall_s; /* this interval's wall time */ + double work_s; /* see LOAD above */ + int held; /* the host holds: not a sample, the window restarts */ +} DynresSample; + +typedef struct DynresController { + DynresParams p; + int floor, ceiling, level, forced; + double f; /* learned share of load ~ S^2 */ + /* the window in progress */ + double win_period, win_work, win_wall; + int win_n, win_late; + /* the last closed window */ + double last_load, last_vblank_hz; + int last_late, last_valid; + double prev_load; /* the window before it (-1 = none) */ + double hold_until; + double up_streak_s; + int up_ready; + double last_step_t, last_down_t; + int last_step_up; + double up_reached_t[DYNRES_MAX_LEVEL + 1]; + double up_block_until[DYNRES_MAX_LEVEL + 1]; + double relapse_dur[DYNRES_MAX_LEVEL + 1]; + double down_block_until, verify_dur, verify_last_fail; + /* after a step: the window it is judged on */ + int post_active, post_from, post_to, post_windows, post_down; + double post_load_before, post_pred; + int undo_level; + double undo_at; + double step_cost_s; + /* telemetry */ + unsigned long long downs, ups, undos, relapses, windows, held_windows; + const char *last_reason; + double last_decision_t; +} DynresController; + +void dynres_init(DynresController *c, const DynresParams *p, int floor_level, + int ceiling, int level); +/* Hold sampling and decisions until now_s + tail_s (the latest hold wins). */ +void dynres_hold(DynresController *c, double now_s, double tail_s); +/* One guest interval ending at now_s. Returns the level to render at (the + * current one when nothing changes; the host applies a change). */ +int dynres_sample(DynresController *c, double now_s, const DynresSample *s); +/* The measured wall time of a step the host applied. */ +void dynres_note_step_cost(DynresController *c, double seconds); +/* Pin a level (debugging); 0 releases the pin. Returns the level to apply. */ +int dynres_force(DynresController *c, int level); +/* Predicted load at level `to` from `load` measured at `from`. */ +double dynres_predict(const DynresController *c, double load, int from, int to); +/* Up steps into `level` blocked for this many seconds from now_s (0 = not). */ +double dynres_up_blocked_s(const DynresController *c, int level, double now_s); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5090635c..a506e31b 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -393,6 +393,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/render_pass_motion.c ${PSXRECOMP_ROOT}/runtime/src/render_pass_projection.cpp ${PSXRECOMP_ROOT}/runtime/src/render_pass_frame.c + ${PSXRECOMP_ROOT}/runtime/src/dynamic_resolution.c ${PSXRECOMP_ROOT}/runtime/src/host_time.c ${PSXRECOMP_ROOT}/runtime/src/psx_fiber.c ${PSXRECOMP_ROOT}/runtime/src/sio.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index ff44bfe7..1724ec38 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -10057,6 +10057,79 @@ static void handle_hd_textures(int id, const char *json) (unsigned long long)info.pending_dump_sources, diagnostic_json); } +/* Dynamic internal resolution ([video] dynamic_resolution; docs/TCP_COMMANDS.md). + * dynres: the controller's state and the last step. */ +static void handle_dynres(int id, const char *json) +{ + (void)json; + extern int psx_dynres_status_json(char *out, int cap); + char body[2048]; + psx_dynres_status_json(body, (int)sizeof body); + send_fmt("{\"id\":%d,\"ok\":true,%s}", id, body); +} + +/* dynres_force {"scale":N}: pin the level (clamped to floor..ceiling); + * {"scale":0} releases the pin. */ +static void handle_dynres_force(int id, const char *json) +{ + extern int psx_dynres_force(int scale); + int s = json_get_int(json, "scale", -1); + if (s < 0) { send_err(id, "need scale (0 releases)"); return; } + int l = psx_dynres_force(s); + if (l < 0) { send_err(id, "dynamic resolution is not on"); return; } + send_fmt("{\"id\":%d,\"ok\":true,\"level\":%d,\"forced\":%d}", id, l, s > 0); +} + +/* FNV-1a over the guest-visible VRAM (the CPU array after a GL sync). */ +static uint64_t dynres_vram_hash(void) { + gl_renderer_sync_cpu(); + uint64_t h = 1469598103934665603ull; + for (int y = 0; y < 512; y++) + for (int x = 0; x < 1024; x++) { + uint16_t p = gpu_vram_peek(x, y); + h = (h ^ (p & 0xFF)) * 1099511628211ull; + h = (h ^ (p >> 8)) * 1099511628211ull; + } + return h; +} + +/* dynres_check {"seq":"9,8,3,10"}: hash the guest-visible VRAM, step through + * seq inside this one handler (no guest code runs in between), hash after + * each step, then step back to the level it started at. Any change is a + * mismatch. The regression probe for runtime scale steps. */ +static void handle_dynres_check(int id, const char *json) +{ + char seq[256]; + if (!json_get_str(json, "seq", seq, sizeof(seq))) { send_err(id, "need seq"); return; } + GlDynresStats st; + gl_renderer_dynres_stats(&st); + if (st.ceiling < 2) { send_err(id, "runtime scale steps are not available"); return; } + const int start = st.level; + const uint64_t h0 = dynres_vram_hash(); + char out[4096]; + int pos = 0, bad = 0, first = 1; + pos += snprintf(out + pos, sizeof(out) - (size_t)pos, "["); + char *save = NULL; + for (char *t = strtok_r(seq, ",", &save); t && pos < (int)sizeof(out) - 160; + t = strtok_r(NULL, ",", &save)) { + int s = atoi(t); + int ok = gl_renderer_step_internal_scale_now(s); + gl_renderer_dynres_stats(&st); + uint64_t h = dynres_vram_hash(); + if (h != h0) bad++; + pos += snprintf(out + pos, sizeof(out) - (size_t)pos, + "%s{\"to\":%d,\"ok\":%d,\"level\":%d,\"same\":%d,\"ms\":%.3f," + "\"rects\":%d}", first ? "" : ",", s, ok, st.level, h == h0, + st.last_ms, st.last_rects); + first = 0; + } + pos += snprintf(out + pos, sizeof(out) - (size_t)pos, "]"); + (void)gl_renderer_step_internal_scale_now(start); + if (dynres_vram_hash() != h0) bad++; + send_fmt("{\"id\":%d,\"ok\":true,\"hash0\":\"%016llx\",\"mismatches\":%d," + "\"restored\":%d,\"steps\":%s}", id, (unsigned long long)h0, bad, start, out); +} + static void handle_video_info(int id, const char *json) { (void)json; @@ -10070,7 +10143,11 @@ static void handle_video_info(int id, const char *json) GpuDisplayInfo di; gpu_get_display_info(&di); int eff = gr_scale(); - send_fmt("{\"id\":%d,\"ok\":true,\"backend\":\"%s\",\"preset\":%d," + extern void psx_dynres_summary(int *enabled, int *level, int *floor_s, int *ceiling); + int dyn_on = 0, dyn_level = 0, dyn_floor = 0, dyn_ceiling = 0; + psx_dynres_summary(&dyn_on, &dyn_level, &dyn_floor, &dyn_ceiling); + send_fmt("{\"id\":%d,\"ok\":true,\"dynamic\":%d,\"level\":%d,\"floor\":%d," + "\"alloc_scale\":%d,\"backend\":\"%s\",\"preset\":%d," "\"reference_lines\":%d,\"requested_scale\":%d,\"effective_scale\":%d,\"texture_filter\":%d," "\"internal_lines\":%u,\"gl\":%d,\"gl_max_dim\":%d,\"gl_max_scale\":%d," "\"gl_swap_interval\":%d," @@ -10082,7 +10159,8 @@ static void handle_video_info(int id, const char *json) "\"hires_window_x\":%d,\"hires_window_w\":%d,\"hires_fbo_w\":%d," "\"hires_fbo_h\":%d,\"hires_window_grows\":%d,\"hires_window_tiles\":%d," "\"hires_window_mib\":%d}", - id, gr_backend() == GR_BACKEND_OPENGL ? "opengl" + id, dyn_on, dyn_level, dyn_floor, gl ? si.alloc_scale : 0, + gr_backend() == GR_BACKEND_OPENGL ? "opengl" : gr_backend() == GR_BACKEND_VULKAN ? "vulkan" : "software", preset, ref, req, eff, gr_texture_filter(), di.height * (unsigned)(eff > 0 ? eff : 1), gl, si.max_dim, si.max_scale, gl_renderer_get_swap_interval(), si.clamp_reason, si.alloc_retries, si.budget_mib, @@ -15627,6 +15705,9 @@ static const CmdEntry s_commands[] = { { "wide_full", handle_wide_full }, { "video_info", handle_video_info }, { "hd_textures", handle_hd_textures }, + { "dynres", handle_dynres }, + { "dynres_force", handle_dynres_force }, + { "dynres_check", handle_dynres_check }, { "screenshot_wide_hires", handle_screenshot_wide_hires }, { "wide_shot", handle_wide_shot }, { "gpu_opcodes", handle_gpu_opcodes }, diff --git a/runtime/src/dynamic_resolution.c b/runtime/src/dynamic_resolution.c new file mode 100644 index 00000000..c1285cf3 --- /dev/null +++ b/runtime/src/dynamic_resolution.c @@ -0,0 +1,301 @@ +/* dynamic_resolution.c — see dynamic_resolution.h. */ +#include "dynamic_resolution.h" + +#include + +void dynres_default_params(DynresParams *p) { + memset(p, 0, sizeof *p); + p->window_s = 0.5; + p->late_factor = 1.10; + p->gap_factor = 4.0; + p->gap_hold_s = 1.0; + p->down_load = 0.90; + p->down_late = 2; + p->down_load_sustained = 0.92; + p->target_load = 0.80; + p->cooldown_down_s = 2.0; + p->burst_late = 6; + p->verify_fraction = 0.30; + p->verify_undo_s = 1.0; + p->verify_block_s = 30.0; + p->verify_block_max_s = 480.0; + p->verify_forget_s = 300.0; + p->up_load = 0.75; + p->up_after_s = 3.0; + p->cooldown_up_after_up_s = 3.0; + p->cooldown_up_after_down_s = 5.0; + p->relapse_s = 10.0; + p->relapse_block_s = 10.0; + p->relapse_block_max_s = 160.0; + p->relapse_forget_s = 60.0; + p->prior_scaled = 0.6; + p->learn_rate = 0.3; + p->step_cost_s = 0.004; +} + +static int clamp_level(const DynresController *c, int l) { + if (l < c->floor) l = c->floor; + if (l > c->ceiling) l = c->ceiling; + return l; +} + +void dynres_init(DynresController *c, const DynresParams *p, int floor_level, + int ceiling, int level) { + memset(c, 0, sizeof *c); + if (p) c->p = *p; else dynres_default_params(&c->p); + if (ceiling < 1) ceiling = 1; + if (ceiling > DYNRES_MAX_LEVEL) ceiling = DYNRES_MAX_LEVEL; + if (floor_level < 1) floor_level = 1; + if (floor_level > ceiling) floor_level = ceiling; + c->floor = floor_level; + c->ceiling = ceiling; + c->level = clamp_level(c, level); + c->f = c->p.prior_scaled; + c->prev_load = -1.0; + c->last_step_t = -1e9; + c->last_down_t = -1e9; + c->verify_last_fail = -1e9; + c->verify_dur = c->p.verify_block_s; + c->step_cost_s = c->p.step_cost_s; + for (int i = 0; i <= DYNRES_MAX_LEVEL; i++) { + c->up_reached_t[i] = -1e9; + c->relapse_dur[i] = c->p.relapse_block_s; + } + c->last_reason = "start"; +} + +double dynres_predict(const DynresController *c, double load, int from, int to) { + if (from < 1 || to < 1) return load; + double q = ((double)to * (double)to) / ((double)from * (double)from); + return load * ((1.0 - c->f) + c->f * q); +} + +double dynres_up_blocked_s(const DynresController *c, int level, double now_s) { + if (level < 0 || level > DYNRES_MAX_LEVEL) return 0.0; + double r = c->up_block_until[level] - now_s; + return r > 0.0 ? r : 0.0; +} + +static void window_reset(DynresController *c) { + c->win_period = c->win_work = c->win_wall = 0.0; + c->win_n = c->win_late = 0; +} + +/* Everything that needs consecutive clean windows starts over. */ +static void discard(DynresController *c) { + if (c->win_n) c->held_windows++; + window_reset(c); + c->prev_load = -1.0; + c->up_streak_s = 0.0; + c->up_ready = 0; + /* A step's effect is judged on clean windows only: drop the judgement + * (no learning, no undo) rather than blame a hold on the resolution. */ + c->post_active = 0; +} + +void dynres_hold(DynresController *c, double now_s, double tail_s) { + double until = now_s + (tail_s > 0.0 ? tail_s : 0.0); + if (until > c->hold_until) c->hold_until = until; + discard(c); +} + +void dynres_note_step_cost(DynresController *c, double seconds) { + if (seconds <= 0.0) return; + c->step_cost_s = c->step_cost_s + 0.3 * (seconds - c->step_cost_s); +} + +int dynres_force(DynresController *c, int level) { + if (level <= 0) { + c->forced = 0; + c->last_reason = "force released"; + discard(c); + return c->level; + } + c->forced = clamp_level(c, level); + c->level = c->forced; + c->post_active = 0; + c->undo_level = 0; + c->last_reason = "forced"; + discard(c); + return c->level; +} + +static void learn(DynresController *c, double before, double after, int from, int to) { + if (before <= 0.05 || from == to) return; + double q = ((double)to * (double)to) / ((double)from * (double)from); + double fo = (after / before - 1.0) / (q - 1.0); + if (fo < 0.1) fo = 0.1; + if (fo > 0.95) fo = 0.95; + c->f += c->p.learn_rate * (fo - c->f); +} + +static void begin_post(DynresController *c, int from, int to, double load_before, + int down) { + c->post_active = 1; + c->post_from = from; + c->post_to = to; + c->post_windows = 0; + c->post_down = down; + c->post_load_before = load_before; + c->post_pred = dynres_predict(c, load_before, from, to); +} + +static int step_down(DynresController *c, double now, double load, const char *why) { + int from = c->level, to = c->floor; + for (int l = from - 1; l >= c->floor; l--) + if (dynres_predict(c, load, from, l) <= c->p.target_load) { to = l; break; } + /* Relapse: an up step into this level failed within relapse_s. */ + if (now - c->up_reached_t[from] < c->p.relapse_s) { + c->up_block_until[from] = now + c->relapse_dur[from]; + c->relapse_dur[from] *= 2.0; + if (c->relapse_dur[from] > c->p.relapse_block_max_s) + c->relapse_dur[from] = c->p.relapse_block_max_s; + c->relapses++; + } + c->up_reached_t[from] = -1e9; + begin_post(c, from, to, load, 1); + c->level = to; + c->last_step_t = c->last_down_t = now; + c->last_step_up = 0; + c->up_streak_s = 0.0; + c->up_ready = 0; + c->prev_load = -1.0; + c->downs++; + c->last_reason = why; + c->last_decision_t = now; + return c->level; +} + +static int step_up(DynresController *c, double now, double load, int to, const char *why) { + int from = c->level; + begin_post(c, from, to, load, 0); + c->level = to; + c->up_reached_t[to] = now; + c->last_step_t = now; + c->last_step_up = 1; + c->up_streak_s = 0.0; + c->up_ready = 0; + c->prev_load = -1.0; + c->ups++; + c->last_reason = why; + c->last_decision_t = now; + return c->level; +} + +/* A window closed: judge a recent step, then the rules. */ +static int close_window(DynresController *c, double now) { + const double load = c->win_work / c->win_period; + const int late = c->win_late; + const double dur = c->win_period; + c->last_load = load; + c->last_late = late; + c->last_vblank_hz = c->win_wall > 0.0 ? (double)c->win_n / c->win_wall : 0.0; + c->last_valid = 1; + c->windows++; + window_reset(c); + + if (c->post_active && ++c->post_windows >= 2) { + /* The first window after a step settles; the second judges it. */ + c->post_active = 0; + double fell = c->post_load_before - load; + double want = c->post_load_before - c->post_pred; + if (c->post_down && want > 0.0 && fell < c->p.verify_fraction * want) { + /* Not resolution-bound: undo, and block down steps a while. */ + if (now - c->verify_last_fail > c->p.verify_forget_s) + c->verify_dur = c->p.verify_block_s; + c->down_block_until = now + c->verify_dur; + c->verify_dur *= 2.0; + if (c->verify_dur > c->p.verify_block_max_s) + c->verify_dur = c->p.verify_block_max_s; + c->verify_last_fail = now; + c->undo_level = c->post_from; + c->undo_at = now + c->p.verify_undo_s; + c->last_reason = "down step did not lower the load (not resolution-bound)"; + c->last_decision_t = now; + } else { + learn(c, c->post_load_before, load, c->post_from, c->post_to); + } + } + if (c->undo_level && now >= c->undo_at) { + int to = clamp_level(c, c->undo_level); + c->undo_level = 0; + if (to != c->level) { + c->undos++; + c->level = to; + c->last_step_t = now; + c->last_step_up = 1; + c->prev_load = -1.0; + c->up_streak_s = 0.0; + c->up_ready = 0; + c->last_reason = "undo"; + c->last_decision_t = now; + return c->level; + } + } + + /* A level an up step reached and kept for relapse_forget_s is stable: + * its next relapse starts the back-off over. */ + if (now - c->up_reached_t[c->level] >= c->p.relapse_forget_s) { + c->relapse_dur[c->level] = c->p.relapse_block_s; + c->up_reached_t[c->level] = -1e9; + } + const double prev = c->prev_load; + c->prev_load = load; + if (c->post_active || c->undo_level) return c->level; /* judging a step */ + + int over = (load >= c->p.down_load && late >= c->p.down_late) || + (load > c->p.down_load_sustained && prev > c->p.down_load_sustained); + if (over) { + c->up_streak_s = 0.0; + c->up_ready = 0; + if (c->level > c->floor && now >= c->down_block_until && + (now - c->last_down_t >= c->p.cooldown_down_s || late >= c->p.burst_late)) + return step_down(c, now, load, + late >= c->p.down_late ? "busy with late frames" + : "busy for two windows"); + return c->level; + } + if (c->level >= c->ceiling) { c->up_streak_s = 0.0; c->up_ready = 0; return c->level; } + const int next = c->level + 1; + if (late == 0 && dynres_predict(c, load, c->level, next) <= c->p.up_load) + c->up_streak_s += dur; + else { + c->up_streak_s = 0.0; + c->up_ready = 0; + } + double cd = c->last_step_up ? c->p.cooldown_up_after_up_s : c->p.cooldown_up_after_down_s; + c->up_ready = c->up_streak_s >= c->p.up_after_s - 1e-9 && + now - c->last_step_t >= cd && + now >= c->up_block_until[next]; + return c->level; +} + +int dynres_sample(DynresController *c, double now_s, const DynresSample *s) { + if (c->forced) return c->level; + if (!s || s->period_s <= 0.0) return c->level; + if (s->held || now_s < c->hold_until) { + if (s->held) discard(c); + else if (c->win_n) discard(c); + return c->level; + } + if (s->wall_s > s->period_s * c->p.gap_factor) { + /* A pause, a stall in the host, a window drag: not the scene. */ + dynres_hold(c, now_s, c->p.gap_hold_s); + c->last_reason = "gap"; + return c->level; + } + double work = s->work_s < 0.0 ? 0.0 : s->work_s; + if (c->up_ready) { + /* Up steps land in an interval whose idle time covers the step. */ + double idle = s->period_s - work; + if (idle >= c->step_cost_s && c->level < c->ceiling) + return step_up(c, now_s, c->last_load, c->level + 1, "headroom"); + } + c->win_period += s->period_s; + c->win_work += work; + c->win_wall += s->wall_s; + c->win_n++; + if (s->wall_s > s->period_s * c->p.late_factor) c->win_late++; + if (c->win_period >= c->p.window_s - 1e-9) return close_window(c, now_s); + return c->level; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 15afef91..49a29bd9 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -11,6 +11,7 @@ #include "mod_controller_source.h" #include "window_size.h" /* default game-window size */ #include "internal_resolution.h" /* Settings -> Display -> Internal resolution */ +#include "dynamic_resolution.h" /* [video] dynamic_resolution: the step controller */ #include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */ #include "parity_trace.h" /* general two-process control-flow parity ring */ #include "device_trace.h" /* general two-process device-event cycle ring */ @@ -486,6 +487,7 @@ static Smooth60State g_smooth_60_state; * survive soft-return and poison FMV/FPS after session_reboot). */ static bool s_disabled_frame_presented = false; static bool s_force_present_after_load = false; +static void dynres_note_savestate_loaded(void); /* dynamic resolution hold */ /* §33 SW hold-last: sdl_texture is 640x512; Live only uploads the active * display rect. Resim must reuse that src/dst — RenderCopy(NULL,NULL) sticks * the image in the upper-left corner (user-confirmed). */ @@ -1065,6 +1067,7 @@ extern "C" void psx_frontend_on_savestate_loaded(void) { psx_projection_reset_session(); psx_local_mouse_reset(); mod_runtime_on_savestate_loaded(); + dynres_note_savestate_loaded(); #ifndef PSX_NO_DEBUG_TOOLS debug_server_note_savestate_loaded(); #endif @@ -1287,6 +1290,40 @@ static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; * persisted, so two local peers sharing one settings.toml stay independent. */ static int g_video_internal_res = PSX_IR_UNSET; static int g_video_internal_res_env = PSX_IR_UNSET; +/* Dynamic resolution ([video] dynamic_resolution, dynamic_resolution_min; + * dynamic_resolution.h): the internal resolution above is the ceiling, and + * the GL scale steps down to the floor only while the game would miss frames. + * Same precedence as the preset (game.toml < settings.toml < launcher); + * PSX_DYNRES=0/1 and PSX_DYNRES_MIN win for one run, never persisted. */ +static int g_video_dynres = 0; +static int g_video_dynres_min = 720; +static int g_video_dynres_env = -1; +static int g_video_dynres_min_env = PSX_IR_UNSET; +/* The controller and its per-interval books (dynres_tick). */ +struct DynresHost { + bool active = false; + DynresController ctl{}; + uint64_t last_t = 0; + uint64_t last_pacer = 0; + GlHostLedger last_ledger{}; + uint64_t pacer_ticks = 0; /* frame_pacer_wait, while active */ + uint64_t step_ticks = 0; /* the step taken after the last sample */ + int drawable_w = 0, drawable_h = 0; + bool game_started = false; + double hold_request_s = 0.0; /* asked by an event (savestate load) */ + const char *hold_reason = ""; + unsigned long long last_windows = 0; + /* the last step, for the trace and the dynres command */ + int step_from = 0, step_to = 0; + double step_ms = 0.0, step_interval_ms = 0.0; + bool step_measure_next = false; + FILE *trace = nullptr; + double t0_s = 0.0; +}; +static DynresHost g_dynres; +/* A savestate load re-stages VRAM and re-anchors pacing: hold for a while. */ +static void dynres_note_savestate_loaded(void) { g_dynres.hold_request_s = 2.0; } + static int g_video_ref_lines = PSX_IR_DEFAULT_REF_LINES; /* The scale asked of the backend before its own clamp (GL reports its real * scale only after context init), and whether that request applies (netplay @@ -8520,8 +8557,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * resim. */ if (!psx_netplay_active() && !psx_selfcheck_resim_active()) { uint64_t perf_start = runtime_perf_section_begin(); - if (!manual_turbo_active && !turbo_load_paced && present_should_wall_pace()) + if (!manual_turbo_active && !turbo_load_paced && present_should_wall_pace()) { + const uint64_t dyn_t0 = g_dynres.active ? SDL_GetPerformanceCounter() : 0; frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); + if (dyn_t0) g_dynres.pacer_ticks += SDL_GetPerformanceCounter() - dyn_t0; + } runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); latency_ring_mark(LAT_PACED); @@ -9138,6 +9178,230 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { return ep; } +/* ---- Dynamic internal resolution: the host side --------------------------- + * [video] dynamic_resolution (docs/ENHANCEMENTS.md, IR3). The controller + * (dynamic_resolution.c) decides; this feeds it one sample per guest VBlank, + * after the present, and applies its level through the GL backend. The + * sample is the interval's wall time and its guest work: the wall time minus + * the pacer's wait, the frame blend's waits, the swap's vsync block (counted + * as work when the interval was late and no render pass ran: the driver then + * waited on the GPU, not the display), render passes, the blend's extra + * presents and the step itself. Holds discard samples: turbo, fast-forward + * and turbo loads, FMV, a disabled display, overlay compiles, savestate loads, + * rollback resim, window size changes, and the game's first seconds. */ + +static int dynres_requested(void) { + return g_video_dynres_env >= 0 ? g_video_dynres_env : g_video_dynres; +} + +static int dynres_min_value(void) { + return g_video_dynres_min_env != PSX_IR_UNSET ? g_video_dynres_min_env + : g_video_dynres_min; +} + +/* After GL context init: the ceiling is what the backend allocated, the + * floor the configured minimum as a scale (never above the ceiling). */ +static void dynres_setup(void) { + if (g_dynres.trace) { std::fclose(g_dynres.trace); g_dynres.trace = nullptr; } + g_dynres = DynresHost{}; + const int ceiling = g_gl_active ? gl_renderer_dynamic_resolution_ceiling() : 0; + if (ceiling < 2) { + if (dynres_requested() && g_gl_active) + std::fprintf(stdout, "psxrecomp: dynamic resolution off: the internal " + "scale is fixed here (1x, the high-resolution window, or " + "dual raster)\n"); + return; + } + int floor_s = psx_resolve_internal_scale(dynres_min_value(), g_video_ref_lines, + psx_sdl_display_pixel_height(nullptr), + ceiling); + if (floor_s > ceiling) floor_s = ceiling; + DynresParams params; + dynres_default_params(¶ms); + dynres_init(&g_dynres.ctl, ¶ms, floor_s, ceiling, ceiling); + g_dynres.active = floor_s < ceiling; + if (const char* e = std::getenv("PSX_DYNRES_FORCE")) { + const int n = std::atoi(e); + if (n > 0) { + g_dynres.active = true; + const int l = dynres_force(&g_dynres.ctl, n); + (void)gl_renderer_step_internal_scale_now(l); + } + } + if (const char* e = std::getenv("PSX_DYNRES_TRACE")) + if (*e) g_dynres.trace = std::fopen(e, "w"); + if (g_dynres.trace) + std::fprintf(g_dynres.trace, "t_s,level,load,late,vblank_hz,decision\n"); + g_dynres.t0_s = (double)SDL_GetPerformanceCounter() / + (double)SDL_GetPerformanceFrequency(); + std::fprintf(stdout, "psxrecomp: dynamic resolution %s: %dx..%dx (%d..%d lines)\n", + g_dynres.active ? "on" : "inert (floor = ceiling)", floor_s, ceiling, + floor_s * g_video_ref_lines, ceiling * g_video_ref_lines); +} + +static void dynres_apply_level(int level) { + GlDynresStats st; + gl_renderer_dynres_stats(&st); + if (level == st.level) return; + const int from = st.level; + const uint64_t t0 = SDL_GetPerformanceCounter(); + const int ok = gl_renderer_step_internal_scale_now(level); + const uint64_t t1 = SDL_GetPerformanceCounter(); + const double sec = (double)(t1 - t0) / (double)SDL_GetPerformanceFrequency(); + if (ok) { + dynres_note_step_cost(&g_dynres.ctl, sec); + g_dynres.step_ticks += t1 - t0; + g_dynres.step_from = from; + g_dynres.step_to = level; + g_dynres.step_ms = sec * 1000.0; + g_dynres.step_measure_next = true; + } +} + +/* One guest VBlank, after its present. */ +static void dynres_tick(void) { + if (!g_dynres.active || !g_gl_active) return; + const uint64_t now = SDL_GetPerformanceCounter(); + const double freq = (double)SDL_GetPerformanceFrequency(); + GlHostLedger led; + gl_renderer_host_ledger(&led); + const double now_s = (double)now / freq; + if (!g_dynres.last_t) { + g_dynres.last_t = now; + g_dynres.last_ledger = led; + g_dynres.last_pacer = g_dynres.pacer_ticks; + return; + } + const GlHostLedger &p0 = g_dynres.last_ledger; + const double wall = (double)(now - g_dynres.last_t) / freq; + const double period = g_frame_period_ms / 1000.0; + const double pacer = (double)(g_dynres.pacer_ticks - g_dynres.last_pacer) / freq; + const double idle = (double)(led.idle_ticks - p0.idle_ticks) / freq; + const double pass = (double)(led.pass_ticks - p0.pass_ticks) / freq; + const double swap = (double)(led.swap_ticks - p0.swap_ticks) / freq; + const uint64_t blends = led.interp_presents - p0.interp_presents; + const double blend_work = (double)(led.interp_work_ticks - p0.interp_work_ticks) / freq; + const double extra = blends > 1 ? blend_work * (double)(blends - 1) / (double)blends : 0.0; + const double step = (double)g_dynres.step_ticks / freq; + const bool late = wall > period * g_dynres.ctl.p.late_factor; + const double swap_idle = (late && pass <= 0.0) ? 0.0 : swap; + const double work = wall - pacer - idle - swap_idle - pass - extra - step; + g_dynres.last_t = now; + g_dynres.last_ledger = led; + g_dynres.last_pacer = g_dynres.pacer_ticks; + g_dynres.step_ticks = 0; + if (g_dynres.step_measure_next) { + g_dynres.step_measure_next = false; + g_dynres.step_interval_ms = wall * 1000.0; + if (g_dynres.trace) + std::fprintf(g_dynres.trace, "%.3f,%d,,,,step %d->%d %.2f ms interval %.2f ms\n", + now_s - g_dynres.t0_s, g_dynres.step_to, g_dynres.step_from, + g_dynres.step_to, g_dynres.step_ms, g_dynres.step_interval_ms); + } + + /* Holds. */ + double tail = 0.0; + int held = 0; + const char *why = nullptr; + GpuDisplayInfo di; + gpu_get_display_info(&di); + const bool started = fntrace_is_game_started() != 0; + if (!started) { held = 1; why = "boot"; } + else if (!g_dynres.game_started) { tail = 5.0; why = "game entry"; } + g_dynres.game_started = started; + if (s_presentation_fast_forward) { held = 1; tail = 1.0; why = "fast-forward"; } + if (di.disabled || di.depth24 || mdec_recently_active(2)) { + held = 1; tail = 1.0; why = "FMV or no display"; + } + if (autocompile_busy()) { held = 1; tail = 1.0; why = "overlay compile"; } + if (psx_netplay_is_resimulating()) { held = 1; tail = 1.0; why = "resim"; } +#ifndef PSX_SDL_NO_RENDER + if (sdl_window) { + int dw = 0, dh = 0; + SDL_GL_GetDrawableSize(sdl_window, &dw, &dh); + if (dw != g_dynres.drawable_w || dh != g_dynres.drawable_h) { + if (g_dynres.drawable_w) { tail = 1.0; why = "window resize"; } + g_dynres.drawable_w = dw; + g_dynres.drawable_h = dh; + } + } +#endif + if (g_dynres.hold_request_s > 0.0) { + if (g_dynres.hold_request_s > tail) tail = g_dynres.hold_request_s; + why = "savestate load"; + g_dynres.hold_request_s = 0.0; + } + if (tail > 0.0) dynres_hold(&g_dynres.ctl, now_s, tail); + if (why) g_dynres.hold_reason = why; + else if (now_s >= g_dynres.ctl.hold_until) g_dynres.hold_reason = ""; + + DynresSample smp{ period, wall, work, held }; + const int level = dynres_sample(&g_dynres.ctl, now_s, &smp); + if (g_dynres.trace && g_dynres.ctl.windows != g_dynres.last_windows) { + g_dynres.last_windows = g_dynres.ctl.windows; + std::fprintf(g_dynres.trace, "%.3f,%d,%.3f,%d,%.2f,%s\n", now_s - g_dynres.t0_s, + g_dynres.ctl.level, g_dynres.ctl.last_load, g_dynres.ctl.last_late, + g_dynres.ctl.last_vblank_hz, + g_dynres.ctl.last_decision_t == now_s ? g_dynres.ctl.last_reason : ""); + std::fflush(g_dynres.trace); + } + dynres_apply_level(level); +} + +/* Debug server (dynres, dynres_force, video_info). */ +extern "C" void psx_dynres_summary(int *enabled, int *level, int *floor_s, int *ceiling) { + GlDynresStats st; + gl_renderer_dynres_stats(&st); + if (enabled) *enabled = g_dynres.active ? 1 : 0; + if (level) *level = st.level; + if (floor_s) *floor_s = g_dynres.active ? g_dynres.ctl.floor : 0; + if (ceiling) *ceiling = st.ceiling; +} + +extern "C" int psx_dynres_force(int scale) { + if (!g_dynres.active) return -1; + const int l = dynres_force(&g_dynres.ctl, scale); + dynres_apply_level(l); + return l; +} + +extern "C" int psx_dynres_status_json(char *out, int cap) { + GlDynresStats st; + gl_renderer_dynres_stats(&st); + const DynresController &c = g_dynres.ctl; + const double now_s = (double)SDL_GetPerformanceCounter() / + (double)SDL_GetPerformanceFrequency(); + char blocked[256]; + int bp = 0; + blocked[0] = 0; + for (int l = c.floor; g_dynres.active && l <= c.ceiling && bp < (int)sizeof blocked - 24; l++) { + double b = dynres_up_blocked_s(&c, l, now_s); + if (b > 0.0) + bp += std::snprintf(blocked + bp, sizeof blocked - (size_t)bp, "%s\"%d\":%.1f", + bp ? "," : "", l, b); + } + double hold = c.hold_until - now_s; + return std::snprintf(out, (size_t)cap, + "\"enabled\":%d,\"requested\":%d,\"ceiling\":%d,\"floor\":%d,\"level\":%d," + "\"internal_lines\":%d,\"forced\":%d,\"load\":%.3f,\"late\":%d,\"vblank_hz\":%.2f," + "\"scaled_share\":%.3f,\"hold\":\"%s\",\"hold_s\":%.2f,\"last_reason\":\"%s\"," + "\"downs\":%llu,\"ups\":%llu,\"undos\":%llu,\"relapses\":%llu,\"windows\":%llu," + "\"held_windows\":%llu,\"steps\":%llu,\"deferred\":%llu,\"last_from\":%d," + "\"last_to\":%d,\"last_ms\":%.3f,\"last_interval_ms\":%.3f,\"last_prep_ms\":%.3f," + "\"last_seed_ms\":%.3f,\"last_rects_ms\":%.3f,\"last_wide_ms\":%.3f," + "\"step_cost_ms\":%.3f,\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", + g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, + g_dynres.active ? c.floor : 0, st.level, st.level * g_video_ref_lines, c.forced, + c.last_load, c.last_late, c.last_vblank_hz, c.f, + g_dynres.hold_reason ? g_dynres.hold_reason : "", hold > 0.0 ? hold : 0.0, + c.last_reason ? c.last_reason : "", c.downs, c.ups, c.undos, c.relapses, + c.windows, c.held_windows, (unsigned long long)st.steps, + (unsigned long long)st.deferred, st.last_from, st.last_to, st.last_ms, + g_dynres.step_interval_ms, st.last_prep_ms, st.last_seed_ms, st.last_rects_ms, + st.last_wide_ms, c.step_cost_s * 1000.0, + c.down_block_until > now_s ? c.down_block_until - now_s : 0.0, blocked); +} + static void sdl_vblank_present(void) { sync_guest_cadence_to_video_standard(); NetplayVblankEpilogue ep = sdl_vblank_present_body(); @@ -9145,6 +9409,7 @@ static void sdl_vblank_present(void) { * further guest progress. Longjmps on success — keeps every resim load * on the same VBlank boundary (BB fast-poll tails forked #2 vs #3). */ psx_selfcheck_flush_load(); + dynres_tick(); /* after the present: a scale step lands between frames */ if (!ep.do_epilogue) return; if (!psx_return_to_lobby_requested()) @@ -15208,6 +15473,8 @@ int main(int argc, char** argv) { gc.runtime.video_depth24_trailing_margin; g_video_internal_res = gc.runtime.video_internal_resolution; g_video_ref_lines = gc.runtime.video_resolution_reference_lines; + g_video_dynres = gc.runtime.video_dynamic_resolution ? 1 : 0; + g_video_dynres_min = gc.runtime.video_dynamic_resolution_min; if (gc.runtime.video_window_width > 0) { g_video_win_w = gc.runtime.video_window_width; } @@ -15671,6 +15938,8 @@ int main(int argc, char** argv) { g_video_internal_res = PSX_IR_UNSET; } if (us.has_internal_resolution) g_video_internal_res = us.internal_resolution; + if (us.has_dynamic_resolution) g_video_dynres = us.dynamic_resolution ? 1 : 0; + if (us.has_dynamic_resolution_min) g_video_dynres_min = us.dynamic_resolution_min; if (us.has_window_width) g_video_win_w = us.window_width; if (us.has_antialiasing) g_video_aa = us.antialiasing; if (us.has_texture_filter) g_video_texfilter = us.texture_filter; @@ -17430,6 +17699,14 @@ session_reboot: } apply_internal_resolution(psx_sdl_display_pixel_height(nullptr)); if (g_video_scale < 1) g_video_scale = 1; + if (const char* e = std::getenv("PSX_DYNRES")) + g_video_dynres_env = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_DYNRES_MIN")) { + int v = 0; + if (psx_ir_parse(e, &v) && v != PSX_IR_DISPLAY) g_video_dynres_min_env = v; + else std::fprintf(stdout, "psxrecomp: PSX_DYNRES_MIN=%s not understood " + "(native, 720p, 1080p, 1440p, 4k, 5k, 8k, or lines)\n", e); + } { /* Per-backend ceiling. OpenGL allocates its hr surface at context init * and clamps there to the driver's texture limits and a memory budget @@ -17989,7 +18266,12 @@ session_reboot: "%d px -> %dx requested\n", dh, s); } gl_renderer_set_swap_interval(present_effective_swap_interval()); /* applied at context init */ + /* Dynamic resolution: the surfaces are allocated at the scale above + * (the ceiling) and the level steps under it (dynres_setup). */ + gl_renderer_set_dynamic_resolution( + (dynres_requested() && g_video_scale_applies) ? 1 : 0); g_gl_active = (gl_renderer_init_context(sdl_window) != 0); + dynres_setup(); /* Bezel artwork (Mods): load after the GL context exists. */ if (!g_bezel_path.empty() && g_gl_active) { diff --git a/runtime/tests/test_dynamic_resolution.c b/runtime/tests/test_dynamic_resolution.c new file mode 100644 index 00000000..cd7ffe0a --- /dev/null +++ b/runtime/tests/test_dynamic_resolution.c @@ -0,0 +1,320 @@ +/* The dynamic-resolution controller (dynamic_resolution.c) against synthetic + * guest load: a scene costs base + k * S^2 per interval (S the level), and an + * interval lasts max(period, work) like a wall-paced frame. Checks the step + * rules, hysteresis (no oscillation), that transient spikes and CPU-bound + * stretches never cost resolution for long, holds, the floor and pins. + * Build/run: ctest -R dynamic_resolution_test */ +#include +#include +#include + +#include "dynamic_resolution.h" + +static int failures, checks; +#define CHECK(cond, ...) do { checks++; if (!(cond)) { \ + fprintf(stderr, "FAIL: "); fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); \ + failures++; } } while (0) + +static const double kPeriod = 1.0 / 59.94; + +/* Load model: the load (work / period) at level S at time t. */ +typedef double (*LoadFn)(int level, double t, void *ctx); + +typedef struct Run { + double t; + int level; + int steps, downs, ups; + int min_level, max_level; + double time_at[DYNRES_MAX_LEVEL + 1]; + double last_up_t[64]; + int up_to[64]; + int n_up; +} Run; + +static void run_init(Run *r, int level) { + memset(r, 0, sizeof *r); + r->level = level; + r->min_level = r->max_level = level; +} + +/* Drive c for `seconds` of guest intervals. held_until: the host holds every + * interval before it. */ +static void simulate(DynresController *c, Run *r, double seconds, LoadFn fn, void *ctx, + double held_until) { + double end = r->t + seconds; + while (r->t < end) { + double load = fn(r->level, r->t, ctx); + double work = load * kPeriod; + double wall = work > kPeriod ? work : kPeriod; + r->t += wall; + DynresSample s = { kPeriod, wall, work, r->t < held_until }; + int l = dynres_sample(c, r->t, &s); + r->time_at[r->level] += wall; + if (l != r->level) { + r->steps++; + if (l < r->level) r->downs++; + else if (r->n_up < 64) { + r->ups++; + r->up_to[r->n_up] = l; + r->last_up_t[r->n_up++] = r->t; + } else { + r->ups++; + } + r->level = l; + if (l < r->min_level) r->min_level = l; + if (l > r->max_level) r->max_level = l; + dynres_note_step_cost(c, 0.002); + } + } +} + +/* base + k*S^2 */ +typedef struct Quad { double base, k; } Quad; +static double quad_load(int s, double t, void *ctx) { + (void)t; + const Quad *q = (const Quad *)ctx; + return q->base + q->k * (double)s * (double)s; +} + +static DynresController make(int floor, int ceiling) { + DynresController c; + dynres_init(&c, NULL, floor, ceiling, ceiling); + return c; +} + +static void test_light_scene_stays_at_ceiling(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + Quad q = { 0.30, 0.004 }; /* 0.70 at 10x */ + simulate(&c, &r, 120.0, quad_load, &q, 0.0); + CHECK(r.steps == 0 && r.level == 10, "light scene: %d steps, level %d", r.steps, r.level); +} + +static void test_heavy_scene_settles_without_oscillation(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + Quad q = { 0.35, 0.0065 }; /* 10x 1.00, 9x 0.88, 8x 0.77, 7x 0.67 */ + simulate(&c, &r, 300.0, quad_load, &q, 0.0); + double load = quad_load(r.level, 0, &q); + CHECK(r.level < 10 && load <= 0.90, "heavy scene: settles below the ceiling (level %d, load %.2f)", + r.level, load); + CHECK(r.level >= 7, "heavy scene: no deeper than needed (level %d)", r.level); + CHECK(r.steps <= 6, "heavy scene: %d steps in 300 s (oscillation)", r.steps); + /* After the first 60 s it holds still. */ + int before = r.steps; + simulate(&c, &r, 300.0, quad_load, &q, 0.0); + CHECK(r.steps - before <= 1, "heavy scene: %d steps in the next 300 s", r.steps - before); + CHECK(c.downs >= 1, "heavy scene: stepped down"); +} + +/* A level whose predicted load sits just above the up threshold at the next + * level never flips back up. */ +static void test_hysteresis_band(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + Quad q = { 0.40, 0.0055 }; /* 10x 0.95, 9x 0.85, 8x 0.75 */ + simulate(&c, &r, 600.0, quad_load, &q, 0.0); + CHECK(r.downs >= 1, "band: stepped down from 0.95"); + CHECK(r.ups <= r.downs, "band: ups %d downs %d", r.ups, r.downs); + CHECK(r.steps <= 5, "band: %d steps in 600 s", r.steps); + CHECK(r.level <= 9, "band: stays below the ceiling (level %d)", r.level); +} + +/* The model says the next level fits but it does not: the relapse back-off + * makes each retry wait longer. */ +typedef struct Liar { double at_low, at_high; int high; } Liar; +static double liar_load(int s, double t, void *ctx) { + (void)t; + const Liar *l = (const Liar *)ctx; + return s >= l->high ? l->at_high : l->at_low; +} +static void test_relapse_backoff(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 9); + c.level = 9; + Liar l = { 0.40, 0.97, 10 }; /* 9x is light, 10x is far over */ + simulate(&c, &r, 900.0, liar_load, &l, 0.0); + /* Retries of the failing level: their gaps grow (blocks of 10, 20, 40, + * ... up to 160 s). */ + double at[64]; + int n = 0; + for (int i = 0; i < r.n_up; i++) + if (r.up_to[i] == 10) at[n++] = r.last_up_t[i]; + CHECK(n >= 2, "relapse: retried the ceiling (%d retries)", n); + CHECK(c.relapses >= 2, "relapse: %llu relapses", c.relapses); + int grew = 1; + for (int i = 2; i < n; i++) { + double a = at[i - 1] - at[i - 2], b = at[i] - at[i - 1]; + if (b + 1.0 < a || (a < 150.0 && b < 1.5 * a)) grew = 0; + } + CHECK(grew, "relapse: retries back off"); + CHECK(n <= 9, "relapse: %d retries in 900 s", n); + CHECK(r.time_at[10] < 0.10 * 900.0, "relapse: %.0f s at the failing level", r.time_at[10]); +} + +/* CPU-bound: the load does not depend on the level. Down steps are undone and + * blocked, so the resolution is rarely lowered. */ +static double cpu_bound(int s, double t, void *ctx) { (void)s; (void)t; (void)ctx; return 0.97; } +static void test_cpu_bound_keeps_resolution(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + simulate(&c, &r, 600.0, cpu_bound, NULL, 0.0); + double below = 600.0 - r.time_at[10]; + CHECK(c.undos >= 1, "cpu-bound: a down step was undone (%llu)", c.undos); + CHECK(below < 0.05 * 600.0, "cpu-bound: %.1f s of 600 below the ceiling", below); + CHECK(r.downs <= 6, "cpu-bound: %d down steps in 600 s", r.downs); + CHECK(r.level == 10, "cpu-bound: ends at the ceiling (level %d)", r.level); +} + +/* Transients: one 100 ms stall every 7 s, and three slow intervals every 5 s, + * in a scene at 0.70: never a step. */ +static double spiky(int s, double t, void *ctx) { + (void)s; (void)ctx; + double m7 = fmod(t, 7.0), m5 = fmod(t, 5.0), m11 = fmod(t, 11.0); + if (m7 < kPeriod) return 0.1 / kPeriod; /* one 100 ms interval */ + if (m11 < 0.3) return 0.15 / kPeriod; /* two 150 ms hitches */ + if (m5 < 3.0 * kPeriod * 1.6) return 1.6; /* ~3 late intervals */ + return 0.70; +} +static void test_transient_spikes_ignored(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + simulate(&c, &r, 300.0, spiky, NULL, 0.0); + CHECK(r.steps == 0, "spikes: %d steps", r.steps); +} + +/* Busy but on time: no late frame, under the two-window bar. */ +static double busy_on_time(int s, double t, void *ctx) { (void)s; (void)t; (void)ctx; return 0.91; } +static void test_busy_on_time_keeps_resolution(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + simulate(&c, &r, 120.0, busy_on_time, NULL, 0.0); + CHECK(r.steps == 0, "busy on time: %d steps", r.steps); +} + +/* Headroom must last up_after_s: 2 s light, then half a second heavy. */ +static double bursty(int s, double t, void *ctx) { + (void)ctx; + return fmod(t, 2.5) < 2.0 ? 0.30 : (s >= 10 ? 0.99 : 0.95); +} +static void test_up_needs_sustained_headroom(void) { + DynresController c = make(3, 10); + c.level = 9; + c.f = 0.2; + Run r; run_init(&r, 9); + simulate(&c, &r, 120.0, bursty, NULL, 0.0); + CHECK(r.ups == 0, "bursty: %d up steps on 2 s of headroom", r.ups); +} + +/* The scene gets lighter: back up, one level at a time, >= 3 s apart. */ +typedef struct Phase { double switch_t; Quad heavy, light; } Phase; +static double phased(int s, double t, void *ctx) { + const Phase *p = (const Phase *)ctx; + return quad_load(s, t, t < p->switch_t ? (void *)&p->heavy : (void *)&p->light); +} +static void test_recovers_one_level_at_a_time(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + Phase p = { 60.0, { 0.45, 0.0090 }, { 0.20, 0.0030 } }; /* heavy: 7x 0.89, 6x 0.77 */ + simulate(&c, &r, 60.0, phased, &p, 0.0); + int low = r.level; + CHECK(low <= 7, "phased: heavy part steps down (level %d)", low); + int ups0 = r.ups; + simulate(&c, &r, 120.0, phased, &p, 0.0); + CHECK(r.level == 10, "phased: back to the ceiling (level %d)", r.level); + CHECK(r.ups - ups0 == 10 - low, "phased: %d up steps for %d levels", r.ups - ups0, 10 - low); + int spaced = 1; + for (int i = 1; i < r.n_up; i++) + if (r.last_up_t[i] - r.last_up_t[i - 1] < 3.0) spaced = 0; + CHECK(spaced, "phased: up steps at least 3 s apart"); +} + +static void test_holds(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + Quad q = { 0.35, 0.0065 }; + simulate(&c, &r, 30.0, quad_load, &q, 30.0); /* held throughout */ + CHECK(r.steps == 0, "hold: %d steps while held", r.steps); + dynres_hold(&c, r.t, 5.0); /* a tail */ + simulate(&c, &r, 4.5, quad_load, &q, 0.0); + CHECK(r.steps == 0, "hold: %d steps inside the tail", r.steps); + simulate(&c, &r, 10.0, quad_load, &q, 0.0); + CHECK(r.downs >= 1, "hold: steps once the tail has passed"); +} + +static void test_floor_and_inert(void) { + DynresController c = make(8, 10); + Run r; run_init(&r, 10); + Quad q = { 0.6, 0.008 }; /* over even at 8x */ + simulate(&c, &r, 120.0, quad_load, &q, 0.0); + CHECK(r.min_level >= 8, "floor: never below 8 (min %d)", r.min_level); + DynresController d = make(5, 5); + Run r2; run_init(&r2, 5); + simulate(&d, &r2, 60.0, quad_load, &q, 0.0); + CHECK(r2.steps == 0, "floor == ceiling: inert (%d steps)", r2.steps); +} + +static void test_force(void) { + DynresController c = make(3, 10); + CHECK(dynres_force(&c, 4) == 4 && c.level == 4, "force: pins 4"); + Run r; run_init(&r, 4); + Quad q = { 0.2, 0.002 }; + simulate(&c, &r, 30.0, quad_load, &q, 0.0); + CHECK(r.steps == 0 && r.level == 4, "force: stays pinned"); + CHECK(dynres_force(&c, 99) == 10, "force: clamps to the ceiling"); + dynres_force(&c, 0); + CHECK(!c.forced, "force: released"); +} + +/* Up steps wait for an interval whose idle time covers the step. */ +typedef struct Tight { int n; } Tight; +static double tight(int s, double t, void *ctx) { + (void)s; (void)t; + Tight *k = (Tight *)ctx; + k->n++; + /* Mostly 0.30, but every interval leaves less than 4 ms idle until + * n passes 60 s worth; then one roomy interval. */ + return 0.80; +} +static void test_up_waits_for_idle(void) { + DynresController c = make(3, 10); + c.level = 9; + c.f = 0.0; /* the next level predicts 0.80 * 1 */ + c.p.up_load = 0.85; + c.step_cost_s = 0.005; /* idle at 0.80 is 3.3 ms */ + Run r; run_init(&r, 9); + Tight k = { 0 }; + simulate(&c, &r, 30.0, tight, &k, 0.0); + CHECK(r.ups == 0, "idle: no up step without room for it (%d)", r.ups); + c.step_cost_s = 0.003; + simulate(&c, &r, 10.0, tight, &k, 0.0); + CHECK(r.ups == 1, "idle: up step once it fits (%d)", r.ups); +} + +/* f is learned from what steps change. */ +static void test_learns_scaling(void) { + DynresController c = make(3, 10); + Run r; run_init(&r, 10); + Quad q = { 0.05, 0.0092 }; /* nearly all ~S^2: f ~ 0.95 */ + simulate(&c, &r, 120.0, quad_load, &q, 0.0); + CHECK(c.f > c.p.prior_scaled, "learn: f rose from %.2f to %.2f", c.p.prior_scaled, c.f); +} + +int main(void) { + test_light_scene_stays_at_ceiling(); + test_heavy_scene_settles_without_oscillation(); + test_hysteresis_band(); + test_relapse_backoff(); + test_cpu_bound_keeps_resolution(); + test_transient_spikes_ignored(); + test_busy_on_time_keeps_resolution(); + test_up_needs_sustained_headroom(); + test_recovers_one_level_at_a_time(); + test_holds(); + test_floor_and_inert(); + test_force(); + test_up_waits_for_idle(); + test_learns_scaling(); + printf("dynamic_resolution_test: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} From e0c6abe5e3ebaec3d4d7018af75469e48ec23597 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:59:55 -0700 Subject: [PATCH 03/57] Round-trip dynamic resolution through launcher settings --- runtime/src/main.cpp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 49a29bd9..a6f1e742 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -14895,6 +14895,9 @@ namespace { gi->internal_resolution_values = g_ir_values; gi->num_internal_resolutions = g_ir_count; gi->internal_resolution_note = kIrNote; +#endif +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + gi->has_dynamic_resolution = 1; #endif if (language_labels && num_languages > 0) { gi->language_labels = language_labels; @@ -16520,6 +16523,12 @@ int main(int argc, char** argv) { #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) seed.internal_resolution = internal_resolution_for_launcher(); seed.has_internal_resolution = true; +#endif +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + seed.dynamic_resolution = dynres_requested() != 0; + seed.has_dynamic_resolution = true; + seed.dynamic_resolution_min = dynres_min_value(); + seed.has_dynamic_resolution_min = true; #endif seed.antialiasing = g_video_aa; seed.has_antialiasing = true; seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true; @@ -16723,6 +16732,10 @@ int main(int argc, char** argv) { ls.supersampling = seed.supersampling; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ls.internal_resolution = seed.internal_resolution; +#endif +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + ls.dynamic_resolution = seed.dynamic_resolution ? 1 : 0; + ls.dynamic_resolution_min = seed.dynamic_resolution_min; #endif ls.antialiasing = seed.antialiasing ? 1 : 0; ls.texture_filter = seed.texture_filter; @@ -17063,6 +17076,12 @@ int main(int argc, char** argv) { seed.supersampling = ls.supersampling; seed.has_supersampling = true; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ir_row_result = ls.internal_resolution; +#endif +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + seed.dynamic_resolution = ls.dynamic_resolution != 0; + seed.has_dynamic_resolution = true; + seed.dynamic_resolution_min = ls.dynamic_resolution_min; + seed.has_dynamic_resolution_min = true; #endif seed.antialiasing = ls.antialiasing != 0; seed.has_antialiasing = true; seed.geometry_correction = ls.geometry_correction != 0; @@ -17320,6 +17339,10 @@ int main(int argc, char** argv) { seed.has_internal_resolution = ir.save_ir != PSX_IR_UNSET; seed.internal_resolution = ir.save_ir; } +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + g_video_dynres = seed.dynamic_resolution ? 1 : 0; + g_video_dynres_min = seed.dynamic_resolution_min; +#endif g_video_aa = seed.antialiasing; g_video_texfilter = seed.texture_filter; g_video_fmv_filter = seed.fmv_filter; @@ -19041,6 +19064,10 @@ soft_return_lobby: ls.supersampling = ir_ss_seeded; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ls.internal_resolution = internal_resolution_for_launcher(); +#endif +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + ls.dynamic_resolution = dynres_requested() != 0; + ls.dynamic_resolution_min = dynres_min_value(); #endif ls.antialiasing = g_video_aa ? 1 : 0; ls.texture_filter = g_video_texfilter; @@ -19415,6 +19442,14 @@ soft_return_lobby: us.has_supersampling = true; us.internal_resolution = ir.save_ir; us.has_internal_resolution = ir.save_ir != PSX_IR_UNSET; +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + us.dynamic_resolution = ls.dynamic_resolution != 0; + us.has_dynamic_resolution = true; + us.dynamic_resolution_min = ls.dynamic_resolution_min; + us.has_dynamic_resolution_min = true; + g_video_dynres = us.dynamic_resolution ? 1 : 0; + g_video_dynres_min = us.dynamic_resolution_min; +#endif us.antialiasing = ls.antialiasing != 0; us.has_antialiasing = true; us.texture_filter = ls.texture_filter; From 3152a4442908a20d6d178a464206fedce85dc374 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:04:17 -0700 Subject: [PATCH 04/57] Keep one owned GL swap with dynres telemetry --- runtime/src/gpu_gl_renderer.c | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 64356732..cc287ae7 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -7758,17 +7758,15 @@ static void gl_swap_with_osd(void) { present_shot_done(wrote); } } + /* Dynamic resolution's ledger: time blocked in the swap is the driver's + * vsync wait unless the frame was late (main.cpp). Keep one owned swap. */ + uint64_t t0 = s_dyn_on ? SDL_GetPerformanceCounter() : 0; + SDL_GL_SwapWindow(s_win); if (s_dyn_on) { - /* Dynamic resolution's ledger: time blocked in the swap is the - * driver's vsync wait unless the frame was late (main.cpp). */ - uint64_t t0 = SDL_GetPerformanceCounter(); - SDL_GL_SwapWindow(s_win); s_dyn_last_swap_ticks = SDL_GetPerformanceCounter() - t0; s_dyn_ledger.swap_ticks += s_dyn_last_swap_ticks; s_dyn_ledger.swaps++; - return; } - SDL_GL_SwapWindow(s_win); } /* Output pixels per source texel below which a supersampled present switches From 9a135baf6ef54f5b413e70544dd719ec368df18f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:45:21 -0700 Subject: [PATCH 05/57] Regenerate the OpenBIOS emitter stamp Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 6ba9b228..52d394aa 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -df844aebb7ff1a0e2bdb4b472c3d6a1586b0e0e2012c261b80480ee6e0a27b01 +98ae5fba10ef4b45d5ce18b0c4af1b637a54135e12f0375d875a7e8e07f57dc7 From b15667423b0a0cd032f9dc19a4187174f35ee64b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:26:14 -0700 Subject: [PATCH 06/57] Dynamic resolution: scale guards follow the present wrappers; refuse in windowed mode gl_internal_scale_guards checks the hr-window handling where it now lives (present_vram_impl, present_wide_fbo_impl, the float present_target_quad); dyn_apply refuses outright in the windowed high-resolution mode, which dyn_eligible already excludes. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 4 +++- runtime/tests/test_gl_internal_scale_guards.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index cc287ae7..9e5f0327 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -8457,7 +8457,9 @@ static void dyn_rescale_in_place(GLuint fbo, int surf_w, const int (*cols)[2], i } static int dyn_apply(int snew) { - if (!s_alloc_scale || !s_raster_ok || !s_ctx) { s_dyn_stats.refused++; return 0; } + /* Never in the windowed high-resolution mode (s_hiw): dyn_eligible keeps + * s_alloc_scale 0 there; refuse here too rather than step its tiles. */ + if (!s_alloc_scale || !s_raster_ok || !s_ctx || s_hiw) { s_dyn_stats.refused++; return 0; } if (snew < 1) snew = 1; if (snew > s_alloc_scale) snew = s_alloc_scale; if (snew == s_hr_scale) return 1; diff --git a/runtime/tests/test_gl_internal_scale_guards.py b/runtime/tests/test_gl_internal_scale_guards.py index d7ef6089..48225ce8 100644 --- a/runtime/tests/test_gl_internal_scale_guards.py +++ b/runtime/tests/test_gl_internal_scale_guards.py @@ -63,7 +63,7 @@ class GlScaleGuards(unittest.TestCase): geo = body(GL, "static void gpu_geometry(") self.assertIn("if (is_line && s_hr_scale > 1) {", geo) self.assertIn("if (draw_mode == GL_LINES) glLineWidth((float)s_hr_scale);", geo) - quad = body(GL, "static void present_target_quad(GLuint tex, int tex_w, int tex_h,\n" + quad = body(GL, "static void present_target_quad(GLuint tex, float tex_w, float tex_h,\n" " int x, int y, int w, int h, int linear,\n" " int lx, int ly, int lw, int lh, int v_flip,\n" " int apply_gamma, int src_scale) {") @@ -175,7 +175,7 @@ class HiresWindowGuards(unittest.TestCase): for fn in ("static void glb_wide_configure(", "static void glb_wide_clear(", "static void glb_wide_clear_margins(", "static int glb_render_wide_display(", "static int glb_wide_dump_full(", - "int gl_renderer_present_wide_fbo(", "static void rebuild_mask_stencils(void)"): + "static int present_wide_fbo_impl(", "static void rebuild_mask_stencils(void)"): self.assertIn("hiw_flush_queue();", body(GL, fn), fn) def test_queue_syncs_before_the_raw_mirror_changes(self): @@ -186,7 +186,7 @@ class HiresWindowGuards(unittest.TestCase): self.assertIn("hiw_flush_queue();", body(GL, "static void depth24_clear_skipped_fb(void)")) self.assertIn("hiw_flush_queue();", body(GL, "static void rebuild_mask_stencils(void)")) self.assertIn("hiw_flush_queue();", body(GL, "static const HiwTile *hiw_ensure(int x0, int x1)")) - present = body(GL, "void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear,") + present = body(GL, "static void present_vram_impl(int disp_x, int disp_y, int w, int h, int linear,") self.assertIn("if (s_hiw) {", present) self.assertIn("int src_tw = VRAM_W, src_x = disp_x, src_scale = s_out_scale;", present) From 39216d1875ee1d6f75e95a431ff7784782b99302 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:54:08 -0700 Subject: [PATCH 07/57] Rebuild only the stale part of native-wide mask stencils At S > 1 every GP0(E6h) mask-check enable after an unchecked opaque draw rebuilt each native-wide surface's whole stencil from alpha, tiled. At a 10x allocation that is many render passes over 4270x5120 surfaces per enable; R4 2P VS enables the check several times a frame and spent ~85% of its emulation thread waiting on Metal command buffers there (16:9 at 10x: 17.7 guest Hz). Track per wide surface the rect where the stencil may differ from alpha (unchecked mirror draws by bbox, the centre splice, scale-step rescales, render-pass restores) and rebuild only that. While the centre is spliced from hr at every present its stencil is never observed, so the centre part is left stale and rebuilt only if a mask-checked mirror draw meets it with the splice off. Native (1x) keeps the whole-surface rebuild. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 164 ++++++++++++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 7 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 9e5f0327..b78a3513 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -441,6 +441,8 @@ static int s_hiw_grows = 0; /* tile allocations (diagnostic) static int hiw_on(void); static const HiwTile *hiw_ensure(int x0, int x1); static void hiw_flush_queue(void); +static void wst_rebuild(int i, int defer); +static int wide_fast_center_valid(void); static GLuint make_tex(GLenum internal, int w, int h, GLenum fmt, GLenum type); static int make_fbo(GLuint *out_fbo, GLuint color_tex, GLuint stencil_rb); static GLenum s_last_fbo_status; @@ -773,6 +775,83 @@ uint64_t gl_renderer_wide_triangle_recovery_count(void) { static int view_enabled, view_shift, view_pad_left, view_pad_right; static inline int wide_dx(void) { return g_wide_off + view_shift - g_wide_cur_base; } +/* Wide-surface mask stencil: the rect (wide-local native px, [x0,x1) x + * [y0,y1)) where a surface's stencil may differ from its alpha (bit 15), per + * surface. At S > 1 the rebuild (rebuild_mask_stencils) covers only this rect + * instead of the whole surface: the stencil equals alpha everywhere else, so + * rebuilding it there would change nothing. Every write that can leave the + * stencil behind alpha adds to it: a mirror draw without the mask check (its + * bbox), the centre splice (wide_blit_center, colour only), a scale step's + * in-place rescale and a render pass's restore (colour only), a new surface. + * Clears write both and add nothing. While the centre is spliced from the hr + * surface at every present (wide_fast_center_valid), the centre columns' + * stencil is never observed (their pixels are overwritten before anything + * reads them), so a rebuild leaves the centre part stale and it is rebuilt + * only if a mask-checked mirror draw meets it with the splice off + * (wide_stencil_ready). A whole-surface rebuild was the cost of every + * GP0(E6h) mask-check enable at high internal scales (R4 2P VS: ~85% of the + * emulation thread at 10x, waiting on Metal command buffers). */ +static int s_wst_x0[WIDE_MAX_SURF], s_wst_y0[WIDE_MAX_SURF]; +static int s_wst_x1[WIDE_MAX_SURF], s_wst_y1[WIDE_MAX_SURF]; +static uint64_t s_wst_rebuilds = 0, s_wst_px = 0, s_wst_deferred = 0; +static int wide_index(GLuint fbo) { + for (int i = 0; fbo && i < WIDE_MAX_SURF; i++) + if (s_wide_fbo[i] == fbo) return i; + return -1; +} +static void wst_add(int i, int x0, int y0, int x1, int y1) { + if (i < 0 || i >= WIDE_MAX_SURF) return; + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x1 > g_wide_w) x1 = g_wide_w; + if (y1 > VRAM_H) y1 = VRAM_H; + if (x1 <= x0 || y1 <= y0) return; + if (s_wst_x1[i] <= s_wst_x0[i] || s_wst_y1[i] <= s_wst_y0[i]) { + s_wst_x0[i] = x0; s_wst_y0[i] = y0; s_wst_x1[i] = x1; s_wst_y1[i] = y1; + return; + } + if (x0 < s_wst_x0[i]) s_wst_x0[i] = x0; + if (y0 < s_wst_y0[i]) s_wst_y0[i] = y0; + if (x1 > s_wst_x1[i]) s_wst_x1[i] = x1; + if (y1 > s_wst_y1[i]) s_wst_y1[i] = y1; +} +static void wst_all(int i) { wst_add(i, 0, 0, g_wide_w, VRAM_H); } +static void wst_clear(int i) { s_wst_x0[i] = s_wst_y0[i] = s_wst_x1[i] = s_wst_y1[i] = 0; } +/* A mirror draw of `n` vertices (x at v[0], y at v[1], `stride` floats + * apart, canonical VRAM px) into surface `fbo`, shifted by dx and stretched + * by (scale, centre) exactly as GEO_VS/TEX_VS do; check: the mask check it + * was drawn under (checked draws keep the stencil equal to alpha). The bbox + * is widened by 2 px each way for sub-pixel positions and line quads. */ +static void wst_note_draw(GLuint fbo, const float *v, int n, int stride, + int dx, float scale, float centre, int check) { + if (check || n <= 0) return; + int i = wide_index(fbo); + if (i < 0) return; + float lo = v[0], hi = v[0], ylo = v[1], yhi = v[1]; + for (int k = 1; k < n; k++) { + float x = v[(size_t)k * stride], y = v[(size_t)k * stride + 1]; + if (x < lo) lo = x; if (x > hi) hi = x; + if (y < ylo) ylo = y; if (y > yhi) yhi = y; + } + if (scale != 1.0f) { /* monotonic in x: transform the ends */ + float t[2] = { lo, hi }; + for (int k = 0; k < 2; k++) { + float xb = t[k]; + if (scale < 0.0f) { + float s = -scale, h = ((float)g_wide_w / 2.0f) / s; + float l = centre - h, r = centre + h; + if (xb < l) xb = l + (xb - l) * s; else if (xb > r) xb = r + (xb - r) * s; + } else { + xb = (xb - centre) * scale + centre; + } + t[k] = xb; + } + lo = t[0]; hi = t[1]; + } + wst_add(i, (int)floorf(lo) + dx - 2, (int)floorf(ylo) - 2, + (int)ceilf(hi) + dx + 3, (int)ceilf(yhi) + 3); +} + /* ---- dirty-rect helpers ------------------------------------------------- */ static void rect_clear(DirtyRect *r) { r->set = 0; } static void rect_add(DirtyRect *r, int x0, int y0, int x1, int y1) { @@ -1979,6 +2058,7 @@ static void rebuild_mask_stencils(void) { for (int i = 0; i < WIDE_MAX_SURF; i++) { if (s_wide_fbo[i]) rebuild_target_stencil(s_wide_fbo[i], g_wide_w * s_out_scale, hh); + wst_clear(i); } } else { /* S > 1: only primitives can leave stencil behind alpha, so rebuild @@ -2002,17 +2082,59 @@ static void rebuild_mask_stencils(void) { (s_stencil_stale.y1 - s_stencil_stale.y0 + 1) * s_out_scale); } } - for (int i = 0; i < WIDE_MAX_SURF; i++) { - if (s_wide_fbo[i]) - rebuild_target_stencil_tiled(s_wide_fbo[i], g_wide_w * s_out_scale, - VRAM_H * s_out_scale, 0, 0, - g_wide_w * s_out_scale, VRAM_H * s_out_scale); - } + /* Wide surfaces: their stale rects (s_wst_*), the centre columns + * left for later while the centre is spliced from hr. */ + int defer = wide_fast_center_valid() && !view_enabled; + for (int i = 0; i < WIDE_MAX_SURF; i++) + if (s_wide_fbo[i]) wst_rebuild(i, defer); } rect_clear(&s_stencil_stale); s_stencil_valid = 1; } +/* Rebuild surface i's stale stencil rect (S > 1). defer: leave the centre + * columns [g_wide_off, g_wide_w - g_wide_off) stale (see s_wst_*). */ +static void wst_rebuild_rect(int i, int x0, int x1, int y0, int y1) { + if (x1 <= x0 || y1 <= y0) return; + int S = s_out_scale; + rebuild_target_stencil_tiled(s_wide_fbo[i], g_wide_w * S, VRAM_H * S, + x0 * S, y0 * S, (x1 - x0) * S, (y1 - y0) * S); + s_wst_rebuilds++; + s_wst_px += (uint64_t)(x1 - x0) * (uint64_t)(y1 - y0); +} +static void wst_rebuild(int i, int defer) { + int x0 = s_wst_x0[i], x1 = s_wst_x1[i], y0 = s_wst_y0[i], y1 = s_wst_y1[i]; + if (x1 <= x0 || y1 <= y0) return; + int cl = g_wide_off, cr = g_wide_w - g_wide_off; + if (!defer || cl <= 0 || cr <= cl) { + wst_rebuild_rect(i, x0, x1, y0, y1); + wst_clear(i); + return; + } + wst_rebuild_rect(i, x0, x1 < cl ? x1 : cl, y0, y1); /* left margin */ + wst_rebuild_rect(i, x0 > cr ? x0 : cr, x1, y0, y1); /* right margin */ + if (x1 > cl && x0 < cr) { /* the centre part stays stale */ + s_wst_x0[i] = x0 > cl ? x0 : cl; + s_wst_x1[i] = x1 < cr ? x1 : cr; + s_wst_deferred++; + } else { + wst_clear(i); + } +} + +/* Before a mirror draw into the current wide surface: under the mask check + * with the centre splice off, the stencil it tests must be current + * everywhere, including a centre part a rebuild left stale. Called before + * any GL state for the draw is set (it binds its own). */ +static void wide_stencil_ready(void) { + if (!g_wide_cur || !s_mask_check || s_out_scale <= 1) return; + int i = wide_index(g_wide_cur); + if (i < 0 || s_wst_x1[i] <= s_wst_x0[i] || s_wst_y1[i] <= s_wst_y0[i]) return; + if (wide_fast_center_valid() && !view_enabled) return; + hiw_flush_queue(); + wst_rebuild(i, 0); +} + /* ---- windowed high-resolution surface: mirror plumbing ------------------ */ static int hiw_on(void) { return s_hiw && s_hiw_n > 0; } @@ -3131,6 +3253,7 @@ static void hiw_flush_queue(void) { static void flush_tex_batch(void) { if (s_tb_n == 0) return; + wide_stencil_ready(); /* before any of this batch's GL state */ int nverts = s_tb_n, semi = s_tb_semi; s_tb_n = 0; /* clear first: re-entrancy safe */ double cw_t0 = cw_ms(); @@ -3154,6 +3277,11 @@ static void flush_tex_batch(void) { * frame, so it is never treated as centre-only. */ int mirror = g_wide_cur && s_ws_ablate != 1 && !(s_tb_gate == 0 && mirror_batch_center_only(nverts)); + if (mirror) { /* the stencil it may leave behind alpha */ + float sc, ce; + wide_bd_scale(s_tb_gate, &sc, &ce); + wst_note_draw(g_wide_cur, s_tb, nverts, TEXV, wide_dx(), sc, ce, s_mask_check); + } /* Windowed high-resolution surface: the same batch at S, queued and * replayed in one render pass at the next sync point (see s_hq), with its * native-wide mirror. No-op unless that mode is engaged. */ @@ -3230,6 +3358,7 @@ static void flat_batch_draw_hr_lines(int nverts, int nl) { static void flush_flat_batch(void) { if (s_fb_n == 0) return; + wide_stencil_ready(); /* before any of this batch's GL state */ int nverts = s_fb_n, semi = s_fb_semi, mask = s_fb_mask, nl = s_fbl_n; GLenum fmode = s_fb_mode; s_fb_n = 0; @@ -3249,6 +3378,11 @@ static void flush_flat_batch(void) { else glDrawArrays(fmode, 0, nverts); int mirror = g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && !(!g_ws_bd_stretch_on && mirror_flat_batch_center_only(nverts)); + if (mirror) { + float sc, ce; + wide_bd_scale(s_fb_gate, &sc, &ce); + wst_note_draw(g_wide_cur, s_fb, nverts, 6, wide_dx(), sc, ce, s_mask_check); + } if (hiw_on() && hiw_enqueue_geo(s_fb, nverts, semi, mask, mirror, s_fb_gate)) mirror = 0; if (mirror) { @@ -3419,6 +3553,7 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, if (is_line && s_hr_scale > 1) { draw_mode = GL_TRIANGLES; draw_verts = quad; draw_n = 6; } + wide_stencil_ready(); /* before any of this draw's GL state */ hr_begin(1); if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); mask_stencil(s_mask_set); @@ -3437,6 +3572,11 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, } int mirror = g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && !(!g_ws_bd_stretch_on && mirror_geo_center_only(xs, n)); + if (mirror) { + float sc, ce; + wide_bd_scale(bd_prim_gate(xs, n, 0), &sc, &ce); + wst_note_draw(g_wide_cur, verts, n, 6, wide_dx(), sc, ce, s_mask_check); + } if (hiw_on() && hiw_enqueue_geo(draw_mode == GL_TRIANGLES && is_line ? quad : verts, draw_n, semi, s_mask_set, mirror, bd_prim_gate(xs, n, 0))) @@ -3718,7 +3858,9 @@ static void gpu_flat_rect(int x,int y,int w,int h,uint16_t c,int semi) { /* Re-open the bracket for the full-width wide pass so * blend/scissor/program state is clean. */ if (s_ws_ablate != 1) { + wide_stencil_ready(); hiw_flush_queue(); /* windowed: queued wide mirrors land first */ + if (!s_mask_check) wst_add(wide_index(g_wide_cur), 0, y, g_wide_w, y + h); hr_begin(0); gl_perf_mirror_begin(); wide_flat_rect_direct(0, y, g_wide_w, h, c, semi); @@ -5439,6 +5581,7 @@ static void wide_free_all(void) { if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } if (s_wide_rb[i]) { p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; } s_wide_base[i] = -1; + wst_clear(i); } g_wide_cur = 0; } @@ -5498,6 +5641,7 @@ static GLuint wide_fbo_for(int base_x) { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); s_wide_base[i] = base_x; s_dyn_wide_y0[i] = s_dyn_wide_y1[i] = 0; /* nothing presented yet */ + wst_clear(i); /* colour and stencil cleared together */ return s_wide_fbo[i]; } } @@ -6946,9 +7090,12 @@ static void transaction_restore_ex(int keep_color) { s_pass_w * S, s_pass_h * S, hr_bits); pass_blit(s_pb_raw_fbo, s_raw_fbo, 0, 0, s_pass_x, s_pass_y, s_pass_w, s_pass_h, GL_COLOR_BUFFER_BIT); - if (s_pb_wide_src && s_pb_wide_src == pass_wide_fbo_for(s_pass_x)) + if (s_pb_wide_src && s_pb_wide_src == pass_wide_fbo_for(s_pass_x)) { pass_blit(s_pb_wide_fbo, s_pb_wide_src, 0, 0, 0, s_pass_y * S, g_wide_w * S, s_pass_h * S, hr_bits); + /* The backed-up stencil may predate a rebuild inside the pass. */ + wst_add(wide_index(s_pb_wide_src), 0, s_pass_y, g_wide_w, s_pass_y + s_pass_h); + } for (int row = 0; row < s_pass_h; row++) memcpy(s_vram + (size_t)(s_pass_y + row) * VRAM_W + s_pass_x, s_pb_cpu + (size_t)row * s_pass_w, @@ -8084,6 +8231,8 @@ static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h if (native_w <= 0) return; int S = s_out_scale; (void)disp_y; (void)disp_h; + /* Colour only: the centre's stencil is left behind (see s_wst_*). */ + wst_add(wide_index(wide_fbo), g_wide_off, 0, g_wide_off + native_w, VRAM_H); /* Copy the canonical framebuffer column into the wide surface CENTRE over the * FULL VRAM height, not just the current display band [disp_y, disp_y+disp_h]. * The wide surface holds BOTH vertical double-buffer bands (Ape flips @@ -8574,6 +8723,7 @@ static int dyn_apply(int snew) { if (y1 <= y0) { y0 = 0; y1 = VRAM_H; } dyn_rescale_in_place(s_wide_fbo[i], g_wide_w, (const int (*)[2])cols, nc, y0, y1, sold, snew); + wst_all(i); /* colour only */ } } const uint64_t t5 = dyn_mark(); From 0b8453e770fb32fe845af40dab041430372d5e51 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:54:08 -0700 Subject: [PATCH 08/57] Queue native-wide mirrors on the full-VRAM surface Each mirrored batch switched to the wide surface and back, ending two Metal render passes over the hr (10240x5120 at 10x) and wide surfaces; cost grew with the allocation, so dynamic resolution's down steps did not lower it. Mirrors now ride the windowed mode's queue (one pass per wide surface, at the same sync points: uploads, pack, clears, stencil rebuilds, render-pass copies, presents and dumps). GL_LINES mirrors (1x) flush it and draw at once. Queue entries carry their scissor and palette texture. PSX_GL_WIDE_QUEUE=0 and PSX_GL_WIDE_STENCIL_FULL=1 restore the previous paths for A/B; gl_scale_invariance_test gains a "wmask" mode (wide mask stencil, centre splice on/off) and checks native VRAM, frame at S and the wide surface are identical with either off, for wmask, lines, twin and the scene at 1x/3x/9x and in the window mode. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 91 +++++++++++++++++--- runtime/tests/run_gl_scale_invariance.py | 31 +++++++ runtime/tests/test_gl_scale_invariance.c | 105 ++++++++++++++++++++++- 3 files changed, 210 insertions(+), 17 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index b78a3513..84c85148 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -794,6 +794,12 @@ static inline int wide_dx(void) { return g_wide_off + view_shift - g_wide_cur_ba static int s_wst_x0[WIDE_MAX_SURF], s_wst_y0[WIDE_MAX_SURF]; static int s_wst_x1[WIDE_MAX_SURF], s_wst_y1[WIDE_MAX_SURF]; static uint64_t s_wst_rebuilds = 0, s_wst_px = 0, s_wst_deferred = 0; +/* PSX_GL_WIDE_STENCIL_FULL=1: rebuild whole surfaces, as before (A/B). */ +static int wst_full(void) { + static int v = -1; + if (v < 0) { const char *e = getenv("PSX_GL_WIDE_STENCIL_FULL"); v = e && e[0] == '1'; } + return v; +} static int wide_index(GLuint fbo) { for (int i = 0; fbo && i < WIDE_MAX_SURF; i++) if (s_wide_fbo[i] == fbo) return i; @@ -2085,8 +2091,11 @@ static void rebuild_mask_stencils(void) { /* Wide surfaces: their stale rects (s_wst_*), the centre columns * left for later while the centre is spliced from hr. */ int defer = wide_fast_center_valid() && !view_enabled; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i]) wst_rebuild(i, defer); + for (int i = 0; i < WIDE_MAX_SURF; i++) { + if (!s_wide_fbo[i]) continue; + if (wst_full()) wst_all(i); + wst_rebuild(i, defer && !wst_full()); + } } rect_clear(&s_stencil_stale); s_stencil_valid = 1; @@ -2127,7 +2136,7 @@ static void wst_rebuild(int i, int defer) { * everywhere, including a centre part a rebuild left stale. Called before * any GL state for the draw is set (it binds its own). */ static void wide_stencil_ready(void) { - if (!g_wide_cur || !s_mask_check || s_out_scale <= 1) return; + if (!g_wide_cur || !s_mask_check || s_out_scale <= 1 || wst_full()) return; int i = wide_index(g_wide_cur); if (i < 0 || s_wst_x1[i] <= s_wst_x0[i] || s_wst_y1[i] <= s_wst_y0[i]) return; if (wide_fast_center_valid() && !view_enabled) return; @@ -2604,7 +2613,7 @@ static void mark_prim_dirty(const int *xs, const int *ys, int n, int textured) { * top/bottom edge flicker (16:9 GL only). */ /* Wide-surface scissor: full wide width, draw-area rows (see above). Every * draw into a wide surface uses it, including the full-screen-overlay pass. */ -static void wide_band_scissor_x(int x, int w) { +static void wide_band_rows(int *out_sy, int *out_sh) { int y1 = s_area_y1, y2 = s_area_y2; if (s_pass_active) { /* render pass: stay inside the backed-up band */ if (y1 < s_pass_y) y1 = s_pass_y; @@ -2614,6 +2623,11 @@ static void wide_band_scissor_x(int x, int w) { if (sy < 0) { sh += sy; sy = 0; } if (sy + sh > VRAM_H) sh = VRAM_H - sy; if (sh < 0) sh = 0; + *out_sy = sy; *out_sh = sh; +} +static void wide_band_scissor_x(int x, int w) { + int sy, sh; + wide_band_rows(&sy, &sh); glEnable(GL_SCISSOR_TEST); glScissor(x * s_out_scale, sy * s_out_scale, w * s_out_scale, sh * s_out_scale); } @@ -3000,6 +3014,13 @@ static int mirror_batch_center_only(int nverts) { * one pass per wide surface. Every other write to a wide surface (clears, * the full-screen overlay rect, reallocation) and every read of one * (present, capture, dumps) flushes the queue first. */ +/* The same queue carries the native-wide mirrors when the window is not + * engaged (wide_queue_live): at a 10x Match display allocation each per-batch + * switch to a wide surface and back ended two render passes over surfaces + * 4270x5120 (wide) and 10240x5120 (hr) and held a 16:9 R4 race to ~47 + * frames/s; queued, the mirrors land in one pass per wide surface at the same + * sync points. Lines drawn as GL_LINES (1x) are not queued; they flush the + * queue and mirror at once. PSX_GL_WIDE_QUEUE=0 turns the queue off. */ enum { HQ_TEX = 1, HQ_GEO = 2 }; typedef struct { uint8_t kind; @@ -3015,6 +3036,7 @@ typedef struct { GLuint wfbo; /* native-wide mirror target, 0: none */ int wdx; /* its x shift (wide_dx()) */ float wscale, wcenter; /* its backdrop stretch (wide_bd_scale) */ + int wsx, wsy, wsw, wsh; /* its wide-surface scissor (native px) */ } HiCmd; static HiCmd *s_hq = NULL; static int s_hq_n = 0, s_hq_cap = 0; @@ -3063,10 +3085,30 @@ static int hiw_area_touches(void) { static int hiw_wide_queue_ok(int mirror) { return mirror && g_wide_cur && s_ws_ablate == 0; } +static int s_wide_queue = -1; /* PSX_GL_WIDE_QUEUE (default on) */ +static int wide_queue_live(void) { + if (s_wide_queue < 0) { + const char *e = getenv("PSX_GL_WIDE_QUEUE"); + s_wide_queue = !(e && e[0] == '0'); + } + return s_wide_queue && !s_hiw && g_wide_cur && s_ws_ablate == 0; +} +static void wide_band_rows(int *sy, int *sh); static void hiw_wide_set(HiCmd *c, int gate) { c->wfbo = g_wide_cur; c->wdx = wide_dx(); wide_bd_scale(gate, &c->wscale, &c->wcenter); + if (s_hiw) { /* the window's replay: full width, the draw area's rows */ + int sy = c->ay0, sh = c->ay1 - c->ay0 + 1; + if (sy < 0) { sh += sy; sy = 0; } + if (sy + sh > VRAM_H) sh = VRAM_H - sy; + if (sh < 0) sh = 0; + c->wsx = 0; c->wsw = g_wide_w; c->wsy = sy; c->wsh = sh; + } else { /* what wide_target_begin sets */ + c->wsx = view_pad_left; + c->wsw = g_wide_w - view_pad_left - view_pad_right; + wide_band_rows(&c->wsy, &c->wsh); + } } /* Queue a textured batch for the window, and its native-wide mirror when @@ -3120,15 +3162,12 @@ static void hiw_replay_wide(void) { p_glUniform1f(s_tex_uShift, s_shift_hi); p_glUniform1f(s_tex_uXhalf, (float)g_wide_w / 2.0f); p_glUniform1i(s_uVram, 0); + p_glUniform1i(s_uPalette, 1); p_glActiveTexture(PSXGL_TEXTURE0); bound = c->wfbo; cur = 0; } - int sy = c->ay0, sh = c->ay1 - c->ay0 + 1; - if (sy < 0) { sh += sy; sy = 0; } - if (sy + sh > VRAM_H) sh = VRAM_H - sy; - if (sh < 0) sh = 0; - glScissor(0, sy * S, g_wide_w * S, sh * S); + glScissor(c->wsx * S, c->wsy * S, c->wsw * S, c->wsh * S); if (c->kind == HQ_TEX) { if (cur != HQ_TEX) { p_glUseProgram(s_tex_prog); @@ -3175,9 +3214,14 @@ static void hiw_replay_wide(void) { /* Replay the queued draws into every tile they touch: the window projection * and each command's draw area (clipped to the tile) as the scissor. A tile no * command touches is not bound. */ +static void hiw_flush_tail(void); static void hiw_flush_queue(void) { if (s_hq_n == 0) return; - if (!hiw_on()) { s_hq_n = 0; s_hq_vn = 0; return; } + if (!hiw_on()) { /* native-wide mirrors only (wide_queue_live) */ + s_hq_flushes++; + hiw_flush_tail(); + return; + } int S = s_out_scale; s_hq_flushes++; for (int t = 0; t < s_hiw_n; t++) { @@ -3236,6 +3280,12 @@ static void hiw_flush_queue(void) { } } } + hiw_flush_tail(); +} + +/* The native-wide mirrors (both modes), then the canonical projection back + * on both programs. */ +static void hiw_flush_tail(void) { hiw_replay_wide(); /* Canonical projection back on both programs. */ p_glUseProgram(s_geo_prog); @@ -3247,6 +3297,9 @@ static void hiw_flush_queue(void) { p_glUniform1f(s_tex_uXoff, 0.0f); p_glUniform1f(s_tex_uXhalf, 512.0f); hr_end(); + /* Native-wide only: leave the hr surface bound, as an immediate mirror + * (wide_target_end) does. */ + if (!s_hiw) p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); s_hq_n = 0; s_hq_vn = 0; } @@ -3285,7 +3338,8 @@ static void flush_tex_batch(void) { /* Windowed high-resolution surface: the same batch at S, queued and * replayed in one render pass at the next sync point (see s_hq), with its * native-wide mirror. No-op unless that mode is engaged. */ - if (hiw_on() && hiw_enqueue_tex(nverts, semi, mirror, s_tb_gate)) mirror = 0; + if ((hiw_on() || (mirror && wide_queue_live())) && + hiw_enqueue_tex(nverts, semi, mirror, s_tb_gate)) mirror = 0; if (mirror) { /* native-wide mirror */ int dx = wide_dx(); @@ -3359,6 +3413,8 @@ static void flat_batch_draw_hr_lines(int nverts, int nl) { static void flush_flat_batch(void) { if (s_fb_n == 0) return; wide_stencil_ready(); /* before any of this batch's GL state */ + if (s_fb_mode != GL_TRIANGLES && !hiw_on()) + hiw_flush_queue(); /* GL_LINES mirror at once: queued mirrors first */ int nverts = s_fb_n, semi = s_fb_semi, mask = s_fb_mask, nl = s_fbl_n; GLenum fmode = s_fb_mode; s_fb_n = 0; @@ -3383,7 +3439,8 @@ static void flush_flat_batch(void) { wide_bd_scale(s_fb_gate, &sc, &ce); wst_note_draw(g_wide_cur, s_fb, nverts, 6, wide_dx(), sc, ce, s_mask_check); } - if (hiw_on() && hiw_enqueue_geo(s_fb, nverts, semi, mask, mirror, s_fb_gate)) mirror = 0; + if ((hiw_on() || (mirror && fmode == GL_TRIANGLES && wide_queue_live())) && + hiw_enqueue_geo(s_fb, nverts, semi, mask, mirror, s_fb_gate)) mirror = 0; if (mirror) { int dx = wide_dx(); @@ -3554,6 +3611,8 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, draw_mode = GL_TRIANGLES; draw_verts = quad; draw_n = 6; } wide_stencil_ready(); /* before any of this draw's GL state */ + if (draw_mode != GL_TRIANGLES && !hiw_on()) + hiw_flush_queue(); /* GL_LINES mirror at once: queued mirrors first */ hr_begin(1); if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); mask_stencil(s_mask_set); @@ -3577,7 +3636,7 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, wide_bd_scale(bd_prim_gate(xs, n, 0), &sc, &ce); wst_note_draw(g_wide_cur, verts, n, 6, wide_dx(), sc, ce, s_mask_check); } - if (hiw_on() && + if ((hiw_on() || (mirror && draw_mode == GL_TRIANGLES && wide_queue_live())) && hiw_enqueue_geo(draw_mode == GL_TRIANGLES && is_line ? quad : verts, draw_n, semi, s_mask_set, mirror, bd_prim_gate(xs, n, 0))) mirror = 0; @@ -5576,6 +5635,7 @@ void gl_renderer_diag(int *gpu_dirty, int pending[5], int pack[5]) { * ------------------------------------------------------------------------- */ static void wide_free_all(void) { + if (s_ctx) hiw_flush_queue(); /* queued mirrors target these surfaces */ for (int i = 0; i < WIDE_MAX_SURF; i++) { if (s_wide_fbo[i]) { p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; } if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } @@ -6797,11 +6857,14 @@ static int pass_gen_reserve(int gi, uint32_t need, int w, int h) { * refreshes the band's canonical centre, as the wide present does. */ static void pass_capture_into(GLuint tex, const PassGen *g) { int S = s_hr_scale; + hiw_flush_queue(); /* queued wide mirrors land before the capture reads */ if (g->source_path == GL_PRES_WIDE) { GLuint wf = pass_wide_fbo_for(g->x); int native_w = g_wide_w - 2 * g_wide_off; if (!wf) return; if (wide_center_is_canonical() && native_w > 0) { + /* Colour only: the centre's stencil is left behind (s_wst_*). */ + wst_add(wide_index(wf), g_wide_off, g->y, g_wide_off + native_w, g->y + g->h); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, wf); glDisable(GL_SCISSOR_TEST); @@ -6826,6 +6889,7 @@ static void pass_capture_into(GLuint tex, const PassGen *g) { static void pass_blit(GLuint src, GLuint dst, int sx, int sy, int dx, int dy, int w, int h, GLbitfield mask) { + hiw_flush_queue(); /* queued wide mirrors land before a copy reads or writes */ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, src); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, dst); glDisable(GL_SCISSOR_TEST); @@ -6837,6 +6901,7 @@ static void pass_blit(GLuint src, GLuint dst, int sx, int sy, int dx, int dy, static void pass_verify_read(uint8_t **hr, size_t *hr_cap, uint8_t **raw, size_t *raw_cap) { + hiw_flush_queue(); int S = s_hr_scale; size_t hn = (size_t)s_pass_w * S * (size_t)s_pass_h * S * 4u; size_t rn = (size_t)s_pass_w * (size_t)s_pass_h * 2u; diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index b5783586..b078f8b5 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -317,6 +317,37 @@ def main(): if got[0][3] is None or got[1][3] is None or got[1][3] >= got[0][3]: print(f"FAIL twin {label} {s}x: batching on did not draw fewer batches:", got) ok = False + # Native-wide mirror queue and stale-rect wide stencil rebuild: the same + # native VRAM, frame at S and wide surface with each off (the previous + # immediate mirrors and whole-surface rebuilds) and on. + ab_envs = (("default", {}), ("queue-off", {"PSX_GL_WIDE_QUEUE": "0"}), + ("stencil-full", {"PSX_GL_WIDE_STENCIL_FULL": "1"}), + ("both-off", {"PSX_GL_WIDE_QUEUE": "0", "PSX_GL_WIDE_STENCIL_FULL": "1"})) + ab_runs = [("wmask", s, fast, {}) for s in (1, 3, 9) for fast in ("0", "1")] + ab_runs += [("wmask", 9, fast, {"PSX_GL_HIRES_WINDOW": "1"}) for fast in ("0", "1")] + ab_runs += [("lines", 9, None, {}), ("twin", 9, "1", {}), ("scene", 3, None, {}), + ("scene", 9, None, {})] + for mode, s, arg, extra in ab_runs: + got = {} + for name, ab in ab_envs: + e = dict(env) + e.update(extra) + e.update(ab) + cmd = [dest / "probe", s, mode] + ([arg] if arg is not None else []) + r = run(cmd, env=e) + parsed = parse_run(r.stdout) + if r.returncode or not parsed or parsed[1]: + print(f"wide a/b {mode} {s} {arg} {name}: exit={r.returncode}", + r.stdout.strip().splitlines()[-3:], r.stderr.strip()[-600:]) + ok = False + got[name] = (parsed[2] if parsed else None, parse_hires(r.stdout), + parse_hires(r.stdout, "wide")) + same = len(set(got.values())) == 1 and None not in got["default"][1:] + print(f"wide a/b {mode} {s}x {arg or ''}{' window' if extra else ''}:", + "same" if same else got) + if not same: + print(f"FAIL wide a/b {mode} {s}x: queue/stencil change the image:", got) + ok = False # Render passes: offered on the full-VRAM surface, refused in the window mode. if not passes: print("passes: skipped (this renderer has no render passes)") diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 964b3af1..61ed9b0f 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -286,12 +286,17 @@ static int lines_main(int scale, int window) { glb_draw_line(5, 160, 90, 190, 0x7fff); glb_set_draw_area(0, 0, 1023, 511); { - /* Windowed: the batches' native-wide mirrors wait in the window's - * queue (replayed in one pass per surface at the next sync point). */ + /* The batches' native-wide mirrors wait in the queue (replayed in + * one pass per surface at the next sync point): in the window mode, + * and above 1x on the full-VRAM surface unless PSX_GL_WIDE_QUEUE=0 + * (at 1x lines mirror as GL_LINES, at once). */ int wq = 0; + const char *qe = getenv("PSX_GL_WIDE_QUEUE"); + int qoff = !window && qe && qe[0] == '0'; for (int i = 0; i < s_hq_n; i++) wq += s_hq[i].wfbo != 0; - if (window && wq < 3) fprintf(stderr, "queued wide mirrors=%d\n", wq); - check(window ? wq >= 3 : s_hq_n == 0, "windowed: native-wide mirrors queued"); + if ((window || scale > 1) && !qoff && wq < 3) fprintf(stderr, "queued wide mirrors=%d\n", wq); + if (window || scale > 1) + check(qoff ? s_hq_n == 0 : wq >= 3, "native-wide mirrors queued"); } gl_renderer_sync_cpu(); check(gl_renderer_fbo_peek(0, 0, 1024, 512, peek), "native peek"); @@ -607,6 +612,92 @@ static int twin_main(int scale, int window, int on) { return failures ? 1 : 0; } +/* ---- mode "wmask": the native-wide mask stencil --------------------------- + * argv[3] "0"/"1": the centre splice (gl_wide_fast) off/on. Set-mask draws + * into both native-wide margins, unchecked opaque draws over them (the + * stencil falls behind alpha), the mask check turned on (the stencil is + * rebuilt) and checked draws across the margins and the centre, a dump in + * between (the centre splice at a present), then the same again with a + * full-frame overlay rect. The runner checks the native VRAM, the frame at S + * and the wide surface are the same with the mirror queue and the stale-rect + * stencil rebuild off and on (PSX_GL_WIDE_QUEUE=0, PSX_GL_WIDE_STENCIL_FULL=1). */ +static void wmask_dump(int scale, uint64_t *wide) { + int ww = 426 * scale, wh = 512 * scale, gw = 0, gh = 0; + uint32_t *wb = (uint32_t *)malloc((size_t)ww * wh * 4); + int got = wb ? glb_wide_dump_full(wb, ww * wh, &gw, &gh, 0) : 0; + check(got == ww * wh && gw == ww && gh == wh, "wide surface dump size"); + if (got && wide) *wide = fnv(wb, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + free(wb); +} +static int wmask_main(int scale, int window, int fast) { + static uint16_t page[64 * 64], clut[16]; + for (int i = 0; i < 64 * 64; i++) page[i] = (uint16_t)((i * 0x2469u) ^ (i >> 2) ^ 0x1111u); + for (int i = 0; i < 16; i++) clut[i] = (uint16_t)(i ? (0x0421u * (uint16_t)i) | ((i & 3) == 3 ? 0x8000u : 0) : 0); + glb_vram_transfer_in(512, 0, 64, 64, page); + glb_vram_transfer_in(512, 256, 16, 1, clut); + gl_renderer_set_wide_fast(fast); + glb_set_draw_area(0, 0, 1023, 511); + glb_set_draw_offset(0, 0); + glb_set_mask_bits(0, 0); + glb_set_semi_transparency(0, 0); + glb_set_color_modulation(128, 128, 128, 0); + if (window) check(hiw_ensure(0, FRAME_W) != NULL, "window covers the frame"); + glb_fill_rect(0, 0, FRAME_W, FRAME_H, 0x0c63); + glb_wide_configure(426, 53); + glb_wide_set_target(0); + glb_set_draw_area(0, 0, FRAME_W - 1, FRAME_H - 1); + for (int round = 0; round < 2; round++) { + int y = round * 110; + /* Set-mask draws reaching both margins. */ + glb_set_mask_bits(1, 0); + glb_draw_flat_rect(-40, y + 10, 100, 40, 0x001f); + glb_draw_textured_rect(290, y + 20, 64, 30, 0, 0, 512, 256, 0x0008); + glb_draw_gouraud_triangle(-30, y + 60, 0x001f, 60, y + 55, 0x7c00, 10, y + 100, 0x03e0); + /* Unchecked opaque draws over part of them: stencil behind alpha. */ + glb_set_mask_bits(0, 0); + glb_draw_flat_rect(-50, y + 30, 60, 10, 0x03e0); + glb_draw_textured_rect(320, y + 35, 40, 10, 8, 8, 512, 256, 0x0008); + glb_draw_flat_triangle(140, y + 5, 200, y + 5, 170, y + 40, 0x7c1f); + /* The check on: rebuilt; checked draws across margins and centre. */ + glb_set_mask_bits(0, 1); + glb_draw_flat_rect(-45, y + 15, 380, 12, 0x7fff); + glb_draw_textured_rect(-20, y + 40, 360, 20, 4, 4, 512, 256, 0x0008); + glb_draw_line(-30, y + 70, 360, y + 80, 0x5ef7); + glb_set_semi_transparency(1, 1); + glb_draw_flat_triangle(-40, y + 85, 360, y + 90, 150, y + 105, 0x2108); + glb_set_semi_transparency(0, 0); + wmask_dump(scale, NULL); /* a present: the centre splice */ + glb_set_mask_bits(0, 0); + glb_draw_flat_rect(30, y + 45, 280, 8, 0x4210); /* centre, unchecked */ + glb_set_mask_bits(0, 1); + glb_draw_flat_rect(-50, y + 44, 420, 12, 0x7c00); + if (round == 1) { /* a full-frame overlay rect, checked */ + glb_set_semi_transparency(1, 0); + glb_draw_flat_rect(0, 0, FRAME_W, FRAME_H, 0x0842); + glb_set_semi_transparency(0, 0); + } + } + glb_set_mask_bits(0, 0); + glb_set_draw_area(0, 0, 1023, 511); + gl_renderer_sync_cpu(); + check(gl_renderer_fbo_peek(0, 0, 1024, 512, peek), "native peek"); + uint64_t digest = fnv(peek, sizeof peek, 0xcbf29ce484222325ull); + int fw = FRAME_W * scale, fh = FRAME_H * scale, ow = 0, oh = 0; + uint32_t *img = (uint32_t *)malloc((size_t)fw * fh * 4); + int n = img ? gl_renderer_read_display_hires(0, 0, FRAME_W, FRAME_H, img, fw * fh, &ow, &oh) : 0; + check(n == fw * fh && ow == fw && oh == fh, "hires readback size"); + uint64_t hires = n ? fnv(img, (size_t)fw * fh * 4, 0xcbf29ce484222325ull) : 0; + free(img); + uint64_t wide = 0; + wmask_dump(scale, &wide); + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("digest=%016llx\n", (unsigned long long)digest); + printf("hires=%016llx\n", (unsigned long long)hires); + printf("wide=%016llx\n", (unsigned long long)wide); + printf("checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; +} + #if defined(PSX_TEST_RENDER_PASSES) /* Mode "passes": see the header. */ static int passes_main(int scale, int window) { @@ -1169,6 +1260,12 @@ int main(int argc, char **argv) { gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); return rc; } + if (!strcmp(mode, "wmask")) { + if (si.windowed) check(si.hr_scale == 1, "window mode engaged"); + int rc = wmask_main(scale, si.windowed, argc > 3 && argv[3][0] == '1'); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return rc; + } if (!strcmp(mode, "mask")) { if (si.windowed) check(si.hr_scale == 1, "window mode engaged"); int rc = mask_main(scale, si.windowed); From 35637627a85133055dacdbad3eb3f3d109350d94 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:26:28 -0700 Subject: [PATCH 09/57] Test: band scissor guard follows the shared wide_band_rows helper The stale-part stencil rebuild shares the draw-area rows with the band scissor; the guard checks the clamp where it now lives. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_wide_overlay_band_scissor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/runtime/tests/test_wide_overlay_band_scissor.py b/runtime/tests/test_wide_overlay_band_scissor.py index dece81ac..70a848dd 100644 --- a/runtime/tests/test_wide_overlay_band_scissor.py +++ b/runtime/tests/test_wide_overlay_band_scissor.py @@ -23,6 +23,8 @@ def body(text, signature): gl = (src / "gpu_gl_renderer.c").read_text(encoding="utf-8") band = body(gl, "static void wide_band_scissor_x(int x, int w) {") assert "static void wide_band_scissor(void) { wide_band_scissor_x(0, g_wide_w); }" in gl, "GL band scissor does not cover the full wide width" +if "wide_band_rows(" in band: # the rows are shared with the stencil rebuild + band = body(gl, "static void wide_band_rows(") assert "s_area_y1" in band and "s_area_y2" in band, "GL band scissor ignores the draw area" flat = body(gl, "static void wide_flat_rect_direct(") assert "wide_band_scissor();" in flat, "GL overlay pass does not use the band scissor" From 7ced6ff8bbf40803c4a83cbbd3b1c228445079b9 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:28:01 -0700 Subject: [PATCH 10/57] Test: scale guards follow mirrors queued on the full-VRAM surface Native-wide mirrors now ride the window queue on the full-VRAM surface too (wide_queue_live), share the queue tail (hiw_flush_tail) and scissor per command; the guards check those forms. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_gl_internal_scale_guards.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/runtime/tests/test_gl_internal_scale_guards.py b/runtime/tests/test_gl_internal_scale_guards.py index 48225ce8..2a96e122 100644 --- a/runtime/tests/test_gl_internal_scale_guards.py +++ b/runtime/tests/test_gl_internal_scale_guards.py @@ -132,8 +132,11 @@ class HiresWindowGuards(unittest.TestCase): self.assertIn("if (!hiw_on()", body(GL, fn)) # an immediate write into the window lands after every queued draw self.assertIn("hiw_flush_queue();", body(GL, fn)) + # Queued when the window is on, or (full-VRAM surface) when a native-wide + # mirror can ride the queue (wide_queue_live). for site in ("flush_tex_batch(void)", "flush_flat_batch(void)"): - self.assertIn("if (hiw_on() && hiw_enqueue_", body(GL, "static void " + site)) + self.assertRegex(body(GL, "static void " + site), + r"if \(\(hiw_on\(\) \|\| \(mirror[^)]*wide_queue_live\(\)\)\) &&\s*hiw_enqueue_") def test_wide_mirror_queued_in_windowed_mode(self): # Windowed mode: a draw's native-wide mirror rides in its window queue @@ -141,14 +144,16 @@ class HiresWindowGuards(unittest.TestCase): # surface), not as a surface switch per batch. The immediate mirror # runs only when the entry did not take it. for site, call in (("static void flush_tex_batch(void)", - "if (hiw_on() && hiw_enqueue_tex(nverts, semi, mirror, s_tb_gate)) mirror = 0;"), + "hiw_enqueue_tex(nverts, semi, mirror, s_tb_gate)) mirror = 0;"), ("static void flush_flat_batch(void)", - "if (hiw_on() && hiw_enqueue_geo(s_fb, nverts, semi, mask, mirror, s_fb_gate)) mirror = 0;"), + "hiw_enqueue_geo(s_fb, nverts, semi, mask, mirror, s_fb_gate)) mirror = 0;"), ("static void gpu_geometry(", "mirror = 0;")): fn = body(GL, site) self.assertIn(call, fn, site) - self.assertLess(fn.index(call), fn.index("if (mirror) {"), site) - self.assertIn("wide_target_begin(", fn[fn.index("if (mirror) {"):], site) + # the immediate mirror is the last `if (mirror) {` (an earlier one + # notes the stencil the draw leaves behind) + self.assertLess(fn.index(call), fn.rindex("if (mirror) {"), site) + self.assertIn("wide_target_begin(", fn[fn.rindex("if (mirror) {"):], site) ok = body(GL, "static int hiw_wide_queue_ok(int mirror)") self.assertIn("return mirror && g_wide_cur && s_ws_ablate == 0;", ok) for fn in ("static int hiw_enqueue_tex(", "static int hiw_enqueue_geo("): @@ -156,12 +161,14 @@ class HiresWindowGuards(unittest.TestCase): self.assertIn("if (!wq && !hiw_area_touches()) return 0;", b) self.assertIn("if (wq) hiw_wide_set(c, gate);", b) flush = body(GL, "static void hiw_flush_queue(void)") + if "hiw_flush_tail();" in flush: # shared with the native-wide-only queue + flush = body(GL, "static void hiw_flush_tail(void) {") self.assertIn("hiw_replay_wide();", flush) self.assertLess(flush.index("hiw_replay_wide();"), flush.index("hr_end();")) replay = body(GL, "static void hiw_replay_wide(void)") for need in ("if (!c->wfbo) continue;", "p_glBindFramebuffer(PSXGL_FRAMEBUFFER, c->wfbo);", "glViewport(0, 0, g_wide_w * S, VRAM_H * S);", - "glScissor(0, sy * S, g_wide_w * S, sh * S);", + "glScissor(c->wsx * S, c->wsy * S, c->wsw * S, c->wsh * S);", "p_glUniform1f(s_tex_uXoff, (float)c->wdx);", "p_glUniform1f(s_geo_uXoff, (float)c->wdx);", "tex_draw_passes_ex(c->vcount, c->semi, c->mask, c->check, 0);", From 2abd2200d0621ae6b4abe40cac32ec2a0fd7b0c5 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:54:58 -0700 Subject: [PATCH 11/57] Render thread core: SPSC command ring, frame bound, sync-point hand-off Backend-neutral core for the opt-in [video] render_thread: the emulation thread appends records to a single-producer/single-consumer ring and the render thread replays them in order with the graphics context current. rt_frame_end bounds closed frames in flight (backpressure), the render thread can tell a frame is stale, and rt_acquire is the sync point: drain, then move the context to the emulation thread until rt_release. No lock is held while a record executes. render_thread_test drives it with a fake executor and context token: ordering and payload integrity across ring wraps, oversize refusal, the in-flight bound under a slow consumer, stale detection, randomized acquire/release interleaving and stop while held (TSan clean). Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 11 + runtime/include/render_thread.h | 122 ++++++++ runtime/runtime.cmake | 1 + runtime/src/render_thread.c | 454 +++++++++++++++++++++++++++++ runtime/tests/test_render_thread.c | 238 +++++++++++++++ 5 files changed, 826 insertions(+) create mode 100644 runtime/include/render_thread.h create mode 100644 runtime/src/render_thread.c create mode 100644 runtime/tests/test_render_thread.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7882f8c1..aeec366a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1118,6 +1118,17 @@ if(BUILD_TESTING) add_executable(frame_pacing_test tests/test_frame_pacing.c) target_include_directories(frame_pacing_test PRIVATE include) add_test(NAME frame_pacing_test COMMAND frame_pacing_test) + # Render thread core ([video] render_thread, docs/RENDER_THREAD.md): + # ordering across ring wraps, the frame bound, stale-frame detection and + # the drain + context hand-off sync point, against a fake executor. + # The fixture itself uses POSIX threads and nanosleep. + if(NOT WIN32) + add_executable(render_thread_test tests/test_render_thread.c src/render_thread.c) + target_include_directories(render_thread_test PRIVATE include) + find_package(Threads REQUIRED) + target_link_libraries(render_thread_test PRIVATE Threads::Threads) + add_test(NAME render_thread_test COMMAND render_thread_test) + endif() # Same family of bug as frame_pacing: the starvation watchdog's staleness # gap must not wrap when the (cross-thread) heartbeat is newer than the diff --git a/runtime/include/render_thread.h b/runtime/include/render_thread.h new file mode 100644 index 00000000..c40c4641 --- /dev/null +++ b/runtime/include/render_thread.h @@ -0,0 +1,122 @@ +/* render_thread.h — opt-in render thread core ([video] render_thread). + * + * The emulation thread records host draw work into a single-producer / + * single-consumer command ring; the render thread owns the graphics context + * and replays the ring in order. This header is backend-neutral: it knows + * nothing about OpenGL. The OpenGL backend (gpu_gl_renderer.c) supplies the + * command set (exec callback) and the context hand-off (ctx callback). + * + * Model (docs/RENDER_THREAD.md): + * - Records are appended with rt_cmd_begin()/rt_cmd_commit() and replayed in + * order by exec() on the render thread. A record never splits across the + * ring end; payloads are copied, so the producer may reuse its buffers. + * - rt_frame_end() closes a guest frame. At most max_frames closed frames may + * wait in the ring; past that the emulation thread blocks (backpressure). + * While replaying, rt_newer_frame_queued() tells the backend a later frame + * is already recorded, so it may skip presenting a stale one. + * - rt_acquire() is the one sync point: it waits until every record has been + * executed, has the render thread release the context, and makes it + * current on the calling (emulation) thread. The emulation thread then + * holds the context ("held") until rt_release() hands it back. While held, + * the backend calls its functions directly, exactly as with no render + * thread at all. + * - No lock is held while exec() runs. Locks only guard sleeping/waking. + * + * Every function except the rt_on_render_thread() / rt_newer_frame_queued() + * queries is emulation-thread only. */ +#ifndef PSX_RENDER_THREAD_H +#define PSX_RENDER_THREAD_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Record header. size covers header + payload, rounded up to RT_ALIGN. */ +typedef struct RtCmd { + uint32_t size; + uint16_t op; /* backend opcode; 0 is reserved (padding/frame) */ + uint16_t flags; + uint32_t payload; /* payload bytes actually used */ + uint32_t seq; /* low 32 bits of the record sequence number */ +} RtCmd; + +#define RT_ALIGN 16u + +/* exec: replay one record on the render thread. ctx: make the graphics + * context current on the calling thread (current != 0) or release it + * (current == 0; the backend flushes first). */ +typedef void (*RtExecFn)(void *user, const RtCmd *cmd, const void *payload); +typedef void (*RtCtxFn)(void *user, int current); + +typedef struct RtConfig { + size_t ring_bytes; /* power of two; >= 1 MiB */ + int max_frames; /* closed frames allowed in flight (>= 1) */ + RtExecFn exec; + RtCtxFn ctx; + void *user; +} RtConfig; + +/* Start: the context must be current on the calling thread; on success it is + * released here and made current on the new render thread. Returns 1 on + * success, 0 (nothing started, context still current) on failure. */ +int rt_start(const RtConfig *cfg); +/* Stop: drain every record, join the thread and make the context current on + * the calling thread again. Safe when not running. */ +void rt_stop(void); +int rt_running(void); +int rt_on_render_thread(void); + +/* Append a record with room for payload_bytes; returns the payload pointer + * (16-byte aligned) or NULL when the record can never fit (larger than half + * the ring) — the caller then acquires and runs the work directly. Blocks + * while the ring is full. Must be followed by rt_cmd_commit() before any other + * rt_* call. op 0 is reserved. */ +void *rt_cmd_begin(uint16_t op, uint16_t flags, uint32_t payload_bytes); +void rt_cmd_commit(void); + +/* Close a guest frame: enqueue a frame marker, then block while more than + * max_frames closed frames are unconsumed. */ +void rt_frame_end(void); + +/* Render-thread query while executing: a frame marker newer than the record + * being executed is already queued (the frame being replayed is stale). */ +int rt_newer_frame_queued(void); + +/* Sync point. Drain, then move the context to the calling thread (held). + * reason is a static string kept in the acquire ring. No-op when already held + * or not running. */ +void rt_acquire(const char *reason); +/* Hand the context back to the render thread. No-op unless held. */ +void rt_release(void); +int rt_held(void); +/* Wait until every committed record has executed (context stays where it + * is). Used by tests and by rt_acquire. */ +void rt_drain(void); + +/* Statistics (emulation thread). */ +typedef struct RtStats { + uint64_t records, bytes, frames_produced, frames_consumed; + uint64_t acquires, releases, oversize; + uint64_t backpressure_waits, ring_full_waits; + uint64_t backpressure_ns, ring_full_ns, acquire_ns; + uint64_t render_busy_ns, render_idle_ns; + uint64_t ring_high_water; + int max_frames, running, held; +} RtStats; +void rt_get_stats(RtStats *out); + +/* Ring of the most recent sync points (newest first). Returns the count. */ +typedef struct RtAcquireEvent { + uint64_t frame; /* frames_produced at the time */ + uint64_t wait_ns; /* drain + hand-off time */ + const char *reason; +} RtAcquireEvent; +int rt_acquire_events(RtAcquireEvent *out, int cap); + +#ifdef __cplusplus +} +#endif +#endif /* PSX_RENDER_THREAD_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index a506e31b..097e65b8 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -370,6 +370,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/gpu_sw_renderer.c ${PSXRECOMP_ROOT}/runtime/src/gpu_vram_dirty.c ${PSXRECOMP_ROOT}/runtime/src/gpu_render.c + ${PSXRECOMP_ROOT}/runtime/src/render_thread.c ${PSXRECOMP_ROOT}/runtime/src/gpu_gl_renderer.c ${PSXRECOMP_ROOT}/runtime/src/gpu_hd_textures.cpp ${PSXRECOMP_ROOT}/runtime/src/hd_texture_pack.cpp diff --git a/runtime/src/render_thread.c b/runtime/src/render_thread.c new file mode 100644 index 00000000..33aa71e5 --- /dev/null +++ b/runtime/src/render_thread.c @@ -0,0 +1,454 @@ +/* render_thread.c — render thread core. See render_thread.h and + * docs/RENDER_THREAD.md. + * + * One producer (the emulation thread), one consumer (the render thread). + * Positions are monotonic 64-bit byte counters; the ring index is pos & mask. + * The consumer frees a record only after exec() returns, so a payload stays + * valid for the whole replay. Locks only guard sleeping: the sleeper raises + * its *_sleeping flag under the lock BEFORE re-checking its condition, and the + * waker publishes its position (seq_cst) BEFORE reading that flag, so a wake + * can never fall between a check and a wait. */ + +#include "render_thread.h" + +#include +#include +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +typedef SRWLOCK rt_mutex; +typedef CONDITION_VARIABLE rt_cond; +static void mtx_init(rt_mutex *m) { InitializeSRWLock(m); } +static void mtx_lock(rt_mutex *m) { AcquireSRWLockExclusive(m); } +static void mtx_unlock(rt_mutex *m) { ReleaseSRWLockExclusive(m); } +static void cnd_init(rt_cond *c) { InitializeConditionVariable(c); } +static void cnd_wait(rt_cond *c, rt_mutex *m) { SleepConditionVariableSRW(c, m, INFINITE, 0); } +static void cnd_bcast(rt_cond *c) { WakeAllConditionVariable(c); } +static uint64_t now_ns(void) { + LARGE_INTEGER f, t; + QueryPerformanceFrequency(&f); + QueryPerformanceCounter(&t); + return (uint64_t)((double)t.QuadPart * 1e9 / (double)f.QuadPart); +} +static void cpu_relax(void) { YieldProcessor(); } +#else +# include +# include +# include +typedef pthread_mutex_t rt_mutex; +typedef pthread_cond_t rt_cond; +static void mtx_init(rt_mutex *m) { pthread_mutex_init(m, NULL); } +static void mtx_lock(rt_mutex *m) { pthread_mutex_lock(m); } +static void mtx_unlock(rt_mutex *m) { pthread_mutex_unlock(m); } +static void cnd_init(rt_cond *c) { pthread_cond_init(c, NULL); } +static void cnd_wait(rt_cond *c, rt_mutex *m) { pthread_cond_wait(c, m); } +static void cnd_bcast(rt_cond *c) { pthread_cond_broadcast(c); } +static uint64_t now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} +static void cpu_relax(void) { +# if defined(__aarch64__) + __asm__ __volatile__("yield"); +# elif defined(__x86_64__) || defined(__i386__) + __asm__ __volatile__("pause"); +# endif +} +#endif + +#define OP_PAD 0u /* filler to the ring end; consumer skips it */ +#define F_FRAME 0x8000u /* op 0 with this flag: frame marker */ + +/* Producer-side wake batching: a sleeping render thread is woken once this + * much unread work is queued (frame ends, drains and acquires always wake). */ +#define WAKE_BYTES (128u * 1024u) +#define WAKE_RECORDS 256u +#define SPIN_LOOPS 4096 /* consumer polls this long before sleeping */ + +#define ACQ_RING 64 + +static struct { + /* configuration */ + uint8_t *ring; + uint64_t cap, mask; + int max_frames; + RtExecFn exec; + RtCtxFn ctx; + void *user; + + /* shared positions / counters */ + _Atomic uint64_t wpos, rpos; + _Atomic uint64_t frames_produced, frames_consumed; + _Atomic int render_sleeping, emu_sleeping; + _Atomic int running; + + /* hand-off + lifecycle, guarded by mtx */ + rt_mutex mtx; + rt_cond cv_work, cv_emu; + int release_req, stop; + _Atomic int released; /* read unlocked by emu_wait's first check */ + + /* producer-only */ + uint64_t pend_start, pend_size, pend_payload_off; + uint16_t pend_op, pend_flags; + uint32_t pend_payload; + int pending; + uint64_t unwoken_bytes, unwoken_records; + uint32_t seq; + int held; + + /* stats (producer unless noted) */ + uint64_t records, bytes, acquires, releases, oversize; + uint64_t bp_waits, full_waits, bp_ns, full_ns, acq_ns, high_water; + _Atomic uint64_t busy_ns, idle_ns; /* consumer */ + RtAcquireEvent acq_ring[ACQ_RING]; + uint64_t acq_n; + +#ifdef _WIN32 + HANDLE thread; +#else + pthread_t thread; +#endif +} R; + +#if defined(_MSC_VER) +static __declspec(thread) int t_render; +#else +static __thread int t_render; +#endif + +int rt_running(void) { return atomic_load(&R.running); } +int rt_on_render_thread(void) { return t_render; } +int rt_held(void) { return R.held; } + +static inline uint64_t align_up(uint64_t n) { + return (n + (RT_ALIGN - 1)) & ~(uint64_t)(RT_ALIGN - 1); +} + +/* ---- consumer ------------------------------------------------------------ */ + +static void wake_emu(void) { + if (atomic_load(&R.emu_sleeping)) { + mtx_lock(&R.mtx); + cnd_bcast(&R.cv_emu); + mtx_unlock(&R.mtx); + } +} + +/* Called with mtx held when the ring is empty: hand the context over while + * the emulation thread asks for it. Returns with mtx held. */ +static void serve_release_locked(void) { + if (!R.release_req) return; + mtx_unlock(&R.mtx); + R.ctx(R.user, 0); + mtx_lock(&R.mtx); + atomic_store(&R.released, 1); + cnd_bcast(&R.cv_emu); + while (R.release_req && !R.stop) cnd_wait(&R.cv_work, &R.mtx); + atomic_store(&R.released, 0); + if (R.stop) return; /* the stopper owns the context now */ + mtx_unlock(&R.mtx); + R.ctx(R.user, 1); + mtx_lock(&R.mtx); +} + +#ifdef _WIN32 +static DWORD WINAPI render_main(LPVOID arg) +#else +static void *render_main(void *arg) +#endif +{ + (void)arg; + t_render = 1; + R.ctx(R.user, 1); + uint64_t r = atomic_load(&R.rpos); + int have_ctx = 1; + for (;;) { + uint64_t w = atomic_load_explicit(&R.wpos, memory_order_acquire); + if (r == w) { + int spun = 0; + while (spun < SPIN_LOOPS && + atomic_load_explicit(&R.wpos, memory_order_acquire) == r) { + cpu_relax(); + spun++; + } + if (atomic_load_explicit(&R.wpos, memory_order_acquire) != r) continue; + uint64_t t0 = now_ns(); + mtx_lock(&R.mtx); + atomic_store(&R.render_sleeping, 1); + while (atomic_load(&R.wpos) == r && !R.stop && !R.release_req) + cnd_wait(&R.cv_work, &R.mtx); + atomic_store(&R.render_sleeping, 0); + if (R.release_req && atomic_load(&R.wpos) == r) { + serve_release_locked(); + if (R.stop) have_ctx = 0; + } + int done = R.stop && atomic_load(&R.wpos) == r; + mtx_unlock(&R.mtx); + atomic_fetch_add(&R.idle_ns, now_ns() - t0); + if (done) break; + continue; + } + uint64_t t0 = now_ns(); + const RtCmd *c = (const RtCmd *)(R.ring + (r & R.mask)); + if (c->op == OP_PAD) { + if (c->flags & F_FRAME) + atomic_fetch_add(&R.frames_consumed, 1); + } else { + R.exec(R.user, c, (const uint8_t *)c + sizeof(RtCmd)); + } + r += c->size; + atomic_store(&R.rpos, r); + atomic_fetch_add(&R.busy_ns, now_ns() - t0); + wake_emu(); + } + if (have_ctx) R.ctx(R.user, 0); + t_render = 0; +#ifdef _WIN32 + return 0; +#else + return NULL; +#endif +} + +/* ---- producer ------------------------------------------------------------ */ + +static void wake_render(int force) { + if (!force && R.unwoken_bytes < WAKE_BYTES && R.unwoken_records < WAKE_RECORDS) + return; + R.unwoken_bytes = R.unwoken_records = 0; + if (atomic_load(&R.render_sleeping)) { + mtx_lock(&R.mtx); + cnd_bcast(&R.cv_work); + mtx_unlock(&R.mtx); + } +} + +/* Block until pred() holds; the render thread wakes us after every record. */ +typedef int (*Pred)(uint64_t arg); +static void emu_wait(Pred pred, uint64_t arg) { + if (pred(arg)) return; + wake_render(1); + mtx_lock(&R.mtx); + atomic_store(&R.emu_sleeping, 1); + while (!pred(arg)) cnd_wait(&R.cv_emu, &R.mtx); + atomic_store(&R.emu_sleeping, 0); + mtx_unlock(&R.mtx); +} + +static int pred_space(uint64_t need_end) { /* need_end: wpos after the write */ + return need_end - atomic_load(&R.rpos) <= R.cap; +} +static int pred_drained(uint64_t w) { return atomic_load(&R.rpos) == w; } +static int pred_frames(uint64_t limit) { + return atomic_load(&R.frames_produced) - atomic_load(&R.frames_consumed) <= limit; +} +static int pred_released(uint64_t unused) { (void)unused; return atomic_load(&R.released); } + +void *rt_cmd_begin(uint16_t op, uint16_t flags, uint32_t payload_bytes) { + if (!rt_running() || R.pending || op == OP_PAD) return NULL; + uint64_t size = align_up(sizeof(RtCmd) + (uint64_t)payload_bytes); + if (size > R.cap / 2) { R.oversize++; return NULL; } + uint64_t w = atomic_load_explicit(&R.wpos, memory_order_relaxed); + uint64_t off = w & R.mask; + uint64_t pad = (off + size > R.cap) ? R.cap - off : 0; + if (!pred_space(w + pad + size)) { + uint64_t t0 = now_ns(); + R.full_waits++; + emu_wait(pred_space, w + pad + size); + R.full_ns += now_ns() - t0; + } + if (pad) { + RtCmd *p = (RtCmd *)(R.ring + off); + p->size = (uint32_t)pad; p->op = OP_PAD; p->flags = 0; p->payload = 0; p->seq = 0; + w += pad; + off = 0; + } + RtCmd *c = (RtCmd *)(R.ring + off); + c->size = (uint32_t)size; + c->op = op; + c->flags = flags; + c->payload = payload_bytes; + c->seq = R.seq++; + R.pend_start = w; + R.pend_size = size; + R.pending = 1; + return (uint8_t *)c + sizeof(RtCmd); +} + +void rt_cmd_commit(void) { + if (!R.pending) return; + R.pending = 0; + uint64_t end = R.pend_start + R.pend_size; + atomic_store(&R.wpos, end); + R.records++; + R.bytes += R.pend_size; + uint64_t depth = end - atomic_load(&R.rpos); + if (depth > R.high_water) R.high_water = depth; + R.unwoken_bytes += R.pend_size; + R.unwoken_records++; + wake_render(0); +} + +static void commit_marker(uint16_t flags) { + uint64_t w = atomic_load_explicit(&R.wpos, memory_order_relaxed); + uint64_t off = w & R.mask; + uint64_t size = sizeof(RtCmd); + if (!pred_space(w + size)) emu_wait(pred_space, w + size); + RtCmd *c = (RtCmd *)(R.ring + off); /* 16 bytes always fit before the end */ + c->size = (uint32_t)size; c->op = OP_PAD; c->flags = flags; c->payload = 0; c->seq = R.seq++; + atomic_store(&R.wpos, w + size); +} + +void rt_frame_end(void) { + if (!rt_running()) return; + if (R.held) { + /* Nothing is queued while the context is held; count the frame as + * produced and consumed so the in-flight bound stays meaningful. */ + atomic_fetch_add(&R.frames_produced, 1); + atomic_fetch_add(&R.frames_consumed, 1); + return; + } + atomic_fetch_add(&R.frames_produced, 1); + commit_marker(F_FRAME); + wake_render(1); + if (!pred_frames((uint64_t)R.max_frames)) { + uint64_t t0 = now_ns(); + R.bp_waits++; + emu_wait(pred_frames, (uint64_t)R.max_frames); + R.bp_ns += now_ns() - t0; + } +} + +int rt_newer_frame_queued(void) { + return atomic_load(&R.frames_produced) >= atomic_load(&R.frames_consumed) + 2; +} + +void rt_drain(void) { + if (!rt_running() || R.held) return; + uint64_t w = atomic_load(&R.wpos); + emu_wait(pred_drained, w); +} + +void rt_acquire(const char *reason) { + if (!rt_running() || R.held) return; + uint64_t t0 = now_ns(); + rt_drain(); + mtx_lock(&R.mtx); + R.release_req = 1; + cnd_bcast(&R.cv_work); + mtx_unlock(&R.mtx); + emu_wait(pred_released, 0); + R.ctx(R.user, 1); + R.held = 1; + uint64_t dt = now_ns() - t0; + R.acquires++; + R.acq_ns += dt; + RtAcquireEvent *e = &R.acq_ring[R.acq_n++ % ACQ_RING]; + e->frame = atomic_load(&R.frames_produced); + e->wait_ns = dt; + e->reason = reason ? reason : "?"; +} + +void rt_release(void) { + if (!rt_running() || !R.held) return; + R.ctx(R.user, 0); + mtx_lock(&R.mtx); + R.release_req = 0; + cnd_bcast(&R.cv_work); + mtx_unlock(&R.mtx); + R.held = 0; + R.releases++; +} + +/* ---- lifecycle ----------------------------------------------------------- */ + +int rt_start(const RtConfig *cfg) { + if (rt_running() || !cfg || !cfg->exec || !cfg->ctx) return 0; + size_t cap = cfg->ring_bytes; + if (cap < (1u << 20) || (cap & (cap - 1))) return 0; + uint8_t *ring = (uint8_t *)malloc(cap); + if (!ring) return 0; + memset(&R, 0, sizeof(R)); + R.ring = ring; + R.cap = cap; + R.mask = cap - 1; + R.max_frames = cfg->max_frames < 1 ? 1 : cfg->max_frames; + R.exec = cfg->exec; + R.ctx = cfg->ctx; + R.user = cfg->user; + mtx_init(&R.mtx); + cnd_init(&R.cv_work); + cnd_init(&R.cv_emu); + /* The context leaves this thread before the render thread takes it. */ + R.ctx(R.user, 0); + atomic_store(&R.running, 1); +#ifdef _WIN32 + R.thread = CreateThread(NULL, 0, render_main, NULL, 0, NULL); + int ok = R.thread != NULL; +#else + int ok = pthread_create(&R.thread, NULL, render_main, NULL) == 0; +#endif + if (!ok) { + atomic_store(&R.running, 0); + R.ctx(R.user, 1); + free(R.ring); + R.ring = NULL; + return 0; + } + return 1; +} + +void rt_stop(void) { + if (!rt_running()) return; + int was_held = R.held; + if (!was_held) rt_drain(); + mtx_lock(&R.mtx); + R.stop = 1; + R.release_req = 0; + cnd_bcast(&R.cv_work); + mtx_unlock(&R.mtx); +#ifdef _WIN32 + WaitForSingleObject(R.thread, INFINITE); + CloseHandle(R.thread); +#else + pthread_join(R.thread, NULL); +#endif + atomic_store(&R.running, 0); + R.held = 0; + if (!was_held) R.ctx(R.user, 1); + free(R.ring); + R.ring = NULL; +} + +void rt_get_stats(RtStats *o) { + memset(o, 0, sizeof(*o)); + o->records = R.records; + o->bytes = R.bytes; + o->frames_produced = atomic_load(&R.frames_produced); + o->frames_consumed = atomic_load(&R.frames_consumed); + o->acquires = R.acquires; + o->releases = R.releases; + o->oversize = R.oversize; + o->backpressure_waits = R.bp_waits; + o->ring_full_waits = R.full_waits; + o->backpressure_ns = R.bp_ns; + o->ring_full_ns = R.full_ns; + o->acquire_ns = R.acq_ns; + o->render_busy_ns = atomic_load(&R.busy_ns); + o->render_idle_ns = atomic_load(&R.idle_ns); + o->ring_high_water = R.high_water; + o->max_frames = R.max_frames; + o->running = rt_running(); + o->held = R.held; +} + +int rt_acquire_events(RtAcquireEvent *out, int cap) { + int n = 0; + for (uint64_t i = 0; i < R.acq_n && i < ACQ_RING && n < cap; i++) + out[n++] = R.acq_ring[(R.acq_n - 1 - i) % ACQ_RING]; + return n; +} diff --git a/runtime/tests/test_render_thread.c b/runtime/tests/test_render_thread.c new file mode 100644 index 00000000..a906e482 --- /dev/null +++ b/runtime/tests/test_render_thread.c @@ -0,0 +1,238 @@ +/* Render thread core (render_thread.c): ordering, payload integrity across + * ring wraps, the frame bound (backpressure), stale-frame detection, the + * sync point (drain + context hand-off) and shutdown. The "context" here is a + * token that may be current on one thread at a time; ctx() checks that. */ +#include "render_thread.h" + +#include +#include +#include +#include +#include +#include + +static int checks, failures; +#define CHECK(c, ...) do { checks++; if (!(c)) { failures++; \ + fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); } } while (0) + +/* ---- context token ------------------------------------------------------- */ +static _Atomic int ctx_owner; /* 0 none, 1 emu, 2 render */ +static _Atomic int ctx_violations; +static __thread int t_is_render; /* set by exec on first call */ +static void fake_ctx(void *user, int current) { + (void)user; + int me = rt_on_render_thread() ? 2 : 1; + if (current) { + int expect = 0; + if (!atomic_compare_exchange_strong(&ctx_owner, &expect, me)) + atomic_fetch_add(&ctx_violations, 1); + } else { + int expect = me; + if (!atomic_compare_exchange_strong(&ctx_owner, &expect, 0)) + atomic_fetch_add(&ctx_violations, 1); + } +} + +/* ---- executor ------------------------------------------------------------ */ +static uint64_t exec_next; /* next expected sequence id */ +static _Atomic uint64_t exec_count; +static _Atomic int exec_errors; +static _Atomic int exec_off_thread; +static _Atomic int exec_without_ctx; +static _Atomic int stale_seen; +static int exec_delay_us; + +static void sleep_us(int us) { + struct timespec ts = { us / 1000000, (long)(us % 1000000) * 1000 }; + nanosleep(&ts, NULL); +} + +enum { OP_DATA = 1, OP_PRESENT = 2 }; + +static void fake_exec(void *user, const RtCmd *c, const void *payload) { + (void)user; + t_is_render = 1; + if (!rt_on_render_thread()) atomic_fetch_add(&exec_off_thread, 1); + if (atomic_load(&ctx_owner) != 2) atomic_fetch_add(&exec_without_ctx, 1); + if (c->op == OP_PRESENT) { + if (rt_newer_frame_queued()) atomic_fetch_add(&stale_seen, 1); + } else if (c->op == OP_DATA) { + const uint64_t *p = (const uint64_t *)payload; + if (c->payload < 8 || p[0] != exec_next) atomic_fetch_add(&exec_errors, 1); + const uint8_t *b = (const uint8_t *)payload; + for (uint32_t i = 8; i < c->payload; i++) + if (b[i] != (uint8_t)(p[0] * 31u + i)) { atomic_fetch_add(&exec_errors, 1); break; } + exec_next++; + } + if (exec_delay_us) sleep_us(exec_delay_us); + atomic_fetch_add(&exec_count, 1); +} + +static uint64_t emit_seq; +static int emit(uint32_t bytes) { + if (bytes < 8) bytes = 8; + uint8_t *p = (uint8_t *)rt_cmd_begin(OP_DATA, 0, bytes); + if (!p) return 0; + if (((uintptr_t)p & (RT_ALIGN - 1)) != 0) { failures++; fprintf(stderr, "FAIL unaligned payload\n"); } + memcpy(p, &emit_seq, 8); + for (uint32_t i = 8; i < bytes; i++) p[i] = (uint8_t)(emit_seq * 31u + i); + rt_cmd_commit(); + emit_seq++; + return 1; +} + +static void reset_state(void) { + exec_next = 0; emit_seq = 0; + atomic_store(&exec_count, 0); + atomic_store(&exec_errors, 0); + atomic_store(&exec_off_thread, 0); + atomic_store(&exec_without_ctx, 0); + atomic_store(&stale_seen, 0); + atomic_store(&ctx_violations, 0); + atomic_store(&ctx_owner, 1); /* the "context" starts current here */ + exec_delay_us = 0; +} + +static int start(size_t ring, int frames) { + RtConfig cfg = { ring, frames, fake_exec, fake_ctx, NULL }; + return rt_start(&cfg); +} + +static uint32_t rng = 12345u; +static uint32_t rnd(void) { rng = rng * 1664525u + 1013904223u; return rng >> 8; } + +/* 1. Ordering and payload integrity across many wraps of a small ring. */ +static void test_order_and_wrap(void) { + reset_state(); + CHECK(start(1u << 20, 3), "start"); + CHECK(atomic_load(&ctx_owner) != 1, "context left the starting thread"); + for (int f = 0; f < 400; f++) { + int n = 1 + (int)(rnd() % 40); + for (int i = 0; i < n; i++) CHECK(emit(8 + rnd() % 20000), "emit"); + rt_frame_end(); + } + rt_drain(); + CHECK(atomic_load(&exec_count) == emit_seq, "all records executed (%llu/%llu)", + (unsigned long long)atomic_load(&exec_count), (unsigned long long)emit_seq); + CHECK(atomic_load(&exec_errors) == 0, "order/payload errors %d", atomic_load(&exec_errors)); + CHECK(atomic_load(&exec_off_thread) == 0, "exec on render thread only"); + CHECK(atomic_load(&exec_without_ctx) == 0, "exec with context current"); + RtStats st; rt_get_stats(&st); + CHECK(st.frames_produced == 400 && st.frames_consumed == 400, "frame counters"); + CHECK(st.ring_high_water <= (1u << 20), "high water within ring"); + rt_stop(); + CHECK(atomic_load(&ctx_owner) == 1, "stop returns the context to the caller"); + CHECK(atomic_load(&ctx_violations) == 0, "context never current on two threads"); +} + +/* 2. Oversize records are refused (caller runs them directly). */ +static void test_oversize(void) { + reset_state(); + CHECK(start(1u << 20, 2), "start"); + CHECK(rt_cmd_begin(OP_DATA, 0, (1u << 19)) == NULL, "half-ring record refused"); + CHECK(emit((1u << 19) - 64), "just under half fits"); + rt_drain(); + RtStats st; rt_get_stats(&st); + CHECK(st.oversize == 1, "oversize counted"); + rt_stop(); +} + +/* 3. Backpressure: with a slow render thread the producer never runs more + * than max_frames closed frames ahead, and stale frames are flagged. */ +static void test_backpressure(void) { + reset_state(); + exec_delay_us = 2000; /* 2 ms per record, 2 records per frame */ + CHECK(start(1u << 22, 2), "start"); + uint64_t worst = 0; + for (int f = 0; f < 40; f++) { + emit(64); + uint8_t *p = (uint8_t *)rt_cmd_begin(OP_PRESENT, 0, 0); (void)p; rt_cmd_commit(); + rt_frame_end(); + RtStats st; rt_get_stats(&st); + uint64_t d = st.frames_produced - st.frames_consumed; + if (d > worst) worst = d; + } + rt_drain(); + RtStats st; rt_get_stats(&st); + CHECK(worst <= 2, "in-flight bound held (worst %llu)", (unsigned long long)worst); + CHECK(st.backpressure_waits > 0, "producer was held back"); + CHECK(atomic_load(&stale_seen) > 0, "stale presents detected while behind"); + CHECK(atomic_load(&exec_errors) == 0, "order kept under backpressure"); + rt_stop(); + + /* A fast render thread never sees a stale frame for a 1-deep queue. */ + reset_state(); + CHECK(start(1u << 20, 1), "start"); + for (int f = 0; f < 50; f++) { + emit(64); + rt_cmd_begin(OP_PRESENT, 0, 0); rt_cmd_commit(); + rt_frame_end(); + rt_drain(); + } + CHECK(atomic_load(&stale_seen) == 0, "no stale frames when drained each frame"); + rt_stop(); +} + +/* 4. Sync point: acquire drains everything first and moves the context; + * release hands it back; interleaving with records and frames stays ordered + * and never puts the context on two threads. */ +static void test_acquire_release(void) { + reset_state(); + exec_delay_us = 50; + CHECK(start(1u << 20, 3), "start"); + uint64_t expected_acquires = 0; + for (int round = 0; round < 300; round++) { + if (rt_held()) { + /* The backend never records while it holds the context. */ + if (rnd() % 2) rt_release(); else { rt_frame_end(); continue; } + } + int n = (int)(rnd() % 8); + for (int i = 0; i < n; i++) emit(8 + rnd() % 4000); + if (rnd() % 3 == 0) rt_frame_end(); + uint64_t before = emit_seq; + rt_acquire("test"); + expected_acquires++; + CHECK(atomic_load(&exec_count) >= before, "acquire drained"); + CHECK(atomic_load(&ctx_owner) == 1, "context current on the caller while held"); + CHECK(rt_held(), "held"); + rt_frame_end(); /* held: counted, not queued */ + } + rt_release(); + rt_drain(); + RtStats st; rt_get_stats(&st); + CHECK(st.acquires == expected_acquires, "acquire count %llu", (unsigned long long)st.acquires); + CHECK(atomic_load(&ctx_violations) == 0, "context exclusive (%d)", atomic_load(&ctx_violations)); + CHECK(atomic_load(&exec_without_ctx) == 0, "exec always with context"); + RtAcquireEvent ev[4]; + CHECK(rt_acquire_events(ev, 4) == 4 && !strcmp(ev[0].reason, "test"), "acquire ring"); + rt_stop(); + CHECK(atomic_load(&ctx_owner) == 1, "context back on caller after stop"); +} + +/* 5. Stop while held: the caller keeps the context; nothing is lost. */ +static void test_stop_while_held(void) { + reset_state(); + CHECK(start(1u << 20, 3), "start"); + for (int i = 0; i < 100; i++) emit(100); + rt_acquire("stop"); + rt_stop(); + CHECK(atomic_load(&exec_count) == 100, "drained before stop"); + CHECK(atomic_load(&ctx_owner) == 1, "caller owns the context"); + CHECK(atomic_load(&ctx_violations) == 0, "no violations"); + CHECK(!rt_running(), "stopped"); + /* Restart works. */ + CHECK(start(1u << 20, 3), "restart"); + emit(16); + rt_stop(); +} + +int main(void) { + test_order_and_wrap(); + test_oversize(); + test_backpressure(); + test_acquire_release(); + test_stop_while_held(); + printf("checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; +} From 4a2f5a67d2df4774729906539165807bc76dd4d5 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:24:09 -0700 Subject: [PATCH 12/57] OpenGL backend: record on the emulation thread, replay on the render thread With the render thread running, the backend's facade table records every state change, primitive, fill, copy, CPU->VRAM upload (payload copied) and native-wide call, and the present, into the command ring; the render thread replays them through the unchanged glb_* functions. With it off gl_backend_get returns the existing table and nothing new runs. Sync points: every other GL entry point reached on the emulation thread (readbacks, savestate restage, render passes, OpenXR, settings, diagnostics) drains the ring and takes the context (GL_RT_SYNC); the emulation thread then runs the rest of that frame directly, exactly as without the thread, and hands the context back at the frame boundary when the frame may run asynchronously again (no depth24 display, dual-raster, frame interpolation, render pass, netplay or OpenXR). Guest-visible VRAM stays gpu.c's array: while the render thread owns the context, uploads stage from a private copy that receives each payload in command order. Guest state the backend reads while drawing (per-primitive widescreen tags, the native-wide fast-path latch) is captured at record time; host overlays travel with the present; a present that a newer recorded frame already supersedes is skipped. The emulation thread answers projective/wide-present/draw-area queries from mirrors of the recorded calls. The per-frame display-ring readback is queued (fbo_peek_deferred) instead of being a sync point. The GL fixtures link render_thread.c and stub the two facade hooks; the scale fixture also gains the gpu_ws_background_requires_full_composite stub it was already missing (it failed to link on master). Co-Authored-By: Claude Opus 5.5 --- runtime/include/gpu_gl_renderer.h | 19 + runtime/include/gpu_render.h | 1 + runtime/include/gpu_sw_renderer.h | 2 + runtime/src/gpu_gl_renderer.c | 883 ++++++++++++++++++++++- runtime/src/gpu_render.c | 10 + runtime/src/gpu_sw_renderer.c | 6 + runtime/tests/run_gl_readback_region.py | 2 + runtime/tests/run_gl_scale_invariance.py | 2 + runtime/tests/run_gl_texture_filter.py | 2 + runtime/tests/test_gl_readback_region.c | 4 + runtime/tests/test_gl_scale_invariance.c | 4 + 11 files changed, 900 insertions(+), 35 deletions(-) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index b97199cd..41e29077 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -8,6 +8,7 @@ * same struct tag) so this header needs no SDL include. */ #include +#include #include "gpu_projective_clip.h" struct SDL_Window; @@ -464,6 +465,24 @@ void gl_renderer_batch_diag(uint64_t out[8]); void gl_renderer_set_texture_window_batching(int on); int gl_renderer_get_texture_window_batching(void); +/* Render thread ([video] render_thread, docs/RENDER_THREAD.md). Opt-in, off + * by default; with it off nothing below runs and the backend is unchanged. + * start: after gl_renderer_init_context, on the thread holding the context; + * moves the context to a new render thread and records backend calls from + * here on (1 = running). max_frames bounds closed frames in flight. stop: + * drain and take the context back (gl_renderer_shutdown also stops it). + * frame_boundary: once per vblank after the present; hands the context to or + * from the render thread and applies the in-flight bound. sync: an explicit + * sync point for readers of renderer-written host state outside the GL entry + * points. json: stats object body for the render_thread debug command. */ +int gl_renderer_render_thread_start(int max_frames); +void gl_renderer_render_thread_stop(void); +int gl_renderer_render_thread_active(void); +void gl_renderer_render_thread_frame_boundary(void); +void gl_renderer_render_thread_sync(const char *reason); +int gl_renderer_render_thread_json(char *out, size_t cap); +int gl_renderer_fbo_peek_deferred(int x, int y, int w, int h, uint16_t *out); + #ifdef __cplusplus } #endif diff --git a/runtime/include/gpu_render.h b/runtime/include/gpu_render.h index 4dbad651..5f70d079 100644 --- a/runtime/include/gpu_render.h +++ b/runtime/include/gpu_render.h @@ -25,6 +25,7 @@ typedef enum { void gr_set_backend(GrBackend backend); /* call before gr_init() */ GrBackend gr_backend(void); /* effective backend after init */ +void gr_refresh_backend(void); /* re-fetch the GL table (render thread) */ /* Lifecycle / global state */ void gr_init(uint16_t *vram); diff --git a/runtime/include/gpu_sw_renderer.h b/runtime/include/gpu_sw_renderer.h index c549a585..6525c2ab 100644 --- a/runtime/include/gpu_sw_renderer.h +++ b/runtime/include/gpu_sw_renderer.h @@ -12,6 +12,8 @@ extern "C" { /* Initialize software renderer */ void sw_renderer_init(uint16_t* vram); +/* Rebind the VRAM array only; draw state is kept. */ +void sw_renderer_rebind_vram(uint16_t* vram); /* Internal-resolution supersampling (SSAA). * scale == 1 : disabled, renderer behaves exactly as native VRAM only. diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 84c85148..f2d99b6f 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -349,6 +349,76 @@ static SDL_GLContext s_ctx = NULL; static uint16_t *s_vram = NULL; /* CPU VRAM array (gpu.c's storage) */ static int s_swap_interval = 1; /* SDL_GL swap interval (vsync mode) */ +/* ---- render thread ([video] render_thread, docs/RENDER_THREAD.md) -------- + * While s_rth_on, the render thread owns the GL context and replays the + * backend calls the emulation thread recorded (GL_RT_BACKEND, end of file). + * A GL entry point reached on the emulation thread is a sync point + * (GL_RT_SYNC): it drains the queue and moves the context to the emulation + * thread, which then runs every call directly, exactly as without the render + * thread, until the next frame boundary hands the context back. + * + * s_vram is the guest-visible VRAM (gpu.c's array) whenever the emulation + * thread runs GL, and a private copy while the render thread does: replayed + * uploads stage from the private copy, which receives each CPU->VRAM payload + * in command order, so the emulation thread can keep writing gpu.c's array. + * + * Guest state the backend reads while drawing (per-primitive widescreen tags, + * the native-wide fast-path latch, depth24) is captured when a call is + * recorded and read back through the ctx_* accessors below during replay. */ +#include "render_thread.h" +static int s_rth_on = 0; +static uint16_t *s_rth_vram_pub = NULL; /* gpu.c's VRAM (guest-visible) */ +static uint16_t *s_rth_vram_priv = NULL; /* render thread's upload source */ +/* Replay-side capture of guest state (valid only on the render thread). */ +static int s_rthx_prim = 0; /* current record carries prim tags */ +static int s_rthx_tagged = 0, s_rthx_backdrop = 0; +static int s_rths_flat_bd = 0, s_rths_vp_w = 0, s_rths_bg_full = 0; +static inline int rth_replaying(void) { return s_rth_on && rt_on_render_thread(); } +extern int psx_ws_prim_is_tagged(void); /* gpu.c: is the current GP0 prim sprite-tagged? */ +extern int psx_ws_prim_in_backdrop(void); /* gpu.c: is its source addr in the flower-field struct? */ +extern int gpu_ws_nw_flat_backdrop_enabled(void); /* gpu.c: per-title flat backdrop opt-in */ +static int ctx_prim_tagged(void) { + return rth_replaying() ? (s_rthx_prim ? s_rthx_tagged : 0) : psx_ws_prim_is_tagged(); +} +static int ctx_prim_in_backdrop(void) { + return rth_replaying() ? (s_rthx_prim ? s_rthx_backdrop : 0) : psx_ws_prim_in_backdrop(); +} +static int ctx_flat_backdrop(void) { + return rth_replaying() ? s_rths_flat_bd : gpu_ws_nw_flat_backdrop_enabled(); +} +static int ctx_vp_width(void) { + return rth_replaying() ? s_rths_vp_w : gpu_ws_netplay_local_viewport_width(); +} +static int ctx_bg_full(void) { + return rth_replaying() ? s_rths_bg_full : gpu_ws_background_requires_full_composite(); +} +/* The render thread only ever replays 15-bit frames: a depth24 display is a + * sync point (the emulation thread draws those frames itself). */ +static int ctx_depth24(void) { return rth_replaying() ? 0 : gpu_display_is_depth24(); } +static void gl_rth_acquire(const char *reason); +#define GL_RT_SYNC(reason) \ + do { if (s_rth_on && !rt_on_render_thread()) gl_rth_acquire(reason); } while (0) +/* Recording (defined with GL_RT_BACKEND at the end of the file). */ +enum { + RTH_SEMI = 1, RTH_MASK, RTH_TWIN, RTH_MOD, RTH_PRECISE, RTH_PERSP, + RTH_FILL, RTH_COPY, RTH_FLAT_TRI, RTH_GOURAUD_TRI, RTH_TEX_TRI, + RTH_SHADED_TEX_TRI, RTH_FLAT_RECT, RTH_TEX_RECT, RTH_TEX_RECT_SCALED, + RTH_LINE, RTH_SHADED_LINE, RTH_VRAM_WRITE, RTH_XFER_IN, RTH_AREA, + RTH_OFFSET, RTH_WIDE_CONFIGURE, RTH_WIDE_VIEW, RTH_WIDE_TARGET, + RTH_WIDE_DISABLE, RTH_WIDE_CLEAR, RTH_WIDE_CLEAR_MARGINS, RTH_PROJ_TRI, + RTH_WIDE_RECOVERY, RTH_INTERP_SUSPENDED, RTH_PRESENT_VRAM, + RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK +}; +static int rth_record_mode(void); +static int rth_rec_ints(uint16_t op, uint16_t flags, int n, const int32_t *v); +static uint16_t rth_prim_flags(void); +static int rth_record_present(uint16_t op, int n, const int32_t *v); +static int rth_mirror_wide_present_ok(int base_x); +/* Emulation-side mirror: the recorded wide target is a live surface. */ +static int s_rthm_cur = 0; +#define RTH_REC(op, fl, ...) do { const int32_t rth_v_[] = { __VA_ARGS__ }; \ + rth_rec_ints((op), (fl), (int)(sizeof rth_v_ / sizeof rth_v_[0]), rth_v_); } while (0) + /* ---- Two scales ------------------------------------------------------------ * s_hr_scale the scale of s_hr_fbo, the authoritative VRAM surface, and of * everything that reads, mirrors, backs up or restores it: hr @@ -763,9 +833,11 @@ static int s_wide_suppress = 0; * canonical and wide passes so a face cannot split at the center-copy edge. */ static uint64_t s_wide_triangle_recovery_count; void gl_renderer_note_wide_triangle_recovery(int recovered) { + if (rth_record_mode()) { RTH_REC(RTH_WIDE_RECOVERY, 0, recovered); return; } if (recovered && s_raster_ok && g_wide_cur) ++s_wide_triangle_recovery_count; } uint64_t gl_renderer_wide_triangle_recovery_count(void) { + GL_RT_SYNC("wide_triangle_recovery_count"); return s_wide_triangle_recovery_count; } @@ -1151,8 +1223,10 @@ static void coh_record(int kind, int x0, int y0, int x1, int y1) { present_dirty_rect(x0, y0, x1, y1, 1); } -uint64_t gl_renderer_coh_total(void) { return s_coh_seq; } +uint64_t gl_renderer_coh_total(void) { + GL_RT_SYNC("coh_total"); return s_coh_seq; } int gl_renderer_coh_get(uint64_t seq, GlCohEvent *out) { + GL_RT_SYNC("coh_get"); if (seq >= s_coh_seq) return 0; if (s_coh_seq - seq > GL_COH_RING_CAP) return 0; /* evicted */ *out = s_coh_ring[seq % GL_COH_RING_CAP]; @@ -1218,8 +1292,10 @@ static void pres_record(int path, int dx, int dy, int w, int h, s_pres_seq++; } -uint64_t gl_renderer_pres_total(void) { return s_pres_seq; } +uint64_t gl_renderer_pres_total(void) { + GL_RT_SYNC("pres_total"); return s_pres_seq; } int gl_renderer_pres_get(uint64_t seq, GlPresEvent *out) { + GL_RT_SYNC("pres_get"); if (seq >= s_pres_seq) return 0; if (s_pres_seq - seq > GL_PRES_RING_CAP) return 0; /* evicted */ *out = s_pres_ring[seq % GL_PRES_RING_CAP]; @@ -2653,10 +2729,6 @@ static void wide_target_end(GLint uXoff, GLint uXhalf) { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); } -extern int psx_ws_prim_is_tagged(void); /* gpu.c: is the current GP0 prim sprite-tagged? */ -extern int psx_ws_prim_in_backdrop(void); /* gpu.c: is its source addr in the flower-field struct? */ -extern int gpu_ws_nw_flat_backdrop_enabled(void); /* gpu.c: per-title flat backdrop opt-in */ - /* Per-prim gate: stretch this prim iff native-wide + feature on AND the prim's * source address is inside the flower-field backdrop data structure (precise — * excludes the 3D rock/foreground, which is untagged AND has narrow prims so the @@ -2668,12 +2740,12 @@ static int bd_prim_gate(const int *xs, int n, int textured) { * Stretch those only in the native-wide mirror: the canonical framebuffer * remains byte-for-byte 4:3, while the flat backdrop reaches the reveal * margins. Opt-in because flat foreground geometry is title-dependent. */ - if (!textured && gpu_ws_nw_flat_backdrop_enabled()) return 1; - if (g_ws_bd_phase_mode != 0) return psx_ws_prim_in_backdrop(); /* default: precise address gate */ + if (!textured && ctx_flat_backdrop()) return 1; + if (g_ws_bd_phase_mode != 0) return ctx_prim_in_backdrop(); /* default: precise address gate */ /* mode 0: legacy tag+narrow heuristic (kept for comparison) */ int native_w = g_wide_w - 2 * g_wide_off; if (native_w <= 0) return 0; - if (psx_ws_prim_is_tagged()) return 0; + if (ctx_prim_tagged()) return 0; int base = g_wide_cur_base, lo = xs[0], hi = xs[0]; for (int i = 1; i < n; i++) { if (xs[i] < lo) lo = xs[i]; if (xs[i] > hi) hi = xs[i]; } if (lo < base - g_ws_bd_phase_thresh) return 0; /* into left margin -> GTE-wide */ @@ -2695,7 +2767,8 @@ static int bd_prim_gate(const int *xs, int n, int textured) { * conservative strict-inside test. 4:3 never runs any of this (g_wide_cur == 0). * Toggle via gl_wide_fast for A/B; default ON. */ static int s_wide_fast = 1; -void gl_renderer_set_wide_fast(int on) { s_wide_fast = on ? 1 : 0; } +void gl_renderer_set_wide_fast(int on) { + GL_RT_SYNC("set_wide_fast"); s_wide_fast = on ? 1 : 0; } int gl_renderer_get_wide_fast(void) { return s_wide_fast; } static int wide_fast_center_valid(void) { /* An explicitly stretched sky differs inside the canonical viewport too. @@ -2703,8 +2776,7 @@ static int wide_fast_center_valid(void) { * draws. Tags are installed before DMA, so no earlier center draws skip. */ // The native split seam occupies canonical edge columns that the expanded // camera renders as world. Copying those columns reinstates the divider. - return s_wide_fast && gpu_ws_netplay_local_viewport_width() <= 0 && - !gpu_ws_background_requires_full_composite(); + return s_wide_fast && ctx_vp_width() <= 0 && !ctx_bg_full(); } /* The one predicate for "the wide surface's centre columns [g_wide_off, * g_wide_off+native_w) are the canonical framebuffer, unshifted". A camera view @@ -2887,10 +2959,12 @@ static uint64_t s_batch_total = 0, s_batch_reason[7]; * window therefore stays a batch key while the check is on, and this mode * never makes a batch longer than the default mode would there. */ static int s_twin_batching = 0; -void gl_renderer_set_texture_window_batching(int on) { s_twin_batching = on ? 1 : 0; } +void gl_renderer_set_texture_window_batching(int on) { + GL_RT_SYNC("set_texture_window_batching"); s_twin_batching = on ? 1 : 0; } int gl_renderer_get_texture_window_batching(void) { return s_twin_batching; } void gl_renderer_batch_diag(uint64_t out[8]) { + GL_RT_SYNC("batch_diag"); out[0] = s_batch_total; for (int i = 0; i < 7; i++) out[i + 1] = s_batch_reason[i]; } @@ -4169,10 +4243,23 @@ static void native_draw_end(int previous) { sw_set_faithful_authority(previous); static inline void precise_consumed(void) { s_pc_valid = 0; s_pq_valid = 0; s_projected_uv_valid = 0; } int gl_renderer_projective_supported(void) { + if (s_rth_on && !rt_on_render_thread() && !rt_held()) + return s_raster_ok && s_rthm_cur && !cpu_vram_authoritative(); return s_raster_ok && g_wide_cur && !cpu_vram_authoritative(); } void gl_renderer_draw_projected_triangle(const PSXProjectedVertex vertices[3], uint16_t texpage, uint16_t cx, uint16_t cy, int raw, int semi, int perspective) { + if (rth_record_mode()) { + uint8_t *p = (uint8_t *)rt_cmd_begin(RTH_PROJ_TRI, rth_prim_flags(), + 3u * sizeof(PSXProjectedVertex) + 24u); + if (p) { + const int32_t a[6] = { texpage, cx, cy, raw, semi, perspective }; + memcpy(p, vertices, 3u * sizeof(PSXProjectedVertex)); + memcpy(p + 3u * sizeof(PSXProjectedVertex), a, sizeof a); + rt_cmd_commit(); + return; + } + } PSXProjectedVertex polygon[12]; const double left=fmin(s_area_x1, -wide_dx())-1.0; const double right=fmax(s_area_x2+1, g_wide_w-wide_dx())+1.0; @@ -4343,6 +4430,7 @@ static int glb_render_display_hires(uint32_t *o,int p,int dx,int dy,int dw,int * the GL surface cannot serve, keeps the mirror resolve. The backend's * render_display_hires (present fallback, screenshot_hires) is unchanged. */ int gl_renderer_capture_display_hires(uint32_t *o, int p, int dx, int dy, int dw, int dh) { + GL_RT_SYNC("capture_display_hires"); /* A pitch shorter than one scaled row would make either path below write * rows over each other and past a pitch*height buffer. */ if (!o || dw <= 0 || dh <= 0 || (int64_t)p < (int64_t)dw * s_out_scale * 4) @@ -4363,7 +4451,7 @@ int gl_renderer_capture_display_hires(uint32_t *o, int p, int dx, int dy, int dw * On leave: clear the skipped FB union in the FBO — do NOT restage CPU RGB888 * as 1555 (that painted MotK title rainbow/static). */ static int depth24_is_fb_transfer(int x, int y, int w, int h) { - if (!gpu_display_is_depth24() || w <= 0 || h <= 0) return 0; + if (!ctx_depth24() || w <= 0 || h <= 0) return 0; GpuDisplayInfo di; gpu_get_display_info(&di); int fb_w = (int)((di.width * 3u + 1u) / 2u); /* RGB W → halfwords */ @@ -4395,7 +4483,7 @@ static int depth24_is_fb_transfer(int x, int y, int w, int h) { static void depth24_mark_scanout_band(void) { GpuDisplayInfo di; int fb_w, fb_h, x0, y0, x1, y1; - if (!gpu_display_is_depth24()) return; + if (!ctx_depth24()) return; gpu_get_display_info(&di); fb_w = (int)((di.width * 3u + 1u) / 2u); fb_h = (int)di.height; @@ -4449,7 +4537,7 @@ static void depth24_clear_skipped_fb(void) { static void depth24_upload_policy(void) { if (!s_raster_ok) return; - int d24 = gpu_display_is_depth24(); + int d24 = ctx_depth24(); if (d24 && !s_depth24_skip_up) { /* Entering 24-bit: from here the frame is presented from the CPU * mirror, and MDEC only writes the movie's own rows. A letterboxed @@ -4537,6 +4625,7 @@ static void upload_present_tex(const uint32_t *pixels, int w, int h, int linear) static int s_fmv_filter_cfg = 0; /* VIDEO_FMV_FILTER_NEAREST */ void gl_renderer_set_fmv_filter(int cfg_value) { + GL_RT_SYNC("set_fmv_filter"); if (cfg_value >= 0 && cfg_value <= 3) s_fmv_filter_cfg = cfg_value; } @@ -4613,6 +4702,7 @@ static void present_set_scanline(GLint uOn, GLint uStr, GLint uLines, static int s_aspect_num = 4, s_aspect_den = 3; void gl_renderer_set_display_aspect(int num, int den) { + GL_RT_SYNC("set_display_aspect"); if (num <= 0 || den <= 0) { num = 4; den = 3; } s_aspect_num = num; s_aspect_den = den; } @@ -4622,6 +4712,7 @@ void gl_renderer_set_display_aspect(int num, int den) { * actual darkening is applied per-draw in the present/interpolation shaders and * fades in with output scale — see PSX_SCANLINE_FUNC. */ void gl_renderer_set_scanlines(int on, float strength) { + GL_RT_SYNC("set_scanlines"); s_scanline_on = on ? 1 : 0; if (strength < 0.f) strength = 0.f; if (strength > 1.f) strength = 1.f; @@ -4634,6 +4725,7 @@ int gl_renderer_get_scanlines(float *strength) { } void gl_renderer_set_post_gamma(float gamma) { + GL_RT_SYNC("set_post_gamma"); if (!isfinite(gamma)) gamma = 1.0f; if (gamma < 0.5f) gamma = 0.5f; @@ -5025,6 +5117,7 @@ static int init_gpu_raster(void) { int gl_renderer_texture_banks_supported(void) { return s_raster_ok && !s_cpu_auth_dual && !s_hd_native_authority; } int gl_renderer_fit_wide_aspect(int disp_w, int *num, int *den) { + GL_RT_SYNC("fit_wide_aspect"); if (!s_raster_ok || s_gl_max_dim <= 0) return 0; /* Against the allocation (the dynamic-resolution ceiling): the aspect * must not change when the level steps. */ @@ -5033,6 +5126,7 @@ int gl_renderer_fit_wide_aspect(int disp_w, int *num, int *den) { } int gl_renderer_scale_info(GlScaleInfo *out) { + GL_RT_SYNC("scale_info"); if (!out) return 0; memset(out, 0, sizeof(*out)); out->requested = s_req_scale; @@ -5131,11 +5225,13 @@ static int gl_read_display_argb(int x, int y, int w, int h, uint32_t *out, int gl_renderer_read_display_hires(int x, int y, int w, int h, uint32_t *out, int cap_px, int *ow, int *oh) { + GL_RT_SYNC("read_display_hires"); return gl_read_display_argb(x, y, w, h, out, w * s_out_scale * 4, cap_px, ow, oh); } int gl_renderer_select_texture_bank(uint16_t id) { + GL_RT_SYNC("select_texture_bank"); uint32_t width, height; const uint16_t* pixels; GLint alignment, row_length; @@ -5170,6 +5266,7 @@ int gl_renderer_select_texture_bank(uint16_t id) { } int gl_renderer_select_texture_bank_live_clut(uint16_t id) { + GL_RT_SYNC("select_texture_bank_live_clut"); if (!gl_renderer_select_texture_bank(id)) return 0; s_selected_bank_live_clut = id != 0; return 1; @@ -5265,6 +5362,7 @@ int gl_renderer_init_context(SDL_Window *win) { * Safe to call before or after context creation; applies live when a context * exists. Adaptive falls back to vsync if unsupported. */ void gl_renderer_set_swap_interval(int interval) { + GL_RT_SYNC("set_swap_interval"); s_swap_interval = interval; if (s_ctx) { if (SDL_GL_SetSwapInterval(interval) != 0 && interval < 0) { @@ -5274,6 +5372,7 @@ void gl_renderer_set_swap_interval(int interval) { } } int gl_renderer_get_swap_interval(void) { + GL_RT_SYNC("get_swap_interval"); if(!s_ctx)return -2; #if defined(PSX_SDL3) int interval=0; @@ -5286,6 +5385,7 @@ int gl_renderer_get_swap_interval(void) { static void pass_resources_release(void); void gl_renderer_shutdown(void) { + gl_renderer_render_thread_stop(); /* the context comes back to this thread */ s_native_surface_enabled=s_native_surface_pending=0; gl_renderer_clear_hd_texture_cache(); pass_resources_release(); @@ -5332,6 +5432,7 @@ void gl_renderer_shutdown(void) { * widescreen presents them pillarboxed instead of distorted. */ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linear, int force_4_3, int content_w) { + GL_RT_SYNC("present"); if (!s_ctx) return; interp_reset_history(); int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); @@ -5424,6 +5525,7 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea } void gl_renderer_present_blank(void) { + GL_RT_SYNC("present_blank"); if (!s_ctx) return; interp_reset_history(); int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); @@ -5442,10 +5544,12 @@ void gl_renderer_present_blank(void) { /* Sync the authoritative FBO down into CPU VRAM (no-op when current). * Screenshots / debug server. Not for 24-bit FMV scanout (see flush). */ void gl_renderer_sync_cpu(void) { + GL_RT_SYNC("sync_cpu"); ensure_cpu(); } void gl_renderer_invalidate_present(void) { + GL_RT_SYNC("invalidate_present"); stereo_invalidate(); for (int i = 0; i < PRES_ROWS; i++) s_present_dirty[i] = ~0ull; s_last_present_path = -1; @@ -5455,6 +5559,7 @@ void gl_renderer_invalidate_present(void) { } void gl_renderer_restage_vram_after_savestate(void) { + GL_RT_SYNC("restage_vram_after_savestate"); gpu_hd_textures_reset_tracking(); if (!s_raster_ok || !s_vram) return; /* Belt-and-suspenders after boot_state VRAM apply: force CPU mirror → FBO @@ -5465,7 +5570,7 @@ void gl_renderer_restage_vram_after_savestate(void) { s_depth24_skip_up = 0; up_add_transfer(0, 0, VRAM_W, VRAM_H); flush_cpu_upload(); - if (gpu_display_is_depth24()) { + if (ctx_depth24()) { s_depth24_skip_up = 1; depth24_mark_scanout_band(); } @@ -5477,6 +5582,7 @@ void gl_renderer_restage_vram_after_savestate(void) { } void gl_renderer_set_cpu_auth_dual(int on) { + GL_RT_SYNC("set_cpu_auth_dual"); s_cpu_auth_dual = on ? 1 : 0; if (s_cpu_auth_dual) { s_gpu_dirty = 0; @@ -5506,6 +5612,7 @@ int gl_renderer_cpu_auth_dual(void) { } void gl_renderer_present_probe_reset(void) { + GL_RT_SYNC("present_probe_reset"); s_probe_skip = 0; s_probe_swap = 0; s_probe_dirty_marks = 0; @@ -5514,6 +5621,7 @@ void gl_renderer_present_probe_reset(void) { void gl_renderer_present_probe_take(uint64_t *skip_delta, uint64_t *swap_delta, uint64_t *dirty_mark_delta, int *force_remaining) { + GL_RT_SYNC("present_probe_take"); if (skip_delta) { *skip_delta = s_probe_skip; s_probe_skip = 0; } if (swap_delta) { *swap_delta = s_probe_swap; s_probe_swap = 0; } if (dirty_mark_delta) { @@ -5524,11 +5632,13 @@ void gl_renderer_present_probe_take(uint64_t *skip_delta, uint64_t *swap_delta, } int gl_renderer_present_rect_dirty(int disp_x, int disp_y, int w, int h) { + GL_RT_SYNC("present_rect_dirty"); if (!s_raster_ok || w <= 0 || h <= 0) return 0; return present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1); } void gl_renderer_flush_cpu_uploads(void) { + GL_RT_SYNC("flush_cpu_uploads"); if (!s_raster_ok) return; flush_flat_batch(); flush_tex_batch(); @@ -5540,6 +5650,7 @@ void gl_renderer_flush_cpu_uploads(void) { * VRAM — lets a probe diff FBO truth against CPU truth. Returns 0 when the * GL pipeline is inactive (software backend). */ int gl_renderer_fbo_peek(int x, int y, int w, int h, uint16_t *out) { + GL_RT_SYNC("fbo_peek"); if (!s_raster_ok || !s_ctx) return 0; if (x < 0 || y < 0 || w < 1 || h < 1 || x + w > VRAM_W || y + h > VRAM_H) return 0; @@ -5555,6 +5666,21 @@ int gl_renderer_fbo_peek(int x, int y, int w, int h, uint16_t *out) { return 1; } +/* fbo_peek at this point of the command stream without a sync point: with + * the render thread recording, the readback is queued and lands in out when + * the render thread reaches it (a reader of out must sync first). Otherwise + * it is gl_renderer_fbo_peek. Used by the per-frame display ring. */ +int gl_renderer_fbo_peek_deferred(int x, int y, int w, int h, uint16_t *out) { + if (rth_record_mode()) { + if (!s_raster_ok || !s_ctx || x < 0 || y < 0 || w < 1 || h < 1 || + x + w > VRAM_W || y + h > VRAM_H) return 0; + uint64_t ptr = (uint64_t)(uintptr_t)out; + RTH_REC(RTH_PEEK, 0, x, y, w, h, (int32_t)(ptr & 0xFFFFFFFFu), (int32_t)(ptr >> 32)); + return 1; + } + return gl_renderer_fbo_peek(x, y, w, h, out); +} + /* Diagnostic (debug server "gl_vram_diff"): full-VRAM comparison of the * GPU-side truth (FBO via pack) against the CPU array, WITHOUT writing * either. Reports mismatch count + bounding box + a few sample coords. @@ -5562,6 +5688,7 @@ int gl_renderer_fbo_peek(int x, int y, int w, int h, uint16_t *out) { * at upload-only scenes the two must match exactly. */ int gl_renderer_vram_diff(uint32_t *count, int bbox[4], int samples[8][2], uint16_t samples_px[8][2]) { + GL_RT_SYNC("vram_diff"); if (!s_raster_ok || !s_ctx) return 0; uint16_t *tmp = (uint16_t *)malloc((size_t)VRAM_W * VRAM_H * 2); if (!tmp) return 0; @@ -5599,6 +5726,7 @@ int gl_renderer_vram_diff(uint32_t *count, int bbox[4], /* Diagnostic state for the debug server: coherency flags + dirty rects. */ void gl_renderer_diag(int *gpu_dirty, int pending[5], int pack[5]) { + GL_RT_SYNC("diag"); if (gpu_dirty) *gpu_dirty = s_gpu_dirty; if (pending) { /* [0] = pending rect count; [1..4] = union bbox (diagnostic only — @@ -6056,7 +6184,8 @@ static void gl_perf_present_enter(void) { g_bdg_applied = s_bdg_applied; g_bdg_prims = s_bdg_prims; g_bdg_clearx = s_bdg_clearx; g_bdg_cur = (g_wide_cur != 0); g_bdg_base = g_wide_cur_base; g_bdg_w = g_wide_w; g_bdg_off = g_wide_off; s_bdg_applied = 0; s_bdg_prims = 0; s_bdg_clearx = -999999; - { extern void psx_ws_dbg_gate_frame_snapshot(void); psx_ws_dbg_gate_frame_snapshot(); } + /* Replay: taken on the emulation thread when the present was recorded. */ + { extern void psx_ws_dbg_gate_frame_snapshot(void); if (!rth_replaying()) psx_ws_dbg_gate_frame_snapshot(); } if (!s_pf_on) return; uint64_t now = SDL_GetPerformanceCounter(); s_pf_enter = now; @@ -6130,11 +6259,13 @@ static void gl_perf_present_exit(int wide) { /* Cumulative textured fraction of scene prims (decides flat vs textured batching * priority). out_tex_frac = textured/total since boot; returns total prim count. */ uint64_t gl_renderer_perf_prim_split(double *out_tex_frac) { + GL_RT_SYNC("perf_prim_split"); if (out_tex_frac) *out_tex_frac = s_scene_prims ? (double)s_scene_prims_tex / (double)s_scene_prims : 0.0; return s_scene_prims; } int gl_renderer_perf_aggregate(int wide_filter, double out[18]) { + GL_RT_SYNC("perf_aggregate"); for (int i = 0; i < 18; i++) out[i] = 0.0; if (!s_pf_on) return 0; int navail = (int)(s_pf_ring_seq < (uint64_t)GLPERF_RING ? s_pf_ring_seq : GLPERF_RING); @@ -6168,7 +6299,8 @@ int gl_renderer_perf_aggregate(int wide_filter, double out[18]) { } /* Native-wide mirror ablation (perf attribution): see s_ws_ablate. */ -void gl_renderer_set_ws_ablate(int mode) { s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } +void gl_renderer_set_ws_ablate(int mode) { + GL_RT_SYNC("set_ws_ablate"); s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } int gl_renderer_get_ws_ablate(void) { return s_ws_ablate; } static void interp_reset_history_unlocked(void) { @@ -6190,6 +6322,7 @@ static void interp_reset_history(void) { void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, double source_hz, int blend_mode) { + GL_RT_SYNC("set_interpolation"); double effective_hz = target_hz > 0.0 ? target_hz : host_hz; if (effective_hz < source_hz) effective_hz = source_hz; int active = (enabled && source_hz >= 1.0 && source_hz <= 1000.0 && @@ -6219,17 +6352,20 @@ void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz } void gl_renderer_set_interpolation_blend(int blend_mode) { + GL_RT_SYNC("set_interpolation_blend"); s_interp_blend_mode = blend_mode == 1 ? 1 : 0; s_interp_hold = blend_mode == 2 ? 1 : 0; } void gl_renderer_set_interpolation_suspended(int suspended) { + if (rth_record_mode()) { RTH_REC(RTH_INTERP_SUSPENDED, 0, suspended ? 1 : 0); return; } suspended = suspended ? 1 : 0; if (suspended != s_interp_suspended) interp_reset_history_unlocked(); s_interp_suspended = suspended; } void gl_renderer_set_interpolation_source(int source) { + GL_RT_SYNC("set_interpolation_source"); source = source == 1 ? 1 : 0; if (source != s_interp_source) interp_reset_history_unlocked(); s_interp_source = source; @@ -6241,6 +6377,7 @@ 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) { + GL_RT_SYNC("interpolation_source_diag"); if (source) *source = s_interp_source; if (flip_period) *flip_period = s_interp_flip.period; if (captures) *captures = s_interp_captures; @@ -6266,6 +6403,7 @@ void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, double *host_hz, double *target_hz, uint64_t *swaps) { + GL_RT_SYNC("interpolation_diag"); if (enabled) *enabled = s_interp_enabled; if (suspended) *suspended = s_interp_suspended; if (history_frames) *history_frames = s_interp_valid; @@ -6607,6 +6745,7 @@ static int s_pass_force_refuse = -1; /* -1: read PSX_RENDER_PASS_REFUSE */ static GLRenderPassBeginDiag s_pass_begin_diag; void gl_renderer_pass_begin_diag(GLRenderPassBeginDiag *out) { + GL_RT_SYNC("pass_begin_diag"); if (out) *out = s_pass_begin_diag; } @@ -6626,6 +6765,7 @@ static uint32_t pass_gl_errors(void) { } void gl_renderer_pass_force_refuse(int on) { + GL_RT_SYNC("pass_force_refuse"); s_pass_force_refuse = on ? 1 : 0; } @@ -6640,6 +6780,7 @@ static void pass_refusal_init(void) { } uint32_t gl_renderer_pass_unavailable(void) { + GL_RT_SYNC("pass_unavailable"); pass_refusal_init(); if (!s_ctx || !s_raster_ok || !s_interp_enabled || s_interp_suspended || s_interp_source != 1 || !(s_interp_source_hz > 0.0)) @@ -6660,6 +6801,7 @@ uint32_t gl_renderer_pass_unavailable(void) { } int gl_renderer_pass_ready(void) { + GL_RT_SYNC("pass_ready"); return gl_renderer_pass_unavailable() == PSX_MOD_RENDER_PASS_READY; } @@ -6676,6 +6818,7 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, uint32_t shown_after_vblanks, uint32_t *alpha_q16, uint32_t max, uint32_t *wanted) { + GL_RT_SYNC("pass_plan"); RenderPassPlanInput in; double freq, sp, spare; uint32_t cap; @@ -6760,6 +6903,7 @@ void gl_renderer_pass_plan_diag(GLRenderPassPlanDiag *out) { } void gl_renderer_pass_note_cost(uint64_t ticks) { + GL_RT_SYNC("pass_note_cost"); if (s_pass_cost_w != s_interp_w || s_pass_cost_h != s_interp_h) { memset(&s_pass_cost, 0, sizeof s_pass_cost); s_pass_cost_w = s_interp_w; @@ -6773,8 +6917,10 @@ void gl_renderer_pass_note_cost(uint64_t ticks) { if (s_dyn_on) s_dyn_ledger.pass_ticks += ticks; } -uint32_t gl_renderer_pass_leaks(void) { return s_pass_leaks; } -int gl_renderer_pass_verify_vram(void) { return s_pv_ok; } +uint32_t gl_renderer_pass_leaks(void) { + GL_RT_SYNC("pass_leaks"); return s_pass_leaks; } +int gl_renderer_pass_verify_vram(void) { + GL_RT_SYNC("pass_verify_vram"); return s_pv_ok; } static GLuint pass_wide_fbo_for(int base_x) { @@ -7212,9 +7358,11 @@ uint32_t gl_renderer_local_view_unavailable(void) { return PSX_MOD_RENDER_PASS_READY; } int gl_renderer_local_view_begin(int x, int y, int w, int h) { + GL_RT_SYNC("local_view_begin"); return transaction_begin(x, y, w, h, 0, 0, 0, 2); } int gl_renderer_local_view_end(int keep) { + GL_RT_SYNC("local_view_end"); if (!s_pass_active || !s_pass_local) return 0; flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); transaction_restore_ex(keep ? 1 : 0); @@ -7224,12 +7372,15 @@ int gl_renderer_local_view_end(int keep) { int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, uint32_t period, int reuse) { + GL_RT_SYNC("pass_begin"); return transaction_begin(x, y, w, h, open_gen, period, reuse, 0); } int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse) { + GL_RT_SYNC("stereo_begin"); return transaction_begin(x, y, w, h, 0, 0, reuse, 1); } void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { + GL_RT_SYNC("pass_end"); int gi = 1 - s_pgen_cur; PassGen *g = &s_pgen[gi]; if (!s_pass_active) return; @@ -7252,14 +7403,16 @@ void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { } uint32_t gl_renderer_stereo_unavailable(void) { + GL_RT_SYNC("stereo_unavailable"); pass_refusal_init(); - if (!s_ctx || !s_raster_ok || !s_hr_fbo || gpu_display_is_depth24()) + if (!s_ctx || !s_raster_ok || !s_hr_fbo || ctx_depth24()) return PSX_MOD_RENDER_PASS_NO_PRESENTER; if (s_cpu_auth_dual || s_hd_native_authority || s_hiw || g_wide_w > 0 || s_pass_force_refuse > 0) return PSX_MOD_RENDER_PASS_BACKEND; return PSX_MOD_RENDER_PASS_READY; } void gl_renderer_stereo_stage_reset(void) { + GL_RT_SYNC("stereo_stage_reset"); s_stereo_pair[1 - s_stereo_current].mask = 0; } static void stereo_invalidate(void) { @@ -7267,11 +7420,13 @@ static void stereo_invalidate(void) { s_stereo_pair[0].mask = s_stereo_pair[1].mask = 0; } void gl_renderer_stereo_reset(void) { + GL_RT_SYNC("stereo_reset"); stereo_invalidate(); s_stereo_mode = 0; s_stereo_dump_left = 0; s_stereo_capture_count = 0; s_stereo_presents = 0; } int gl_renderer_stereo_set_presentation(uint32_t mode) { + GL_RT_SYNC("stereo_set_presentation"); if (mode > 1u) return 0; s_stereo_mode = (int)mode; return 1; @@ -7285,6 +7440,7 @@ static void stereo_resources_release(void) { stereo_invalidate(); } int gl_renderer_stereo_end(uint32_t eye, int keep) { + GL_RT_SYNC("stereo_end"); StereoPair *p = &s_stereo_pair[1 - s_stereo_current]; PassGen g; int ok = 0, tw = s_pass_w * s_hr_scale, th = s_pass_h * s_hr_scale; @@ -7306,6 +7462,7 @@ int gl_renderer_stereo_end(uint32_t eye, int keep) { return keep ? ok : 1; } void gl_renderer_stereo_diag(GLRenderStereoDiag *out) { + GL_RT_SYNC("stereo_diag"); StereoPair *p = &s_stereo_pair[s_stereo_current]; if (!out) return; memset(out, 0, sizeof *out); @@ -7316,12 +7473,14 @@ void gl_renderer_stereo_diag(GLRenderStereoDiag *out) { out->presents = s_stereo_presents; } void gl_renderer_stereo_dump_arm(const char *dir, int pairs) { + GL_RT_SYNC("stereo_dump_arm"); if (!dir || !*dir || pairs <= 0) { s_stereo_dump_left = 0; return; } snprintf(s_stereo_dump_dir, sizeof s_stereo_dump_dir, "%s", dir); s_stereo_dump_left = pairs > STEREO_CAPTURE_MAX ? STEREO_CAPTURE_MAX : pairs; s_stereo_capture_count = 0; } uint32_t gl_renderer_stereo_capture_records(GLRenderStereoCapture *out, uint32_t capacity) { + GL_RT_SYNC("stereo_capture_records"); uint32_t count = s_stereo_capture_count; if (!out) return 0; if (count > capacity) count = capacity; @@ -7361,6 +7520,7 @@ static void stereo_dump(const StereoPair *p) { free(rgb); free(sbs); s_stereo_dump_left--; } int gl_renderer_stereo_publish(uint64_t id, uint64_t cycle, const int32_t view[2][3]) { + GL_RT_SYNC("stereo_publish"); StereoPair *p = &s_stereo_pair[1 - s_stereo_current]; if (p->mask != 3u || p->tw[0] != p->tw[1] || p->th[0] != p->th[1]) return 0; p->id = id; p->cycle = cycle; @@ -7372,16 +7532,20 @@ int gl_renderer_stereo_publish(uint64_t id, uint64_t cycle, const int32_t view[2 static uint64_t s_xr_begin_pair; int psx_mod_openxr_enable(int enabled) { + GL_RT_SYNC("openxr_enable"); PSXOpenXRStats s; psx_openxr_stats(&s); if (s_pass_active || s.frame_open) return 0; return psx_openxr_enable(enabled); } -void psx_mod_openxr_recenter(void) { psx_openxr_recenter(); } +void psx_mod_openxr_recenter(void) { + GL_RT_SYNC("openxr_recenter"); psx_openxr_recenter(); } int psx_mod_openxr_quad(double distance,double width,double height) { + GL_RT_SYNC("openxr_quad"); if(s_pass_active)return 0; return psx_openxr_quad(distance,width,height); } int psx_mod_openxr_native_surface(double distance,double width,double units) { + GL_RT_SYNC("openxr_native_surface"); PSXOpenXRStats stats;psx_openxr_stats(&stats); if(s_pass_active || stats.frame_open || !stats.compiled || !isfinite(distance) || !isfinite(width) || !isfinite(units))return 0; @@ -7391,19 +7555,23 @@ int psx_mod_openxr_native_surface(double distance,double width,double units) { return 1; } int psx_mod_openxr_begin(uint32_t width, uint32_t height, double units) { + GL_RT_SYNC("openxr_begin"); if (s_native_surface_enabled || s_pass_active || gl_renderer_stereo_unavailable() != PSX_MOD_RENDER_PASS_READY || !width || !height || width > VRAM_W || height > VRAM_H) return 0; s_xr_begin_pair = s_stereo_valid ? s_stereo_pair[s_stereo_current].id : 0; return psx_openxr_begin((int)width, (int)height, units); } int psx_mod_openxr_view(uint32_t eye, PSXModRenderView *view) { + GL_RT_SYNC("openxr_view"); return psx_openxr_view(eye, view); } int psx_mod_openxr_input(PSXModOpenXRInput *input) { + GL_RT_SYNC("openxr_input"); if (s_pass_active) return 0; return psx_openxr_input(input); } int psx_mod_openxr_hands(PSXModOpenXRHands *hands) { + GL_RT_SYNC("openxr_hands"); return psx_openxr_hands(hands); /* Snapshot only, also safe during replay. */ } /* The target stores display-encoded RGB for an sRGB swapchain, or explicitly @@ -7476,6 +7644,7 @@ static int openxr_copy_eye(uint32_t eye, uint32_t texture, int w, int h) { p_glDeleteFramebuffers(1, &target);return ok; } int psx_mod_openxr_end(int rendered) { + GL_RT_SYNC("openxr_end"); int fresh = rendered && s_stereo_valid && !s_pass_active && s_stereo_pair[s_stereo_current].id != s_xr_begin_pair; if (fresh) psx_openxr_pair_metadata(s_stereo_pair[s_stereo_current].id, @@ -7614,6 +7783,7 @@ static int pass_gen_present(uint64_t deadline) { } void gl_renderer_pass_service_presents(void) { + GL_RT_SYNC("pass_service_presents"); uint64_t deadline; double sp; if (!gl_renderer_pass_ready() || s_pass_active) return; @@ -7630,6 +7800,7 @@ void gl_renderer_pass_service_presents(void) { } void gl_renderer_pass_diag(uint64_t out[10]) { + GL_RT_SYNC("pass_diag"); out[0] = s_pgen_promotions; out[1] = s_pgen_presents; out[2] = s_pgen_blends; @@ -7643,10 +7814,13 @@ void gl_renderer_pass_diag(uint64_t out[10]) { out[9] = s_pass_cost_rewarms; } -uint64_t gl_renderer_pass_journaled(void) { return s_pj_total; } -uint64_t gl_renderer_pass_backups_reused(void) { return s_pb_reused; } +uint64_t gl_renderer_pass_journaled(void) { + GL_RT_SYNC("pass_journaled"); return s_pj_total; } +uint64_t gl_renderer_pass_backups_reused(void) { + GL_RT_SYNC("pass_backups_reused"); return s_pb_reused; } uint32_t gl_renderer_pass_image_textures(uint64_t *bytes) { + GL_RT_SYNC("pass_image_textures"); uint32_t n = s_pgen_alloc_n[0] + s_pgen_alloc_n[1]; if (bytes) { *bytes = 0; @@ -7668,7 +7842,8 @@ static uint64_t s_present_ticks_accum_fwd(uint64_t add) { return s_present_ticks_accum; } -void gl_renderer_pass_set_flip_shown(int shown) { s_pass_flip_shown = shown ? 1 : 0; } +void gl_renderer_pass_set_flip_shown(int shown) { + GL_RT_SYNC("pass_set_flip_shown"); s_pass_flip_shown = shown ? 1 : 0; } /* FLIP source saw a new frame: it is the flip a pending generation was built * for (render_pass_gen_flip_matches) or a frame without passes. */ @@ -7694,6 +7869,7 @@ static char s_pdump_dir[400]; static int s_pdump_left = 0; static uint64_t s_pdump_gen = 0; void gl_renderer_pass_dump_arm(const char *dir, int generations) { + GL_RT_SYNC("pass_dump_arm"); if (!dir || !*dir || generations <= 0) { s_pdump_left = 0; return; } snprintf(s_pdump_dir, sizeof s_pdump_dir, "%s", dir); s_pdump_left = generations; @@ -7893,6 +8069,35 @@ static void present_image_ring_capture_gl(void) { } #endif +/* Host overlays (OSD text, volume bar, rewind strip, savestate menu) are + * rasterized by the emulation thread. A recorded present carries a copy taken + * at record time; replay draws that copy (ov_* below), never the live ones. */ +static struct { + int valid, needs_present; + const uint32_t *px[4]; + int w[4], h[4]; + float slide; +} s_rth_ov; +static int ov_image(int i, const uint32_t **px, int *w, int *h) { + if (rth_replaying() && s_rth_ov.valid) { + *px = s_rth_ov.px[i]; *w = s_rth_ov.w[i]; *h = s_rth_ov.h[i]; + return s_rth_ov.px[i] != NULL; + } + switch (i) { + case 0: return host_osd_image(px, w, h); + case 1: return host_osd_volume_image(px, w, h); + case 2: return psx_rewind_overlay_image(px, w, h); + default: return psx_savestate_menu_overlay_image(px, w, h); + } +} +static float ov_rewind_slide(void) { + return (rth_replaying() && s_rth_ov.valid) ? s_rth_ov.slide : psx_rewind_slide(); +} +static int ov_needs_present(void) { + return (rth_replaying() && s_rth_ov.valid) ? s_rth_ov.needs_present + : host_osd_needs_present(); +} + static void gl_swap_with_osd(void) { openxr_present_native(); /* Copy guest content before host-only overlays. */ s_native_surface_pending=0; /* Hold-last/resim cannot reuse a native source. */ @@ -7911,17 +8116,17 @@ static void gl_swap_with_osd(void) { if (ui < 1) ui = 1; if (ui > 8) ui = 8; margin = 8 * ui; - if (host_osd_image(&px, &ow, &oh) && px) + if (ov_image(0, &px, &ow, &oh) && px) gl_draw_osd_image(px, ow, oh, ow * ui, oh * ui, margin, margin, ww, wh); - if (host_osd_volume_image(&px, &ow, &oh) && px) { + if (ov_image(1, &px, &ow, &oh) && px) { const int dw = ow * ui, dh = oh * ui; int vx = (ww > dw + margin) ? (ww - dw - margin) : margin; int vy = (wh > dh) ? ((wh - dh) / 2) : margin; gl_draw_osd_image(px, ow, oh, dw, dh, vx, vy, ww, wh); } - if (psx_rewind_overlay_image(&px, &ow, &oh) && px) { - float slide = psx_rewind_slide(); + if (ov_image(2, &px, &ow, &oh) && px) { + float slide = ov_rewind_slide(); int dw = ww; int dh = (wh * oh) / 480; int vy; @@ -7929,11 +8134,11 @@ static void gl_swap_with_osd(void) { vy = wh - (int)((float)dh * slide + 0.5f); gl_draw_osd_image(px, ow, oh, dw, dh, 0, vy, ww, wh); } - if (psx_savestate_menu_overlay_image(&px, &ow, &oh) && px) + if (ov_image(3, &px, &ow, &oh) && px) gl_draw_osd_image(px, ow, oh, ww, wh, 0, 0, ww, wh); } } - host_osd_present_done(); + if (!rth_replaying()) host_osd_present_done(); /* replay: done at record */ /* present_shot (GL backend): the default framebuffer now holds the composed * frame — display quad fitted to the window, plus OSD — so this is the only * capture that carries the presented aspect. Buffer-level captures resolve @@ -8073,6 +8278,7 @@ static void present_target_quad(GLuint tex, float tex_w, float tex_h, static GLuint s_bezel_tex = 0; int gl_renderer_set_bezel(const void *rgba, int w, int h) { + GL_RT_SYNC("set_bezel"); if (s_bezel_tex) { glDeleteTextures(1, &s_bezel_tex); s_bezel_tex = 0; } if (!rgba || w <= 0 || h <= 0) return 1; if (!s_ctx || !s_raster_ok) return 0; @@ -8115,6 +8321,7 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { } int gl_renderer_present_hold_last(void) { + GL_RT_SYNC("present_hold_last"); int ww = 0, wh = 0; int lx, ly, lw, lh; if (!s_ctx || !s_win || s_hold_kind == HOLD_NONE || !s_hold_tex) @@ -8184,6 +8391,11 @@ static float present_alloc_extent(int units, int scale) { static void present_vram_impl(int disp_x, int disp_y, int w, int h, int linear, int force_4_3) { + if (rth_record_mode()) { + const int32_t a[6] = { disp_x, disp_y, w, h, linear, force_4_3 }; + if (rth_record_present(RTH_PRESENT_VRAM, 6, a)) return; + gl_rth_acquire("present_oversize"); + } if (!s_ctx || !s_raster_ok) return; flush_flat_batch(); flush_tex_batch(); @@ -8194,7 +8406,7 @@ static void present_vram_impl(int disp_x, int disp_y, int w, int h, int linear, s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == w && s_last_dh == h && !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1) && - !host_osd_needs_present() && + !ov_needs_present() && !psx_present_vsync_owns_cadence() && !gl_renderer_interpolation_owns_cadence()) { s_probe_skip++; @@ -8356,6 +8568,13 @@ static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h * bottom-origin → window top). Returns 0 if there's no wide surface for base_x * (caller falls back). disp_x is the displayed buffer base (the wide-surface key). */ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) { + if (rth_record_mode()) { + /* The caller falls back to the CPU path on 0: answer from the mirror. */ + if (!s_ctx || !s_raster_ok || !rth_mirror_wide_present_ok(disp_x)) return 0; + const int32_t a[4] = { disp_x, disp_y, disp_h, linear }; + if (rth_record_present(RTH_PRESENT_WIDE, 4, a)) return 1; + gl_rth_acquire("present_oversize"); + } if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; GLuint fbo = 0, tex = 0; for (int i = 0; i < WIDE_MAX_SURF; i++) @@ -8383,7 +8602,7 @@ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1) && - !host_osd_needs_present() && + !ov_needs_present() && !psx_present_vsync_owns_cadence() && !gl_renderer_interpolation_owns_cadence()) { s_probe_skip++; @@ -8903,4 +9122,598 @@ static const GpuRenderBackend GL_BACKEND = { .wide_dump_full = glb_wide_dump_full, }; -const GpuRenderBackend *gl_backend_get(void) { return &GL_BACKEND; } +/* ==== Render thread: recording, replay, hand-off =========================== + * See the state block near the top of the file and docs/RENDER_THREAD.md. */ + +/* Emulation-side mirrors of backend state the emulation thread must answer + * without a sync point. They follow the recorded calls exactly as the backend + * will apply them, and are re-read from the backend whenever the emulation + * thread holds the context (rth_mirror_resync). */ +static int s_rthm_wide_w = 0, s_rthm_wide_off = 0; +static int s_rthm_base[WIDE_MAX_SURF]; +static int s_rthm_area[4] = { 0, 0, VRAM_W - 1, VRAM_H - 1 }; +/* Last stream state sent (RTH_STATE); -1 forces the first send. */ +static int s_rthe_flat_bd = -1, s_rthe_vp_w = -1, s_rthe_bg_full = -1; +static uint64_t s_rth_presents = 0, s_rth_presents_stale = 0; + +#define RTHF_PRIM 0x0001u +#define RTHF_TAGGED 0x0002u +#define RTHF_BD_SHIFT 2 /* bits 2-3: psx_ws_prim_in_backdrop() 0..3 */ + +static void rth_mirror_resync(void) { + s_rthm_wide_w = g_wide_w; + s_rthm_wide_off = g_wide_off; + s_rthm_cur = g_wide_cur != 0; + for (int i = 0; i < WIDE_MAX_SURF; i++) + s_rthm_base[i] = s_wide_fbo[i] ? s_wide_base[i] : -1; + s_rthm_area[0] = s_area_x1; s_rthm_area[1] = s_area_y1; + s_rthm_area[2] = s_area_x2; s_rthm_area[3] = s_area_y2; +} + +/* wide_fbo_for() as it will run on the render thread: the same lookup, limit + * check and slot allocation. Only a driver allocation failure can differ. */ +static int rth_mirror_wide_for(int base_x) { + if (s_rthm_wide_w <= 0) return 0; + for (int i = 0; i < WIDE_MAX_SURF; i++) + if (s_rthm_base[i] == base_x) return 1; + if (s_gl_max_dim > 0 && + ((int64_t)s_rthm_wide_w * s_out_scale > s_gl_max_dim || + (int64_t)VRAM_H * s_out_scale > s_gl_max_dim)) + return 0; + for (int i = 0; i < WIDE_MAX_SURF; i++) + if (s_rthm_base[i] < 0) { s_rthm_base[i] = base_x; return 1; } + return 0; +} +static int rth_mirror_wide_present_ok(int base_x) { + if (s_rthm_wide_w <= 0) return 0; + for (int i = 0; i < WIDE_MAX_SURF; i++) + if (s_rthm_base[i] == base_x) return 1; + return 0; +} + +static int rth_rec_ints(uint16_t op, uint16_t flags, int n, const int32_t *v) { + int32_t *p = (int32_t *)rt_cmd_begin(op, flags, (uint32_t)n * 4u); + if (!p) return 0; + memcpy(p, v, (size_t)n * 4u); + rt_cmd_commit(); + return 1; +} + +/* Native-wide stream state read by the fast-path gate (wide_fast_center_valid) + * and the backdrop gate; sent whenever it changes, before the call that would + * first observe the change. */ +static void rth_send_state(void) { + if (s_rthm_wide_w <= 0) return; + int flat = gpu_ws_nw_flat_backdrop_enabled() ? 1 : 0; + int vp = gpu_ws_netplay_local_viewport_width(); + int bg = gpu_ws_background_requires_full_composite() ? 1 : 0; + if (flat == s_rthe_flat_bd && vp == s_rthe_vp_w && bg == s_rthe_bg_full) return; + s_rthe_flat_bd = flat; s_rthe_vp_w = vp; s_rthe_bg_full = bg; + RTH_REC(RTH_STATE, 0, flat, vp, bg); +} + +/* Per-primitive widescreen tags, read by bd_prim_gate during replay. Taken + * only when the backend would read them (native-wide on, stretch gate on). */ +static uint16_t rth_prim_flags(void) { + if (s_rthm_wide_w <= 0 || !g_ws_bd_stretch_on) return 0; + uint16_t f = RTHF_PRIM; + if (g_ws_bd_phase_mode != 0) + f |= (uint16_t)((psx_ws_prim_in_backdrop() & 3) << RTHF_BD_SHIFT); + else if (psx_ws_prim_is_tagged()) + f |= RTHF_TAGGED; + return f; +} + +static int gl_rth_eligible(void) { + extern int psx_netplay_active(void); + return s_raster_ok && s_ctx && !s_cpu_auth_dual && !s_depth24_skip_up && + !gpu_display_is_depth24() && !s_interp_enabled && !s_pass_active && + !psx_netplay_active() && !psx_openxr_session_active() && + gr_backend() == GR_BACKEND_OPENGL; +} + +static void gl_rth_acquire(const char *reason) { + if (!s_rth_on || rt_on_render_thread() || rt_held()) return; + rt_acquire(reason); + s_vram = s_rth_vram_pub; + sw_renderer_rebind_vram(s_rth_vram_pub); +} + +static void gl_rth_release(void) { + if (!s_rth_on || !rt_held()) return; + /* Everything the emulation thread did is in gpu.c's array; the render + * thread continues from a copy of it. */ + memcpy(s_rth_vram_priv, s_rth_vram_pub, (size_t)VRAM_W * VRAM_H * sizeof(uint16_t)); + s_vram = s_rth_vram_priv; + sw_renderer_rebind_vram(s_rth_vram_priv); + rth_mirror_resync(); + rt_release(); +} + +/* Record this call (1) or run it directly (0). Direct: no render thread, on + * the render thread itself (replay), or while the emulation thread holds the + * context. A depth24 display turns the call into a sync point first. */ +static int rth_record_mode(void) { + if (!s_rth_on || rt_on_render_thread() || rt_held()) return 0; + if (gpu_display_is_depth24()) { gl_rth_acquire("depth24"); return 0; } + rth_send_state(); + return 1; +} + +/* Present records carry the host overlays the emulation thread rasterized. */ +typedef struct RthOvHdr { + int32_t needs_present, has, w[4], h[4]; + float slide; + int32_t args[8], nargs; +} RthOvHdr; + +static int rth_record_present(uint16_t op, int n, const int32_t *v) { + const uint32_t *px[4] = { NULL, NULL, NULL, NULL }; + int w[4] = { 0 }, h[4] = { 0 }; + RthOvHdr hd; + memset(&hd, 0, sizeof hd); + hd.needs_present = host_osd_needs_present(); + for (int i = 0; i < 4; i++) { + if (ov_image(i, &px[i], &w[i], &h[i]) && px[i] && w[i] > 0 && h[i] > 0) { + hd.has |= 1 << i; hd.w[i] = w[i]; hd.h[i] = h[i]; + } + } + hd.slide = psx_rewind_slide(); + hd.nargs = n; + for (int i = 0; i < n && i < 8; i++) hd.args[i] = v[i]; + size_t bytes = sizeof hd; + for (int i = 0; i < 4; i++) + if (hd.has & (1 << i)) bytes += (size_t)hd.w[i] * hd.h[i] * 4u; + uint8_t *p = (uint8_t *)rt_cmd_begin(op, 0, (uint32_t)bytes); + if (!p) return 0; + memcpy(p, &hd, sizeof hd); + uint8_t *q = p + sizeof hd; + for (int i = 0; i < 4; i++) + if (hd.has & (1 << i)) { + size_t n4 = (size_t)hd.w[i] * hd.h[i] * 4u; + memcpy(q, px[i], n4); + q += n4; + } + rt_cmd_commit(); + host_osd_present_done(); + { extern void psx_ws_dbg_gate_frame_snapshot(void); psx_ws_dbg_gate_frame_snapshot(); } + s_rth_presents++; + return 1; +} + +static void rth_replay_present(const RtCmd *c, const uint8_t *p) { + RthOvHdr hd; + memcpy(&hd, p, sizeof hd); + if (rt_newer_frame_queued()) { + /* Behind: a later frame is already recorded. Drawing this one only + * delays it; the guest never waits for a present. */ + s_rth_presents_stale++; + return; + } + const uint8_t *q = p + sizeof hd; + memset(&s_rth_ov, 0, sizeof s_rth_ov); + s_rth_ov.valid = 1; + s_rth_ov.needs_present = hd.needs_present; + s_rth_ov.slide = hd.slide; + for (int i = 0; i < 4; i++) + if (hd.has & (1 << i)) { + s_rth_ov.px[i] = (const uint32_t *)q; + s_rth_ov.w[i] = hd.w[i]; + s_rth_ov.h[i] = hd.h[i]; + q += (size_t)hd.w[i] * hd.h[i] * 4u; + } + const int32_t *a = hd.args; + if (c->op == RTH_PRESENT_VRAM) + gl_renderer_present_vram(a[0], a[1], a[2], a[3], a[4], a[5]); + else + (void)gl_renderer_present_wide_fbo(a[0], a[1], a[2], a[3]); + s_rth_ov.valid = 0; +} + +/* ---- the recording vtable ------------------------------------------------ */ +#define RTH_DIRECT_OR(rec) do { if (rth_record_mode()) { rec; return; } } while (0) +static void rtb_init(uint16_t *vram) { + GL_RT_SYNC("init"); + if (s_rth_on) s_rth_vram_pub = vram; + glb_init(vram); + if (s_rth_on && !rt_held()) sw_renderer_rebind_vram(s_rth_vram_priv); +} +static void rtb_set_scale(int sc) { GL_RT_SYNC("set_scale"); glb_set_scale(sc); } +static int rtb_scale(void) { return s_out_scale; } /* changes only under a sync point */ +static void rtb_set_texture_filter(int b) { GL_RT_SYNC("texture_filter"); glb_set_texture_filter(b); } +static int rtb_texture_filter(void) { return s_tex_filter; } +static void rtb_set_semi_transparency(int e, int m) { + RTH_DIRECT_OR(RTH_REC(RTH_SEMI, 0, e, m)); glb_set_semi_transparency(e, m); } +static void rtb_set_mask_bits(int sb, int cb) { + RTH_DIRECT_OR(RTH_REC(RTH_MASK, 0, sb, cb)); glb_set_mask_bits(sb, cb); } +static void rtb_set_texture_window(uint32_t r) { + RTH_DIRECT_OR(RTH_REC(RTH_TWIN, 0, (int32_t)r)); glb_set_texture_window(r); } +static void rtb_set_color_modulation(int r, int g, int b, int raw) { + RTH_DIRECT_OR(RTH_REC(RTH_MOD, 0, r, g, b, raw)); glb_set_color_modulation(r, g, b, raw); } +static void rtb_set_precise_triangle(int en, int32_t x0, int32_t y0, int32_t x1, int32_t y1, + int32_t x2, int32_t y2) { + RTH_DIRECT_OR(RTH_REC(RTH_PRECISE, 0, en, x0, y0, x1, y1, x2, y2)); + glb_set_precise_triangle(en, x0, y0, x1, y1, x2, y2); +} +static void rtb_set_perspective_triangle(int en, float q0, float q1, float q2) { + if (rth_record_mode()) { + int32_t v[4] = { en, 0, 0, 0 }; + memcpy(&v[1], &q0, 4); memcpy(&v[2], &q1, 4); memcpy(&v[3], &q2, 4); + rth_rec_ints(RTH_PERSP, 0, 4, v); + return; + } + glb_set_perspective_triangle(en, q0, q1, q2); +} +static void rtb_fill_rect(int x, int y, int w, int h, uint16_t c) { + RTH_DIRECT_OR(RTH_REC(RTH_FILL, 0, x, y, w, h, c)); glb_fill_rect(x, y, w, h, c); } +static void rtb_copy_rect(int sx, int sy, int dx, int dy, int w, int h) { + RTH_DIRECT_OR(RTH_REC(RTH_COPY, 0, sx, sy, dx, dy, w, h)); glb_copy_rect(sx, sy, dx, dy, w, h); } +static void rtb_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, uint16_t c) { + RTH_DIRECT_OR(RTH_REC(RTH_FLAT_TRI, rth_prim_flags(), x0, y0, x1, y1, x2, y2, c)); + glb_draw_flat_triangle(x0, y0, x1, y1, x2, y2, c); +} +static void rtb_draw_gouraud_triangle(int x0, int y0, uint16_t c0, int x1, int y1, uint16_t c1, + int x2, int y2, uint16_t c2) { + RTH_DIRECT_OR(RTH_REC(RTH_GOURAUD_TRI, rth_prim_flags(), x0, y0, c0, x1, y1, c1, x2, y2, c2)); + glb_draw_gouraud_triangle(x0, y0, c0, x1, y1, c1, x2, y2, c2); +} +static void rtb_draw_textured_triangle(int x0, int y0, int u0, int v0, int x1, int y1, int u1, int v1, + int x2, int y2, int u2, int v2, + uint16_t cx, uint16_t cy, uint16_t tp) { + RTH_DIRECT_OR(RTH_REC(RTH_TEX_TRI, rth_prim_flags(), x0, y0, u0, v0, x1, y1, u1, v1, + x2, y2, u2, v2, cx, cy, tp)); + glb_draw_textured_triangle(x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, cx, cy, tp); +} +static void rtb_draw_shaded_textured_triangle(int x0, int y0, int u0, int v0, uint32_t c0, + int x1, int y1, int u1, int v1, uint32_t c1, + int x2, int y2, int u2, int v2, uint32_t c2, + uint16_t cx, uint16_t cy, uint16_t tp, int raw) { + RTH_DIRECT_OR(RTH_REC(RTH_SHADED_TEX_TRI, rth_prim_flags(), + x0, y0, u0, v0, (int32_t)c0, x1, y1, u1, v1, (int32_t)c1, + x2, y2, u2, v2, (int32_t)c2, cx, cy, tp, raw)); + glb_draw_shaded_textured_triangle(x0, y0, u0, v0, c0, x1, y1, u1, v1, c1, + x2, y2, u2, v2, c2, cx, cy, tp, raw); +} +static void rtb_draw_flat_rect(int x, int y, int w, int h, uint16_t c) { + RTH_DIRECT_OR(RTH_REC(RTH_FLAT_RECT, rth_prim_flags(), x, y, w, h, c)); + glb_draw_flat_rect(x, y, w, h, c); +} +static void rtb_draw_textured_rect(int x, int y, int w, int h, int u, int v, + uint16_t cx, uint16_t cy, uint16_t tp) { + RTH_DIRECT_OR(RTH_REC(RTH_TEX_RECT, rth_prim_flags(), x, y, w, h, u, v, cx, cy, tp)); + glb_draw_textured_rect(x, y, w, h, u, v, cx, cy, tp); +} +static void rtb_draw_textured_rect_scaled(int x, int y, int w, int h, int u0, int v0, int u1, int v1, + uint16_t cx, uint16_t cy, uint16_t tp) { + RTH_DIRECT_OR(RTH_REC(RTH_TEX_RECT_SCALED, rth_prim_flags(), x, y, w, h, u0, v0, u1, v1, + cx, cy, tp)); + glb_draw_textured_rect_scaled(x, y, w, h, u0, v0, u1, v1, cx, cy, tp); +} +static void rtb_draw_line(int x0, int y0, int x1, int y1, uint16_t c) { + RTH_DIRECT_OR(RTH_REC(RTH_LINE, rth_prim_flags(), x0, y0, x1, y1, c)); + glb_draw_line(x0, y0, x1, y1, c); +} +static void rtb_draw_shaded_line(int x0, int y0, uint16_t c0, int x1, int y1, uint16_t c1) { + RTH_DIRECT_OR(RTH_REC(RTH_SHADED_LINE, rth_prim_flags(), x0, y0, c0, x1, y1, c1)); + glb_draw_shaded_line(x0, y0, c0, x1, y1, c1); +} +static int rtb_render_display(uint32_t *o, int p, int dx, int dy, int dw, int dh) { + GL_RT_SYNC("render_display"); return glb_render_display(o, p, dx, dy, dw, dh); } +static int rtb_render_display_hires(uint32_t *o, int p, int dx, int dy, int dw, int dh) { + GL_RT_SYNC("render_display_hires"); return glb_render_display_hires(o, p, dx, dy, dw, dh); } +static void rtb_vram_write(int x, int y, uint16_t px) { + RTH_DIRECT_OR(RTH_REC(RTH_VRAM_WRITE, 0, x, y, px)); glb_vram_write(x, y, px); } +static uint16_t rtb_vram_read(int x, int y) { GL_RT_SYNC("vram_read"); return glb_vram_read(x, y); } +static void rtb_vram_transfer_in(int x, int y, int w, int h, const uint16_t *d) { + if (rth_record_mode() && w > 0 && h > 0) { + size_t n = (size_t)w * (size_t)h; + int32_t *p = (int32_t *)rt_cmd_begin(RTH_XFER_IN, 0, (uint32_t)(16u + n * 2u)); + if (p) { + p[0] = x; p[1] = y; p[2] = w; p[3] = h; + memcpy(p + 4, d, n * 2u); + rt_cmd_commit(); + return; + } + gl_rth_acquire("oversize_upload"); + } + glb_vram_transfer_in(x, y, w, h, d); +} +static void rtb_vram_transfer_out(int x, int y, int w, int h, uint16_t *d) { + GL_RT_SYNC("vram_transfer_out"); glb_vram_transfer_out(x, y, w, h, d); } +static void rtb_set_draw_area(int x1, int y1, int x2, int y2) { + if (rth_record_mode()) { + s_rthm_area[0] = x1; s_rthm_area[1] = y1; s_rthm_area[2] = x2; s_rthm_area[3] = y2; + RTH_REC(RTH_AREA, 0, x1, y1, x2, y2); + return; + } + glb_set_draw_area(x1, y1, x2, y2); +} +static void rtb_get_draw_area(int *x1, int *y1, int *x2, int *y2) { + if (s_rth_on && !rt_on_render_thread() && !rt_held()) { + *x1 = s_rthm_area[0]; *y1 = s_rthm_area[1]; *x2 = s_rthm_area[2]; *y2 = s_rthm_area[3]; + return; + } + glb_get_draw_area(x1, y1, x2, y2); +} +static void rtb_set_draw_offset(int x, int y) { + RTH_DIRECT_OR(RTH_REC(RTH_OFFSET, 0, x, y)); glb_set_draw_offset(x, y); } +static void rtb_wide_configure(int wide_w, int offset) { + if (rth_record_mode()) { + if (s_raster_ok) { + if (wide_w <= 0 || wide_w != s_rthm_wide_w) { + for (int i = 0; i < WIDE_MAX_SURF; i++) s_rthm_base[i] = -1; + s_rthm_cur = 0; + } + s_rthm_wide_w = wide_w > 0 ? wide_w : 0; + s_rthm_wide_off = wide_w > 0 ? offset : 0; + } + RTH_REC(RTH_WIDE_CONFIGURE, 0, wide_w, offset); + return; + } + glb_wide_configure(wide_w, offset); +} +static void rtb_wide_set_view(int en, int shift, int pl, int pr) { + RTH_DIRECT_OR(RTH_REC(RTH_WIDE_VIEW, 0, en, shift, pl, pr)); glb_wide_set_view(en, shift, pl, pr); } +static void rtb_wide_set_target(int base_x) { + if (rth_record_mode()) { + s_rthm_cur = s_raster_ok ? rth_mirror_wide_for(base_x) : 0; + RTH_REC(RTH_WIDE_TARGET, 0, base_x); + return; + } + glb_wide_set_target(base_x); +} +static void rtb_wide_disable_target(void) { + if (rth_record_mode()) { s_rthm_cur = 0; RTH_REC(RTH_WIDE_DISABLE, 0, 0); return; } + glb_wide_disable_target(); +} +static void rtb_wide_clear(int base_x, int y, int h, uint16_t color) { + if (rth_record_mode()) { + if (s_raster_ok && s_ws_ablate != 1) (void)rth_mirror_wide_for(base_x); + RTH_REC(RTH_WIDE_CLEAR, 0, base_x, y, h, color); + return; + } + glb_wide_clear(base_x, y, h, color); +} +static void rtb_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { + if (rth_record_mode()) { + if (s_raster_ok && s_ws_ablate != 1 && s_rthm_wide_off > 0) + (void)rth_mirror_wide_for(base_x); + RTH_REC(RTH_WIDE_CLEAR_MARGINS, 0, base_x, y, h, color, sides); + return; + } + glb_wide_clear_margins(base_x, y, h, color, sides); +} +static int rtb_render_wide_display(uint32_t *out, int pitch, int base_x, int disp_y, int disp_h) { + GL_RT_SYNC("render_wide_display"); + return glb_render_wide_display(out, pitch, base_x, disp_y, disp_h); +} +static int rtb_wide_dump_full(uint32_t *out, int cap, int *ow, int *oh, int base_x) { + GL_RT_SYNC("wide_dump_full"); + return glb_wide_dump_full(out, cap, ow, oh, base_x); +} + +static const GpuRenderBackend GL_RT_BACKEND = { + .name = "opengl", + .init = rtb_init, .set_scale = rtb_set_scale, .scale = rtb_scale, + .set_texture_filter = rtb_set_texture_filter, .texture_filter = rtb_texture_filter, + .set_semi_transparency = rtb_set_semi_transparency, .set_mask_bits = rtb_set_mask_bits, + .set_texture_window = rtb_set_texture_window, .set_color_modulation = rtb_set_color_modulation, + .set_precise_triangle = rtb_set_precise_triangle, + .set_perspective_triangle = rtb_set_perspective_triangle, + .fill_rect = rtb_fill_rect, .copy_rect = rtb_copy_rect, + .draw_flat_triangle = rtb_draw_flat_triangle, .draw_gouraud_triangle = rtb_draw_gouraud_triangle, + .draw_textured_triangle = rtb_draw_textured_triangle, + .draw_shaded_textured_triangle = rtb_draw_shaded_textured_triangle, + .draw_flat_rect = rtb_draw_flat_rect, .draw_textured_rect = rtb_draw_textured_rect, + .draw_textured_rect_scaled = rtb_draw_textured_rect_scaled, + .draw_line = rtb_draw_line, .draw_shaded_line = rtb_draw_shaded_line, + .render_display = rtb_render_display, .render_display_hires = rtb_render_display_hires, + .vram_write = rtb_vram_write, .vram_read = rtb_vram_read, + .vram_transfer_in = rtb_vram_transfer_in, .vram_transfer_out = rtb_vram_transfer_out, + .set_draw_area = rtb_set_draw_area, .get_draw_area = rtb_get_draw_area, + .set_draw_offset = rtb_set_draw_offset, + .wide_configure = rtb_wide_configure, + .wide_set_view = rtb_wide_set_view, + .wide_set_target = rtb_wide_set_target, + .wide_disable_target = rtb_wide_disable_target, + .wide_clear = rtb_wide_clear, + .wide_clear_margins = rtb_wide_clear_margins, + .render_wide_display = rtb_render_wide_display, + .wide_dump_full = rtb_wide_dump_full, +}; + +/* ---- replay (render thread) ---------------------------------------------- */ +static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { + (void)user; + const int32_t *v = (const int32_t *)payload; + s_rthx_prim = (c->flags & RTHF_PRIM) != 0; + s_rthx_tagged = (c->flags & RTHF_TAGGED) != 0; + s_rthx_backdrop = (c->flags >> RTHF_BD_SHIFT) & 3; + switch (c->op) { + case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; + case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; + case RTH_TWIN: glb_set_texture_window((uint32_t)v[0]); break; + case RTH_MOD: glb_set_color_modulation(v[0], v[1], v[2], v[3]); break; + case RTH_PRECISE: glb_set_precise_triangle(v[0], v[1], v[2], v[3], v[4], v[5], v[6]); break; + case RTH_PERSP: { + float q[3]; + memcpy(q, &v[1], sizeof q); + glb_set_perspective_triangle(v[0], q[0], q[1], q[2]); + break; + } + case RTH_FILL: glb_fill_rect(v[0], v[1], v[2], v[3], (uint16_t)v[4]); break; + case RTH_COPY: glb_copy_rect(v[0], v[1], v[2], v[3], v[4], v[5]); break; + case RTH_FLAT_TRI: glb_draw_flat_triangle(v[0], v[1], v[2], v[3], v[4], v[5], (uint16_t)v[6]); break; + case RTH_GOURAUD_TRI: + glb_draw_gouraud_triangle(v[0], v[1], (uint16_t)v[2], v[3], v[4], (uint16_t)v[5], + v[6], v[7], (uint16_t)v[8]); + break; + case RTH_TEX_TRI: + glb_draw_textured_triangle(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], + v[8], v[9], v[10], v[11], + (uint16_t)v[12], (uint16_t)v[13], (uint16_t)v[14]); + break; + case RTH_SHADED_TEX_TRI: + glb_draw_shaded_textured_triangle(v[0], v[1], v[2], v[3], (uint32_t)v[4], + v[5], v[6], v[7], v[8], (uint32_t)v[9], + v[10], v[11], v[12], v[13], (uint32_t)v[14], + (uint16_t)v[15], (uint16_t)v[16], (uint16_t)v[17], v[18]); + break; + case RTH_FLAT_RECT: glb_draw_flat_rect(v[0], v[1], v[2], v[3], (uint16_t)v[4]); break; + case RTH_TEX_RECT: + glb_draw_textured_rect(v[0], v[1], v[2], v[3], v[4], v[5], + (uint16_t)v[6], (uint16_t)v[7], (uint16_t)v[8]); + break; + case RTH_TEX_RECT_SCALED: + glb_draw_textured_rect_scaled(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], + (uint16_t)v[8], (uint16_t)v[9], (uint16_t)v[10]); + break; + case RTH_LINE: glb_draw_line(v[0], v[1], v[2], v[3], (uint16_t)v[4]); break; + case RTH_SHADED_LINE: + glb_draw_shaded_line(v[0], v[1], (uint16_t)v[2], v[3], v[4], (uint16_t)v[5]); + break; + case RTH_VRAM_WRITE: glb_vram_write(v[0], v[1], (uint16_t)v[2]); break; + case RTH_XFER_IN: glb_vram_transfer_in(v[0], v[1], v[2], v[3], (const uint16_t *)(v + 4)); break; + case RTH_AREA: glb_set_draw_area(v[0], v[1], v[2], v[3]); break; + case RTH_OFFSET: glb_set_draw_offset(v[0], v[1]); break; + case RTH_WIDE_CONFIGURE: glb_wide_configure(v[0], v[1]); break; + case RTH_WIDE_VIEW: glb_wide_set_view(v[0], v[1], v[2], v[3]); break; + case RTH_WIDE_TARGET: glb_wide_set_target(v[0]); break; + case RTH_WIDE_DISABLE: glb_wide_disable_target(); break; + case RTH_WIDE_CLEAR: glb_wide_clear(v[0], v[1], v[2], (uint16_t)v[3]); break; + case RTH_WIDE_CLEAR_MARGINS: glb_wide_clear_margins(v[0], v[1], v[2], (uint16_t)v[3], v[4]); break; + case RTH_PROJ_TRI: { + PSXProjectedVertex vx[3]; + memcpy(vx, payload, sizeof vx); + const int32_t *a = (const int32_t *)((const uint8_t *)payload + sizeof vx); + gl_renderer_draw_projected_triangle(vx, (uint16_t)a[0], (uint16_t)a[1], (uint16_t)a[2], + a[3], a[4], a[5]); + break; + } + case RTH_WIDE_RECOVERY: gl_renderer_note_wide_triangle_recovery(v[0]); break; + case RTH_INTERP_SUSPENDED: gl_renderer_set_interpolation_suspended(v[0]); break; + case RTH_PRESENT_VRAM: + case RTH_PRESENT_WIDE: rth_replay_present(c, (const uint8_t *)payload); break; + case RTH_STATE: + s_rths_flat_bd = v[0]; s_rths_vp_w = v[1]; s_rths_bg_full = v[2]; + break; + case RTH_PEEK: { + uint64_t ptr = (uint64_t)(uint32_t)v[4] | ((uint64_t)(uint32_t)v[5] << 32); + (void)gl_renderer_fbo_peek(v[0], v[1], v[2], v[3], (uint16_t *)(uintptr_t)ptr); + break; + } + default: break; + } + s_rthx_prim = 0; +} + +static void gl_rth_ctx(void *user, int current) { + (void)user; + if (current) { + SDL_GL_MakeCurrent(s_win, s_ctx); + } else { + glFlush(); + SDL_GL_MakeCurrent(s_win, NULL); + } +} + +/* ---- lifecycle / public ---------------------------------------------------- */ +extern void gr_refresh_backend(void); /* gpu_render.c */ + +int gl_renderer_render_thread_start(int max_frames) { + if (s_rth_on) return 1; + if (!s_ctx || !s_raster_ok || !s_vram || !gl_rth_eligible()) return 0; + uint16_t *priv = (uint16_t *)malloc((size_t)VRAM_W * VRAM_H * sizeof(uint16_t)); + if (!priv) return 0; + /* Cocoa: a background thread's swap must not wait on the main thread, + * which may itself be waiting on the render thread. */ + SDL_SetHint("SDL_MAC_OPENGL_ASYNC_DISPATCH", "1"); + flush_flat_batch(); + flush_tex_batch(); + flush_cpu_upload(); + memcpy(priv, s_vram, (size_t)VRAM_W * VRAM_H * sizeof(uint16_t)); + s_rth_vram_pub = s_vram; + s_rth_vram_priv = priv; + s_rthe_flat_bd = s_rthe_vp_w = s_rthe_bg_full = -1; + rth_mirror_resync(); + s_vram = priv; + sw_renderer_rebind_vram(priv); + s_rth_on = 1; + RtConfig cfg; + cfg.ring_bytes = (size_t)32u << 20; + cfg.max_frames = max_frames < 1 ? 1 : max_frames > 8 ? 8 : max_frames; + cfg.exec = gl_rth_exec; + cfg.ctx = gl_rth_ctx; + cfg.user = NULL; + if (!rt_start(&cfg)) { + s_rth_on = 0; + s_vram = s_rth_vram_pub; + sw_renderer_rebind_vram(s_rth_vram_pub); + free(priv); + s_rth_vram_priv = NULL; + return 0; + } + gr_refresh_backend(); + return 1; +} + +void gl_renderer_render_thread_stop(void) { + if (!s_rth_on) return; + rt_stop(); /* drains; the context is current here again */ + s_rth_on = 0; + s_vram = s_rth_vram_pub; + sw_renderer_rebind_vram(s_rth_vram_pub); + free(s_rth_vram_priv); + s_rth_vram_priv = NULL; + gr_refresh_backend(); +} + +int gl_renderer_render_thread_active(void) { return s_rth_on; } + +/* Frame boundary (once per vblank, after the present): hand the context back + * to the render thread when the frame may run asynchronously, take it when it + * may not, then close the frame (the in-flight bound applies here). */ +void gl_renderer_render_thread_frame_boundary(void) { + if (!s_rth_on) return; + if (rt_held()) { + if (gl_rth_eligible()) gl_rth_release(); + } else if (!gl_rth_eligible()) { + gl_rth_acquire("ineligible"); + } + rt_frame_end(); +} + +/* Explicit sync point for callers that read renderer-written host state + * without calling a GL entry point (debug rings). */ +void gl_renderer_render_thread_sync(const char *reason) { GL_RT_SYNC(reason); } + +int gl_renderer_render_thread_json(char *out, size_t cap) { + RtStats st; + rt_get_stats(&st); + RtAcquireEvent ev[16]; + int n = rt_acquire_events(ev, 16); + int k = snprintf(out, cap, + "\"active\":%d,\"held\":%d,\"max_frames\":%d,\"records\":%llu,\"bytes\":%llu," + "\"frames_produced\":%llu,\"frames_consumed\":%llu,\"presents\":%llu," + "\"presents_stale\":%llu,\"acquires\":%llu,\"releases\":%llu,\"oversize\":%llu," + "\"backpressure_waits\":%llu,\"backpressure_ms\":%.3f,\"ring_full_waits\":%llu," + "\"ring_full_ms\":%.3f,\"acquire_ms\":%.3f,\"render_busy_ms\":%.3f," + "\"render_idle_ms\":%.3f,\"ring_high_water\":%llu,\"recent_acquires\":[", + s_rth_on, st.held, st.max_frames, (unsigned long long)st.records, + (unsigned long long)st.bytes, (unsigned long long)st.frames_produced, + (unsigned long long)st.frames_consumed, (unsigned long long)s_rth_presents, + (unsigned long long)s_rth_presents_stale, (unsigned long long)st.acquires, + (unsigned long long)st.releases, (unsigned long long)st.oversize, + (unsigned long long)st.backpressure_waits, st.backpressure_ns / 1e6, + (unsigned long long)st.ring_full_waits, st.ring_full_ns / 1e6, + st.acquire_ns / 1e6, st.render_busy_ns / 1e6, st.render_idle_ns / 1e6, + (unsigned long long)st.ring_high_water); + for (int i = 0; i < n && k > 0 && (size_t)k < cap; i++) + k += snprintf(out + k, cap - (size_t)k, "%s{\"frame\":%llu,\"wait_us\":%.1f,\"reason\":\"%s\"}", + i ? "," : "", (unsigned long long)ev[i].frame, ev[i].wait_ns / 1e3, + ev[i].reason); + if (k > 0 && (size_t)k < cap) k += snprintf(out + k, cap - (size_t)k, "]"); + return k; +} + +const GpuRenderBackend *gl_backend_get(void) { return s_rth_on ? &GL_RT_BACKEND : &GL_BACKEND; } diff --git a/runtime/src/gpu_render.c b/runtime/src/gpu_render.c index 1cbf16fd..c205fb80 100644 --- a/runtime/src/gpu_render.c +++ b/runtime/src/gpu_render.c @@ -137,6 +137,16 @@ void gr_set_backend(GrBackend backend) { GrBackend gr_backend(void) { return g_effective; } +/* Re-fetch the OpenGL table after the render thread starts or stops + * (gl_backend_get then returns the recording table, or the direct one). The + * effective backend does not change. */ +void gr_refresh_backend(void) { + if (g_effective == GR_BACKEND_OPENGL) { + const GpuRenderBackend *gl = gl_backend_get(); + if (gl) g_b = gl; + } +} + /* ---- Dispatch wrappers (one line each; forward to the active backend) ---- */ void gr_init(uint16_t *vram) { g_b->init(vram); gpu_hd_textures_set_vram(vram); g_hd_offset_x = g_hd_offset_y = 0; g_hd_texture_window = 0; g_hd_semi = g_hd_perspective = 0; } void gr_set_scale(int scale) { g_b->set_scale(scale); } diff --git a/runtime/src/gpu_sw_renderer.c b/runtime/src/gpu_sw_renderer.c index a00a5e51..e472a06e 100644 --- a/runtime/src/gpu_sw_renderer.c +++ b/runtime/src/gpu_sw_renderer.c @@ -481,6 +481,12 @@ void sw_renderer_init(uint16_t *vram) { g_texture_filter_req = 0; } +/* Point the rasterizer at another VRAM array without touching any draw + * state (the OpenGL render thread switches its upload source this way). */ +void sw_renderer_rebind_vram(uint16_t *vram) { + g_vram = vram; +} + /* ------------------------------------------------------------------ */ /* Supersampling control */ /* ------------------------------------------------------------------ */ diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py index b7643b90..14810c25 100644 --- a/runtime/tests/run_gl_readback_region.py +++ b/runtime/tests/run_gl_readback_region.py @@ -71,6 +71,8 @@ def main(): sources = [("probe", fixture), ("sw", framework / "runtime/src/gpu_sw_renderer.c"), ("rp", framework / "runtime/src/render_pass_plan.c"), ("xr", framework / "runtime/src/psx_openxr.c")] + if (framework / "runtime/src/render_thread.c").exists(): + sources.append(("rth", framework / "runtime/src/render_thread.c")) for name, source in sources: if run([compiler / "gcc.exe", "-std=c11", "-O2", "-flto", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", *includes, "-c", source, diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index b078f8b5..33c55215 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -134,6 +134,8 @@ def main(): sources.append(("rp", framework / "runtime/src/render_pass_plan.c")) if (framework / "runtime/src/psx_openxr.c").exists(): sources.append(("xr", framework / "runtime/src/psx_openxr.c")) + if (framework / "runtime/src/render_thread.c").exists(): + sources.append(("rth", framework / "runtime/src/render_thread.c")) # Unused renderer functions reference the rest of the runtime; the linker # drops them (-dead_strip, or per-function sections with --gc-sections). # MinGW's PE linker reports undefined references from sections it later diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py index fd407225..d4899edd 100644 --- a/runtime/tests/run_gl_texture_filter.py +++ b/runtime/tests/run_gl_texture_filter.py @@ -41,6 +41,8 @@ def main(): ('rp',fw/'runtime/src/render_pass_plan.c')] if (fw/'runtime/src/psx_openxr.c').exists(): sources.append(('xr',fw/'runtime/src/psx_openxr.c')) + if (fw/'runtime/src/render_thread.c').exists(): + sources.append(('rth',fw/'runtime/src/render_thread.c')) objects=[] for name,source in sources: obj=out/(name+'.o'); run([args.cc,*flags,'-c',source,'-o',obj]); objects.append(obj) diff --git a/runtime/tests/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index b51e33a6..e4da089b 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -16,6 +16,10 @@ void gpu_vram_dirty_mark_row_impl(uint32_t y){} void gpu_vram_dirty_mark_rect(int x,int y,int w,int h){} void gpu_vram_dirty_mark_all(void){} int psx_netplay_active(void){return 0;} +/* The renderer's facade hooks (gpu_render.c) for the render thread, which + * these fixtures never start. */ +GrBackend gr_backend(void){return GR_BACKEND_OPENGL;} +void gr_refresh_backend(void){} static int test_depth24; int gpu_display_is_depth24(void){return test_depth24;} void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->display_x=32;out->display_y=32;out->width=320;out->height=16;} diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 61ed9b0f..e5611f81 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -88,6 +88,10 @@ void gpu_vram_dirty_mark_row_impl(uint32_t y){(void)y;} void gpu_vram_dirty_mark_rect(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} void gpu_vram_dirty_mark_all(void){} int psx_netplay_active(void){return 0;} +/* The renderer's facade hooks (gpu_render.c) for the render thread, which + * these fixtures never start. */ +GrBackend gr_backend(void){return GR_BACKEND_OPENGL;} +void gr_refresh_backend(void){} int gpu_display_is_depth24(void){return 0;} void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->width=320;out->height=240;} int psx_ws_prim_in_backdrop(void){return 0;} From aab7c9e6e5b9710c7a50742c9296193d79ea9a13 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:24:09 -0700 Subject: [PATCH 13/57] Frontend: [video] render_thread option, vblank boundary, render_thread command [video] render_thread (default false; PSX_RENDER_THREAD=0/1 overrides, PSX_RENDER_THREAD_FRAMES bounds frames in flight, default 2) starts the OpenGL render thread at the first vblank, after the context and every startup GL call exist. Each vblank then closes a frame right after its present and before wall-clock pacing, so the render thread draws while the guest waits. Netplay, frame interpolation, Vulkan and software keep the synchronous path. TCP: render_thread reports records, frames produced/consumed, presents and stale presents skipped, sync points with their recent reasons, backpressure and render busy time. Display-ring and present-image-ring readers sync first (the render thread fills them). config_loader is a BIOS emitter input: OpenBIOS fingerprint regenerated (tools/regen_bios.sh --config bios/OpenBIOS.toml; the C is unchanged). Co-Authored-By: Claude Opus 5.5 --- recompiler/src/config_loader.cpp | 3 ++ recompiler/src/config_loader.h | 7 ++++ .../tests/video_enhancement_settings_test.cpp | 16 +++++++++ runtime/src/debug_server.c | 31 +++++++++++++--- runtime/src/main.cpp | 36 +++++++++++++++++++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 8d708810..05483809 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -799,6 +799,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.video_texture_window_batching = toml::find(video, "texture_window_batching"); } + if (video.contains("render_thread")) { + rt.video_render_thread = toml::find(video, "render_thread"); + } if (video.contains("vsync")) { const auto mode = toml::find(video, "vsync"); if (mode == "on" || mode == "vsync") rt.video_vsync = 1; diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 6c384a0f..81fbe07e 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -567,6 +567,13 @@ struct RuntimeConfig { // draw in far fewer batches. Off by default; a game opts in. bool video_texture_window_batching = false; + // render_thread: OpenGL only. Run the GL backend on its own thread: the + // emulation thread records each frame's draw work and the render thread + // replays and presents it, so GPU driver stalls stop blocking the guest. + // Guest-visible results are identical (docs/RENDER_THREAD.md). Off by + // default; PSX_RENDER_THREAD=0/1 overrides. + bool video_render_thread = false; + // low_latency_input: re-sample the pad after the wall-clock pacer (just // before present) so the next CPU frame reads near-fresh input instead of // input ~one frame stale. Default on. vsync: present/swap mode — diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index aad3da43..6df8a99f 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -320,6 +320,21 @@ static void test_texture_window_batching() { fs::remove(q); } +/* [video] render_thread (docs/RENDER_THREAD.md): opt-in, off by default so + * every title keeps the synchronous OpenGL path unless it asks. */ +static void test_render_thread() { + fs::path p = write_game_toml("psxrecomp_rth_default.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(!gc.runtime.video_render_thread, "render_thread defaults OFF"); + fs::remove(p); + fs::path q = write_game_toml("psxrecomp_rth_on.toml", + "[video]\n" + "render_thread = true\n"); + auto gq = PSXRecompV4::load_game_config(q); + check(gq.runtime.video_render_thread, "[video] render_thread = true is honoured"); + fs::remove(q); +} + /* docs/ENHANCEMENTS.md G1.11: the PGXP title keys. Defaults keep the * historical behaviour (tolerance 0.5, position cache consulted, IR-path * shadows); a title built with the hooks sets all three. */ @@ -384,6 +399,7 @@ int main() { test_user_settings_round_trip(); test_user_settings_fov_scale(); test_texture_window_batching(); + test_render_thread(); test_pgxp_title_keys(); if (failures) { diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 1724ec38..dfd5f7d4 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9755,11 +9755,13 @@ static void disp_ring_capture(void) * just the display rect + one small readback); CPU VRAM otherwise (the * software backend's authoritative surface). Runs on the present thread, * where the GL context is current. */ - extern int gl_renderer_fbo_peek(int x, int y, int w_, int h_, uint16_t *out); + /* Deferred: with the render thread on, the readback is recorded at this + * point of the command stream and filled in by the render thread (ring + * readers sync first), instead of a per-frame sync point. */ int got = 0; if (di.display_x + w <= 1024 && di.display_y + h <= 512) - got = gl_renderer_fbo_peek((int)di.display_x, (int)di.display_y, - (int)w, (int)h, e->px); + got = gl_renderer_fbo_peek_deferred((int)di.display_x, (int)di.display_y, + (int)w, (int)h, e->px); if (!got) { for (uint32_t y = 0; y < h; y++) for (uint32_t x = 0; x < w; x++) @@ -9767,7 +9769,7 @@ static void disp_ring_capture(void) gpu_vram_peek((int)(di.display_x + x), (int)(di.display_y + y)); } /* Full-VRAM aux capture (same GL-truth/CPU-truth split as the display). */ - if (!gl_renderer_fbo_peek(0, 0, 1024, 512, e->vram)) { + if (!gl_renderer_fbo_peek_deferred(0, 0, 1024, 512, e->vram)) { const uint16_t *v = gpu_get_vram(); memcpy(e->vram, v, (size_t)1024 * 512 * sizeof(uint16_t)); } @@ -9778,6 +9780,7 @@ static void disp_ring_capture(void) * (1024x512 row-major). */ static void handle_display_ring_aux(int id, const char *json) { + gl_renderer_render_thread_sync("display_ring"); int f = json_get_int(json, "frame", -1); if (f < 0) { send_err(id, "missing frame"); return; } char path[512]; @@ -9799,6 +9802,7 @@ static void handle_display_ring_aux(int id, const char *json) static void handle_display_ring_stats(int id, const char *json) { + gl_renderer_render_thread_sync("display_ring"); (void)json; uint32_t oldest = 0, newest = 0; int n = 0; @@ -9816,6 +9820,7 @@ static void handle_display_ring_stats(int id, const char *json) static void handle_display_ring_get(int id, const char *json) { + gl_renderer_render_thread_sync("display_ring"); int f = json_get_int(json, "frame", -1); if (f < 0) { send_err(id, "missing frame"); return; } char path[512]; @@ -10401,16 +10406,31 @@ void debug_server_capture_mark(void) s_capture_wide_ok = write_wide_png(s_capture_wide_path, NULL, NULL) == NULL; s_capture_frozen = 1; gpu_gp0_ring_set_frozen(1); + gl_renderer_render_thread_sync("capture_mark"); present_image_ring_set_frozen(1); gpu_timeline_set_frozen(1); } +/* Render thread ([video] render_thread, docs/RENDER_THREAD.md): + * {"cmd":"render_thread"} -> active, held, frames produced/consumed, presents + * (and stale presents skipped), sync points (acquires) with the most recent + * reasons, backpressure / ring-full waits, render busy/idle time. + * Reading it is not a sync point. */ +static void handle_render_thread(int id, const char *json) +{ + (void)json; + char buf[4096]; + if (gl_renderer_render_thread_json(buf, sizeof buf) <= 0) buf[0] = 0; + send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf[0] ? buf : "\"active\":0"); +} + /* Presented-image ring (see present_image_ring.h): * {"cmd":"present_image_ring_stats"} -> oldest, newest, count, frozen * {"cmd":"present_image_ring_get","frame":N,"path":"x.png"} */ static void handle_present_image_ring_stats(int id, const char *json) { (void)json; + gl_renderer_render_thread_sync("present_image_ring"); /* render thread writes it */ uint32_t lo = 0, hi = 0, seq_lo = 0, seq_hi = 0; int n = 0; present_image_ring_span(&lo, &hi, &n); present_image_ring_sequence_span(&seq_lo, &seq_hi); @@ -10421,6 +10441,7 @@ static void handle_present_image_ring_stats(int id, const char *json) } static void handle_present_image_ring_get(int id, const char *json) { + gl_renderer_render_thread_sync("present_image_ring"); int f = json_get_int(json, "frame", -1); int seq = json_get_int(json, "sequence", -1); char path[512]; @@ -10462,6 +10483,7 @@ static void handle_capture_mark(int id, const char *json) } else if (strcmp(op, "status")) { send_err(id, "op must be status|mark|release"); return; } uint32_t oldest = 0, newest = 0; gpu_gp0_ring_frame_span(&oldest, &newest); + gl_renderer_render_thread_sync("display_ring"); uint32_t d_old = UINT32_MAX, d_new = 0; for (int i = 0; i < DISP_RING_CAP; i++) { if (!s_disp_ring[i].valid) continue; @@ -15475,6 +15497,7 @@ static const CmdEntry s_commands[] = { { "resident_events", handle_resident_events }, { "capture_mark", handle_capture_mark }, { "present_image_ring_stats", handle_present_image_ring_stats }, + { "render_thread", handle_render_thread }, { "present_image_ring_get", handle_present_image_ring_get }, { "kernel_bless", handle_kernel_bless }, { "ws_aspect", handle_ws_aspect }, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a6f1e742..0b725fe2 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1440,6 +1440,12 @@ static int g_fmv_skip_no_xa_hold = 4; * = 30 Hz / 0.50x with the CPU idle. ~60 Hz panels may use vsync as the clock; * otherwise the pacer holds 59.94 Hz and present must not wait on the swap. */ static int g_low_latency_input = 1; +/* [video] render_thread (docs/RENDER_THREAD.md): the GL backend runs on its + * own thread. Started at the first vblank once the context and every startup + * GL call are done; s_render_thread_tried keeps it to one attempt. */ +static int g_render_thread = 0; +static int g_render_thread_frames = 2; +static int s_render_thread_tried = 0; static int g_video_vsync = 1; static int g_frame_interpolation = 0; static int g_frame_interpolation_fps = 0; @@ -9402,9 +9408,32 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { c.down_block_until > now_s ? c.down_block_until - now_s : 0.0, blocked); } +/* Render thread: start once, then close every frame (after its present, + * before pacing, so the render thread draws while the guest waits). */ +static void render_thread_vblank(void) { +#ifndef PSX_SDL_NO_RENDER + if (!g_render_thread) return; + if (!s_render_thread_tried) { + s_render_thread_tried = 1; + if (g_gl_active && gr_backend() == GR_BACKEND_OPENGL && + gl_renderer_render_thread_start(g_render_thread_frames)) { + std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", + g_render_thread_frames); + } else { + std::fprintf(stdout, "psxrecomp: render thread requested but not started " + "(needs the OpenGL backend without netplay, frame " + "interpolation or a 24-bit display)\n"); + } + std::fflush(stdout); + } + gl_renderer_render_thread_frame_boundary(); +#endif +} + static void sdl_vblank_present(void) { sync_guest_cadence_to_video_standard(); NetplayVblankEpilogue ep = sdl_vblank_present_body(); + render_thread_vblank(); /* Selfcheck span-end rewind: after present-body C++ RAII, before any * further guest progress. Longjmps on success — keeps every resim load * on the same VBlank boundary (BB fast-poll tails forked #2 vs #3). */ @@ -15506,6 +15535,7 @@ int main(int argc, char** argv) { g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; gl_renderer_set_texture_window_batching( gc.runtime.video_texture_window_batching ? 1 : 0); + g_render_thread = gc.runtime.video_render_thread ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; @@ -17808,6 +17838,12 @@ session_reboot: /* [video] texture_window_batching A/B (same image, fewer GL draws). */ if (const char* e = std::getenv("PSX_GL_TEXWIN_BATCH")) gl_renderer_set_texture_window_batching((*e && *e != '0') ? 1 : 0); + /* [video] render_thread A/B; PSX_RENDER_THREAD_FRAMES bounds frames in + * flight (default 2). */ + if (const char* e = std::getenv("PSX_RENDER_THREAD")) + g_render_thread = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_RENDER_THREAD_FRAMES")) + g_render_thread_frames = std::atoi(e) > 0 ? std::atoi(e) : 2; /* Scanlines: env override wins over config, same as the corrections above, * so a headless/free-run boot can be captured with the effect armed from the * first present. PSX_SCANLINES=0/1; PSX_SCANLINE_STRENGTH=0..1. Pushed to the From cb36c4752b7f494fd61c426fc9a5e54d8c25bc0e Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:24:09 -0700 Subject: [PATCH 14/57] Test: render thread on vs off on a real OpenGL context gl_render_thread_test runs a scripted stream through the facade twice per scale (1x, 4x): every value the guest reads back, native VRAM, the frame at internal resolution and the native-wide surface must match with the render thread off and on. The stream has mask set/check, a mask-checked upload (per-pixel readback sync point), back-to-back uploads of one rect with a catch-up between gpu.c's writes and its commit, ring wraps, readback frames, presents, and widescreen tags that change per primitive and are poisoned after each call. A slow render-thread present keeps the replay a frame behind. It fails for each of: reading tags live, sharing the upload source with the guest array, dropping the stream state. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 13 ++ runtime/tests/run_gl_render_thread.py | 124 ++++++++++++ runtime/tests/test_gl_render_thread.c | 281 ++++++++++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 runtime/tests/run_gl_render_thread.py create mode 100644 runtime/tests/test_gl_render_thread.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index aeec366a..3147eaf6 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -211,6 +211,19 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-scale-results") set_tests_properties(gl_scale_invariance_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) + # Render thread ([video] render_thread) on vs off: the values the + # guest reads back, native VRAM, the frame at S and the native-wide + # surface are identical for a scripted stream with readbacks, mask + # bits, uploads, ring wraps and per-primitive widescreen tags. + add_test(NAME gl_render_thread_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_render_thread.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-render-thread-results") + set_tests_properties(gl_render_thread_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py new file mode 100644 index 00000000..c6dc7c82 --- /dev/null +++ b/runtime/tests/run_gl_render_thread.py @@ -0,0 +1,124 @@ +"""Build and run test_gl_render_thread.c on a hidden real OpenGL context. + +Each scale runs twice, render thread off and on ([video] render_thread). The +values the scripted guest read back (rb), the native VRAM (digest), the frame +at internal resolution (hires) and the native-wide surface (wide) must be the +same both ways, and the threaded run must actually have recorded and replayed +the stream (its own checks). A third run per scale with one frame in flight +(PSX_TEST_RT_FRAMES is not needed: the fixture takes the bound as argv) is not +required; the in-flight bound itself is covered by render_thread_test. + +macOS/Linux: pass the SDL3 include directory and static library and a C +compiler, as for run_gl_scale_invariance.py. Evidence (commands, output) is +written to receipt.json under --output. +""" +import argparse +import json +import pathlib +import platform +import re +import subprocess +import sys +import tempfile + +MAC_FRAMEWORKS = ["Cocoa", "OpenGL", "IOKit", "CoreVideo", "CoreAudio", "AudioToolbox", + "Carbon", "ForceFeedback", "GameController", "Metal", "QuartzCore", + "CoreMedia", "AVFoundation", "Foundation", "CoreHaptics", + "UniformTypeIdentifiers"] +KEYS = ("rb", "digest", "hires", "wide") + + +def parse(stdout): + out = {} + for k in KEYS: + m = re.search(r"^" + k + r"=([0-9a-f]{16})$", stdout, re.M) + out[k] = m[1] if m else None + m = re.search(r"^checks=(\d+) failures=(\d+)$", stdout, re.M) + out["failures"] = int(m[2]) if m else None + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--cc", default="cc") + ap.add_argument("--sdl-include", required=True) + ap.add_argument("--sdl-library", required=True) + ap.add_argument("--output", required=True) + ap.add_argument("--fixture", type=pathlib.Path) + ap.add_argument("--scales", default="1,4") + ap.add_argument("--frames", default="80") + args = ap.parse_args() + sdl_includes = [str(pathlib.Path(d).resolve()) for d in args.sdl_include.split(";") if d] + args.sdl_library = str(pathlib.Path(args.sdl_library).resolve()) + framework = pathlib.Path(__file__).resolve().parents[2] + fixture = args.fixture or framework / "runtime/tests/test_gl_render_thread.c" + out_root = pathlib.Path(args.output).resolve() + out_root.mkdir(parents=True, exist_ok=True) + dest = pathlib.Path(tempfile.mkdtemp(prefix="rth-", dir=out_root)) + print("Evidence directory:", dest) + receipt = [] + + def run(command): + command = [str(c) for c in command] + r = subprocess.run(command, cwd=dest, capture_output=True, text=True, + encoding="utf-8", errors="replace") + receipt.append({"cmd": command, "exit": r.returncode, + "stdout": r.stdout, "stderr": r.stderr[-4000:]}) + (dest / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8") + return r + + includes = ["-I", framework / "runtime/include", "-I", framework / "runtime/src"] + for d in sdl_includes: + includes += ["-I", d] + src = framework / "runtime/src" + # The fixture #includes gpu_gl_renderer.c and drives it through the real + # facade (gpu_render.c) and render thread (render_thread.c). + sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), + ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), + ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), + ("facade", src / "gpu_render.c")] + sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] + objs = [] + for name, path in sources: + o = dest / (name + ".o") + r = run([args.cc, "-std=gnu11", "-O1", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", + "-DGL_SILENCE_DEPRECATION=1", "-w", *sections, *includes, "-c", path, "-o", o]) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + objs.append(o) + link = [args.cc, *objs, args.sdl_library, "-o", dest / "probe"] + if platform.system() == "Darwin": + for f in MAC_FRAMEWORKS: + link += ["-framework", f] + link += ["-liconv", "-lm", "-lpthread", "-Wl,-dead_strip"] + else: + link += ["-lGL", "-lm", "-ldl", "-lpthread", "-Wl,--gc-sections"] + r = run(link) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + + ok = True + for s in [int(v) for v in args.scales.split(",") if v]: + runs = {} + for threaded in (0, 1): + r = run([dest / "probe", s, threaded, args.frames]) + p = parse(r.stdout) + tail = r.stdout.strip().splitlines()[-6:] + print(f"scale {s} render_thread={threaded}: exit={r.returncode}", tail, + r.stderr.strip()[-800:]) + if r.returncode or p["failures"] != 0 or any(p[k] is None for k in KEYS): + ok = False + runs[threaded] = p + for k in KEYS: + if runs[0][k] != runs[1][k]: + print(f"FAIL scale {s}: {k} differs with the render thread on " + f"({runs[0][k]} off, {runs[1][k]} on)") + ok = False + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c new file mode 100644 index 00000000..e2bb7035 --- /dev/null +++ b/runtime/tests/test_gl_render_thread.c @@ -0,0 +1,281 @@ +/* Render thread equivalence on a real, hidden OpenGL context. + * + * Run twice per scale by run_gl_render_thread.py, once with the render thread + * off (argv[2] == 0) and once on (1); the runner requires every digest to + * match. The scripted stream goes through the renderer facade (gr_*), exactly + * as gpu.c drives it, over many frames: + * - CPU->VRAM uploads written the way gpu.c writes them (the guest-visible + * array first, then the facade with a copy), including the same rect + * re-uploaded with new data before the render thread can have drawn the + * first (the render thread must stage each payload, not the live array); + * full-VRAM uploads push the ring past its end (wrap); + * - GP0(E6) mask set / check around draws, and a mask-checked CPU upload + * whose per-pixel mask test reads VRAM back (a sync point mid-frame); + * - fills, VRAM copies, render-to-texture, every semi-transparency mode, + * lines, textured and shaded primitives, draw area / offset changes; + * - native-wide: margin draws, wide clears, and per-primitive widescreen + * tags that change with every primitive (captured at record time) plus a + * stream latch that flips mid-frame (sent as state); + * - presents and frame boundaries every frame, with readbacks (GPUREAD + * style gr_vram_read, VRAM->CPU transfer_out) in some frames only. + * Printed: rb (digest of every value the "guest" read back), digest (native + * VRAM at the end), hires (frame at internal resolution), wide (native-wide + * surface), and with the thread on its record/acquire counters. + * Original source-owned scene; no retail payload. */ +#include "gpu_gl_renderer.c" +#include "mod_texture_banks.c" +uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} +int g_psx_vram_dirty_tracking=0; +uint64_t s_frame_count=0; +void gpu_vram_dirty_mark_row_impl(uint32_t y){(void)y;} +void gpu_vram_dirty_mark_rect(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_vram_dirty_mark_all(void){} +int psx_netplay_active(void){return 0;} +const GpuRenderBackend *vk_backend_get(void){return NULL;} +int gpu_display_is_depth24(void){return 0;} +void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->width=320;out->height=240;} +/* Guest state the backend reads per primitive. It changes with every + * primitive here, so a replay that read it live (instead of the value + * captured when the primitive was recorded) would draw a different image. */ +static int g_prim_id; +static int g_bg_full; +int psx_ws_prim_in_backdrop(void){return (g_prim_id % 3) == 0;} +int psx_ws_prim_is_tagged(void){return (g_prim_id % 5) == 0;} +int gpu_ws_nw_flat_backdrop_enabled(void){return 0;} +int gpu_ws_background_requires_full_composite(void){return g_bg_full;} +int g_ws_tex_edge_pct=0; +void psx_ws_dbg_gate_frame_snapshot(void){} +void gpu_depth24_upload_span_reset(void){} +int gpu_ws_netplay_local_viewport_width(void){return 0;} +int render_pass_netplay_enabled(void){return 0;} +uint64_t psx_cycle_count=0; +uint32_t g_psx_vblank_cycles=564480u; +/* Frontend hooks of the present path (host overlays, pacing, rings). */ +int host_osd_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int host_osd_volume_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int host_osd_needs_present(void){return 0;} +void host_osd_present_done(void){} +int psx_rewind_overlay_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +float psx_rewind_slide(void){return 0.0f;} +int psx_savestate_menu_overlay_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int present_shot_take(char *out,int n){(void)out;(void)n;return 0;} +void present_shot_done(int ok){(void)ok;} +void psx_host_sleep_ms(uint32_t ms){(void)ms;} +/* Called on every present. On the render thread it also stands in for a slow + * swap, so replay runs well behind recording and anything it read live from + * the "guest" (instead of what was recorded) would be a later value. */ +int psx_present_vsync_owns_cadence(void){ + if (rt_on_render_thread()) SDL_Delay(4); + return 0; +} +void latency_ring_mark(LatencyStage s){(void)s;} +void gpu_timeline_note(uint8_t k,uint32_t a,uint32_t b){(void)k;(void)a;(void)b;} + +static uint16_t vram[1024*512]; +static int checks, failures; +static void check(int ok, const char *label) { + checks++; + if (!ok) { fprintf(stderr, "FAIL %s\n", label); failures++; } +} +static uint64_t fnv(const void *pp, size_t n, uint64_t h) { + const uint8_t *p = (const uint8_t *)pp; + for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } + return h; +} +static uint64_t rb_digest = 0xcbf29ce484222325ull; +static uint32_t rng = 0x2468ace1u; +static uint32_t rnd(void) { rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; return rng; } + +/* gpu.c's CPU->VRAM path: write the guest-visible array (honouring the mask + * test, which reads VRAM through the facade), then hand the facade a copy. */ +static uint16_t staging[1024*512]; +static int s_set_mask, s_check_mask; +static void cpu_upload_ex(int x, int y, int w, int h, const uint16_t *src, int catch_up) { + for (int r = 0; r < h; r++) + for (int c = 0; c < w; c++) { + int vx = (x + c) & 1023, vy = (y + r) & 511; + uint16_t px = src[r * w + c]; + if (s_check_mask && (gr_vram_read(vx, vy) & 0x8000)) continue; + if (s_set_mask) px |= 0x8000; + vram[vy * 1024 + vx] = px; + } + /* Worst case for a render thread that staged uploads from this array: + * it replays an earlier upload of the same rect right here, between + * gpu.c's pixel writes and its commit (which re-reads the array). */ + if (catch_up && gl_renderer_render_thread_active()) rt_drain(); + for (int r = 0; r < h; r++) + for (int c = 0; c < w; c++) + staging[r * w + c] = vram[((y + r) & 511) * 1024 + ((x + c) & 1023)]; + gr_vram_transfer_in(x, y, w, h, staging); +} +static void cpu_upload(int x, int y, int w, int h, const uint16_t *src) { + cpu_upload_ex(x, y, w, h, src, 0); +} +static void mask_bits(int set, int chk) { + s_set_mask = set; s_check_mask = chk; + gr_set_mask_bits(set, chk); +} + +#define FRAME_W 320 +#define FRAME_H 240 + +static void textures(void) { + static uint16_t page[64*64], clut[16]; + for (int i = 0; i < 64*64; i++) page[i] = (uint16_t)((i * 0x1357u) ^ (i >> 3)); + for (int i = 0; i < 16; i++) clut[i] = (uint16_t)(i ? (0x0421u * (uint16_t)i) | ((i & 1) << 15) : 0); + cpu_upload(512, 0, 64, 64, page); + cpu_upload(512, 256, 16, 1, clut); +} + +static void frame(int f, int buf_x) { + gr_set_draw_area(buf_x, 0, buf_x + FRAME_W - 1, FRAME_H - 1); + gr_set_draw_offset(buf_x, 0); + gr_wide_set_target(buf_x); + gr_fill_rect(buf_x, 0, FRAME_W, FRAME_H, (uint16_t)(0x0842 * (f & 7))); + gr_wide_clear(buf_x, 0, FRAME_H, (uint16_t)(0x0842 * (f & 7))); + gr_set_color_modulation(128, 128, 128, 0); + mask_bits(0, 0); + /* Many primitives with tags that change per primitive. */ + for (int i = 0; i < 600; i++) { + g_prim_id = f * 1000 + i; + if (i == 300) g_bg_full = (f & 1); /* stream latch flips mid-frame */ + int x = buf_x + (int)(rnd() % 420) - 50, y = (int)(rnd() % 240); + int w = 4 + (int)(rnd() % 40), h = 4 + (int)(rnd() % 30); + uint16_t c = (uint16_t)(rnd() & 0x7fff); + gr_set_semi_transparency((int)(rnd() % 4 == 0), (int)(rnd() % 4)); + switch (i % 7) { + case 0: gr_draw_flat_rect(x, y, w, h, c); break; + case 1: gr_draw_gouraud_triangle(x, y, c, x + w, y + 3, (uint16_t)(c ^ 0x1f), x + 2, y + h, (uint16_t)(c ^ 0x3e0)); break; + case 2: gr_draw_textured_rect(x, y, w, h, (int)(rnd() % 32), (int)(rnd() % 32), 512, 256, 0x0008); break; + case 3: gr_draw_shaded_textured_triangle(x, y, 0, 0, 0x808080, x + w, y, 63, 4, 0x6090b0, + x, y + h, 8, 63, 0xb09060, 512, 256, 0x0008, (int)(i & 1)); break; + case 4: gr_draw_line(x, y, x + w * 2, y + h, c); break; + case 5: gr_draw_shaded_line(x, y, c, x + 3, y + h, (uint16_t)~c); break; + default: gr_draw_flat_triangle(x, y, x + w, y + h, x - 5, y + h / 2, c); break; + } + g_prim_id = -1; /* the next GP0 command is in flight: no tags */ + } + gr_set_semi_transparency(0, 0); + /* Mask set, then mask check over it. */ + mask_bits(1, 0); + gr_draw_flat_rect(buf_x + 40, 40, 30, 30, 0x4210); + mask_bits(0, 1); + gr_draw_flat_rect(buf_x + 30, 30, 60, 60, 0x7fff); + /* A mask-checked CPU upload over the masked rect: the per-pixel test + * reads VRAM back (sync point), in some frames only. */ + if (f % 4 == 1) { + static uint16_t patch[24 * 24]; + for (int i = 0; i < 24 * 24; i++) patch[i] = (uint16_t)(0x1234 + i * 7 + f); + cpu_upload(buf_x + 35, 35, 24, 24, patch); + } + mask_bits(0, 0); + /* The same rect uploaded twice with different data back to back. */ + { + static uint16_t a[32 * 16], b[32 * 16]; + for (int i = 0; i < 32 * 16; i++) { a[i] = (uint16_t)(0x0300 + i + f); b[i] = (uint16_t)(0x6000 ^ (i * 3 + f)); } + cpu_upload(600, 100, 32, 16, a); + gr_draw_textured_rect(buf_x + 200, 180, 32, 16, 88, 100, 512, 256, 0x0009); /* samples a */ + cpu_upload_ex(600, 100, 32, 16, b, 1); + gr_draw_textured_rect(buf_x + 240, 180, 32, 16, 88, 100, 512, 256, 0x0009); /* samples b */ + /* The guest keeps writing its array (as gpu.c does for the next + * GP0(A0) before that upload completes); nothing may read this. */ + for (int i = 0; i < 32 * 16; i++) vram[(100 + i / 32) * 1024 + 600 + i % 32] = 0x7c1f; + } + /* Render to texture, copies. */ + gr_draw_flat_rect(576, 64, 32, 32, (uint16_t)(0x03e0 + f)); + gr_draw_textured_rect(buf_x + 100, 200, 32, 32, 0, 64, 512, 256, 0x0009); + gr_copy_rect(buf_x + 10, 10, buf_x + 14, 13, 80, 50); + /* A half-VRAM upload every frame pushes the ring past its end. */ + { + static uint16_t big[1024 * 256]; + for (int i = 0; i < 1024 * 256; i++) big[i] = (uint16_t)(rnd() & 0x7fff); + cpu_upload(0, 256, 1024, 256, big); + } + /* Readbacks in some frames (GPUREAD-style and VRAM->CPU). */ + if (f % 5 == 3) { + for (int i = 0; i < 64; i++) { + uint16_t v = gr_vram_read(buf_x + (int)(rnd() % FRAME_W), (int)(rnd() % FRAME_H)); + rb_digest = fnv(&v, 2, rb_digest); + } + static uint16_t out[64 * 32]; + gr_vram_transfer_out(buf_x + 50, 50, 64, 32, out); + rb_digest = fnv(out, sizeof out, rb_digest); + } + gl_renderer_present_vram(buf_x, 0, FRAME_W, FRAME_H, 0, 0); + gl_renderer_render_thread_frame_boundary(); +} + +int main(int argc, char **argv) { + int scale = argc > 1 ? atoi(argv[1]) : 1; + int threaded = argc > 2 && argv[2][0] == '1'; + int frames = argc > 3 ? atoi(argv[3]) : 80; + if (SDL_Init(SDL_INIT_VIDEO) != 0) return 2; + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#if defined(__APPLE__) + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window *win = SDL_CreateWindow("Render thread hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) return 2; + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(scale); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } + check(gr_backend() == GR_BACKEND_OPENGL, "OpenGL backend"); + textures(); + gr_wide_configure(426, 53); + if (threaded) { + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + check(gl_renderer_render_thread_active(), "render thread active"); + } + for (int f = 0; f < frames; f++) frame(f, (f & 1) ? 512 : 0); + + /* Guest-visible VRAM, the frame at internal resolution, the wide surface. */ + static uint16_t native[1024 * 512]; + gr_vram_transfer_out(0, 0, 1024, 512, native); + uint64_t digest = fnv(native, sizeof native, 0xcbf29ce484222325ull); + check(memcmp(native, vram, sizeof native) == 0, "readback landed in the guest-visible array"); + int fw = FRAME_W * scale, fh = FRAME_H * scale, ow = 0, oh = 0; + uint32_t *img = (uint32_t *)malloc((size_t)fw * fh * 4); + int n = img ? gl_renderer_read_display_hires(512, 0, FRAME_W, FRAME_H, img, fw * fh, &ow, &oh) : 0; + check(n == fw * fh, "hires readback"); + uint64_t hires = n ? fnv(img, (size_t)fw * fh * 4, 0xcbf29ce484222325ull) : 0; + free(img); + uint64_t wide = 0; + { + int ww = 426 * scale, wh = 512 * scale, gw = 0, gh = 0; + uint32_t *wb = (uint32_t *)malloc((size_t)ww * wh * 4); + int got = wb ? gr_wide_dump_full(wb, ww * wh, &gw, &gh, 512) : 0; + check(got == ww * wh, "wide dump"); + if (got) wide = fnv(wb, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + free(wb); + } + if (threaded) { + RtStats st; + rt_get_stats(&st); + printf("rt_records=%llu rt_acquires=%llu rt_frames=%llu rt_high_water=%llu presents=%llu stale=%llu\n", + (unsigned long long)st.records, (unsigned long long)st.acquires, + (unsigned long long)st.frames_produced, (unsigned long long)st.ring_high_water, + (unsigned long long)s_rth_presents, (unsigned long long)s_rth_presents_stale); + check(st.records > (uint64_t)frames * 600, "draws were recorded"); + /* Sync points: the mask-checked uploads, the readback frames and the + * final readbacks; never more than one per frame plus the tail. */ + check(st.acquires >= 2 && st.acquires <= (uint64_t)frames + 4, "acquires bounded"); + check(st.bytes > ((uint64_t)32 << 20), "ring wrapped (more than its size recorded)"); + } + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("rb=%016llx\n", (unsigned long long)rb_digest); + printf("digest=%016llx\n", (unsigned long long)digest); + printf("hires=%016llx\n", (unsigned long long)hires); + printf("wide=%016llx\n", (unsigned long long)wide); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); + check(!gl_renderer_render_thread_active(), "stopped by shutdown"); + SDL_DestroyWindow(win); + SDL_Quit(); + return failures ? 1 : 0; +} From 18c7365c1c4af01c3b21fad6a6ba7163d88bfd6f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:39:04 -0700 Subject: [PATCH 15/57] OpenGL render thread: fit_wide_aspect is not a sync point main.cpp's adaptive aspect asks it every native-wide frame; it only does arithmetic on the driver limit and the internal scale, which change only while the emulation thread holds the context. As a sync point it held every widescreen frame on the emulation thread. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index f2d99b6f..e43e08cd 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -5117,7 +5117,8 @@ static int init_gpu_raster(void) { int gl_renderer_texture_banks_supported(void) { return s_raster_ok && !s_cpu_auth_dual && !s_hd_native_authority; } int gl_renderer_fit_wide_aspect(int disp_w, int *num, int *den) { - GL_RT_SYNC("fit_wide_aspect"); + /* No sync point: pure arithmetic on limits that change only while the + * emulation thread holds the context (main.cpp asks every frame). */ if (!s_raster_ok || s_gl_max_dim <= 0) return 0; /* Against the allocation (the dynamic-resolution ceiling): the aspect * must not change when the level steps. */ From 949530623b84b65fd75cfc3d8473acd138fec0ac Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:47:32 -0700 Subject: [PATCH 16/57] Render thread: skip a present only when two newer frames wait One frame behind is the normal pipelined state when the render thread is the slower side; skipping on it dropped nearly every present at 4K widescreen. rt_frames_ahead() counts the complete frames queued after the one being replayed; the backend skips only at two, where the emulation thread is at the in-flight bound waiting on the render thread. The unit test checks the count never exceeds the bound and that both cases occur under a slow consumer. Co-Authored-By: Claude Opus 5.5 --- runtime/include/render_thread.h | 13 +++++++------ runtime/src/gpu_gl_renderer.c | 9 ++++++--- runtime/src/render_thread.c | 6 ++++-- runtime/tests/test_render_thread.c | 11 +++++++++-- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/runtime/include/render_thread.h b/runtime/include/render_thread.h index c40c4641..681c5ca8 100644 --- a/runtime/include/render_thread.h +++ b/runtime/include/render_thread.h @@ -12,8 +12,9 @@ * ring end; payloads are copied, so the producer may reuse its buffers. * - rt_frame_end() closes a guest frame. At most max_frames closed frames may * wait in the ring; past that the emulation thread blocks (backpressure). - * While replaying, rt_newer_frame_queued() tells the backend a later frame - * is already recorded, so it may skip presenting a stale one. + * While replaying, rt_frames_ahead() counts the complete frames already + * recorded after the one being replayed, so the backend can skip + * presenting a frame it is far behind on. * - rt_acquire() is the one sync point: it waits until every record has been * executed, has the render thread release the context, and makes it * current on the calling (emulation) thread. The emulation thread then @@ -22,7 +23,7 @@ * thread at all. * - No lock is held while exec() runs. Locks only guard sleeping/waking. * - * Every function except the rt_on_render_thread() / rt_newer_frame_queued() + * Every function except the rt_on_render_thread() / rt_frames_ahead() * queries is emulation-thread only. */ #ifndef PSX_RENDER_THREAD_H #define PSX_RENDER_THREAD_H @@ -81,9 +82,9 @@ void rt_cmd_commit(void); * max_frames closed frames are unconsumed. */ void rt_frame_end(void); -/* Render-thread query while executing: a frame marker newer than the record - * being executed is already queued (the frame being replayed is stale). */ -int rt_newer_frame_queued(void); +/* Render-thread query while executing: how many complete frames are queued + * after the frame whose record is being executed (0 = it is the newest). */ +int rt_frames_ahead(void); /* Sync point. Drain, then move the context to the calling thread (held). * reason is a static string kept in the acquire ring. No-op when already held diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index e43e08cd..ba0ebb41 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -9285,9 +9285,12 @@ static int rth_record_present(uint16_t op, int n, const int32_t *v) { static void rth_replay_present(const RtCmd *c, const uint8_t *p) { RthOvHdr hd; memcpy(&hd, p, sizeof hd); - if (rt_newer_frame_queued()) { - /* Behind: a later frame is already recorded. Drawing this one only - * delays it; the guest never waits for a present. */ + if (rt_frames_ahead() >= 2) { + /* Two later frames are already recorded, so the emulation thread is + * at (or near) the in-flight bound waiting on us. Showing this frame + * would only delay those; skip its present (its drawing has been + * replayed and stays). One frame behind is the normal pipelined + * state and still presents every frame. */ s_rth_presents_stale++; return; } diff --git a/runtime/src/render_thread.c b/runtime/src/render_thread.c index 33aa71e5..bdb86e5a 100644 --- a/runtime/src/render_thread.c +++ b/runtime/src/render_thread.c @@ -323,8 +323,10 @@ void rt_frame_end(void) { } } -int rt_newer_frame_queued(void) { - return atomic_load(&R.frames_produced) >= atomic_load(&R.frames_consumed) + 2; +int rt_frames_ahead(void) { + int64_t d = (int64_t)(atomic_load(&R.frames_produced) - + atomic_load(&R.frames_consumed)) - 1; + return d < 0 ? 0 : (int)d; } void rt_drain(void) { diff --git a/runtime/tests/test_render_thread.c b/runtime/tests/test_render_thread.c index a906e482..2353be2b 100644 --- a/runtime/tests/test_render_thread.c +++ b/runtime/tests/test_render_thread.c @@ -41,7 +41,9 @@ static _Atomic int exec_errors; static _Atomic int exec_off_thread; static _Atomic int exec_without_ctx; static _Atomic int stale_seen; +static _Atomic int far_behind_seen; /* two complete frames queued after this one */ static int exec_delay_us; +static int g_max_frames = 1; static void sleep_us(int us) { struct timespec ts = { us / 1000000, (long)(us % 1000000) * 1000 }; @@ -56,7 +58,9 @@ static void fake_exec(void *user, const RtCmd *c, const void *payload) { if (!rt_on_render_thread()) atomic_fetch_add(&exec_off_thread, 1); if (atomic_load(&ctx_owner) != 2) atomic_fetch_add(&exec_without_ctx, 1); if (c->op == OP_PRESENT) { - if (rt_newer_frame_queued()) atomic_fetch_add(&stale_seen, 1); + if (rt_frames_ahead() >= 1) atomic_fetch_add(&stale_seen, 1); + if (rt_frames_ahead() >= 2) atomic_fetch_add(&far_behind_seen, 1); + if (rt_frames_ahead() > g_max_frames) atomic_fetch_add(&exec_errors, 1); /* the bound */ } else if (c->op == OP_DATA) { const uint64_t *p = (const uint64_t *)payload; if (c->payload < 8 || p[0] != exec_next) atomic_fetch_add(&exec_errors, 1); @@ -89,12 +93,14 @@ static void reset_state(void) { atomic_store(&exec_off_thread, 0); atomic_store(&exec_without_ctx, 0); atomic_store(&stale_seen, 0); + atomic_store(&far_behind_seen, 0); atomic_store(&ctx_violations, 0); atomic_store(&ctx_owner, 1); /* the "context" starts current here */ exec_delay_us = 0; } static int start(size_t ring, int frames) { + g_max_frames = frames; RtConfig cfg = { ring, frames, fake_exec, fake_ctx, NULL }; return rt_start(&cfg); } @@ -157,7 +163,8 @@ static void test_backpressure(void) { RtStats st; rt_get_stats(&st); CHECK(worst <= 2, "in-flight bound held (worst %llu)", (unsigned long long)worst); CHECK(st.backpressure_waits > 0, "producer was held back"); - CHECK(atomic_load(&stale_seen) > 0, "stale presents detected while behind"); + CHECK(atomic_load(&stale_seen) > 0, "presents one frame behind seen"); + CHECK(atomic_load(&far_behind_seen) > 0, "presents two frames behind seen (skip case)"); CHECK(atomic_load(&exec_errors) == 0, "order kept under backpressure"); rt_stop(); From 7d7dabefb9bf7034ae872062f670c2a751c688ae Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:48:50 -0700 Subject: [PATCH 17/57] Render thread: never skip two presents in a row R4 races present new content every other vblank, so at a saturated 4K widescreen frame two vblanks behind was common and about half the presents were dropped. Skipping is a catch-up step, not a frame-rate policy: one skipped present is always followed by a shown one. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index ba0ebb41..3d21e7a8 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -9285,15 +9285,20 @@ static int rth_record_present(uint16_t op, int n, const int32_t *v) { static void rth_replay_present(const RtCmd *c, const uint8_t *p) { RthOvHdr hd; memcpy(&hd, p, sizeof hd); - if (rt_frames_ahead() >= 2) { + static int skipped_last = 0; + if (rt_frames_ahead() >= 2 && !skipped_last) { /* Two later frames are already recorded, so the emulation thread is * at (or near) the in-flight bound waiting on us. Showing this frame * would only delay those; skip its present (its drawing has been * replayed and stays). One frame behind is the normal pipelined - * state and still presents every frame. */ + * state and still presents every frame, and never two presents in a + * row are skipped, so a saturated render thread keeps showing at + * least every other frame. */ s_rth_presents_stale++; + skipped_last = 1; return; } + skipped_last = 0; const uint8_t *q = p + sizeof hd; memset(&s_rth_ov, 0, sizeof s_rth_ov); s_rth_ov.valid = 1; From 186ceca24489c8ccc149514b93779895d12058a7 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:51:31 -0700 Subject: [PATCH 18/57] Docs: render thread design, sync points, interoperation, tests docs/RENDER_THREAD.md: the record/replay model, why guest-visible VRAM stays gpu.c's array (private upload source on the render thread), the guest state captured at record time, every sync point with file:line, the frame bound and present skipping, interoperation with savestates, netplay, render passes, interpolation, widescreen, resolution changes, screenshots, headless and Vulkan, what is out of scope, and the tests. TCP_COMMANDS.md documents render_thread (index regenerated); config_schema.md documents [video] render_thread. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_THREAD.md | 211 ++++++++++++++++++++++++++++++++++++++++++ docs/TCP_COMMANDS.md | 23 ++++- docs/config_schema.md | 15 +++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 docs/RENDER_THREAD.md diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md new file mode 100644 index 00000000..20cb2838 --- /dev/null +++ b/docs/RENDER_THREAD.md @@ -0,0 +1,211 @@ +# Render thread (`[video] render_thread`) + +Opt-in, OpenGL only, off by default. With it off nothing in this document +runs: `gl_backend_get()` returns the existing table and the GL renderer is +the synchronous path every title already uses. + +```toml +[video] +render_thread = true # game.toml; PSX_RENDER_THREAD=0/1 overrides +``` + +`PSX_RENDER_THREAD_FRAMES=N` (default 2) bounds the closed frames in flight. + +## Why + +Before this change one thread ran the recompiled guest, turned every GP0 +command into GL calls (`gpu_gl_renderer.c`), presented, and paid every +Apple GL-on-Metal stall: command-buffer waits, uploads, readbacks, the +swap. A frame cost `emu + GL`. With the render thread a frame costs +`max(emu, GL)`, and the guest waits for rendering only at the sync points +below. + +## Model + +``` +emulation thread render thread (owns the GL context) +---------------- ----------------------------------- +guest + gpu.c (GP0/GP1, VRAM array, replays records in order through the +status, timing) unchanged glb_* functions; presents + gr_* -> GL_RT_BACKEND (record) ----> ring (32 MiB, SPSC) ----> exec() + present (record + overlay copy) + vblank: frame boundary --------------> frame marker; <= N closed frames + sync point: drain, take context <---- release context, park + ... direct GL calls for the rest of the frame ... + frame boundary: hand context back ---> make current, continue +``` + +Pieces, with file:line on `feat/render-thread`: + +- **Core** — `runtime/src/render_thread.c`, `runtime/include/render_thread.h`. + Single-producer/single-consumer byte ring with monotonic 64-bit positions. + A record never splits across the ring end (a pad record fills the tail); + payloads are copied, and space is freed only after `exec()` returns. No + lock is held while a record executes; a mutex and two condition variables + guard sleeping only (each side raises its `*_sleeping` flag under the lock + before re-checking, the waker publishes its position before reading the + flag). The consumer spins briefly before sleeping; the producer wakes a + sleeping consumer every 128 KiB / 256 records and always at a frame end, + drain or sync point. +- **Frame bound** — `rt_frame_end()` blocks while more than N closed frames + are unconsumed (backpressure). `rt_frames_ahead()` tells the replaying + thread how many complete frames are queued behind the current one. +- **Sync point** — `rt_acquire()`: drain, have the render thread release the + context (`glFlush`, `SDL_GL_MakeCurrent(win, NULL)`), make it current on + the emulation thread. The emulation thread then *holds* the context and + calls the backend directly, exactly as with no render thread, until the + next frame boundary hands it back (`rt_release()`). So a sync point costs + at most one hand-off per frame, and code that is not converted to + recording stays correct. +- **Recording table** — `GL_RT_BACKEND` (`gpu_gl_renderer.c:8463`), selected + by `gl_backend_get()` while the thread runs (`gr_refresh_backend()` in + `gpu_render.c`). Every state call, primitive, fill, copy, CPU->VRAM upload, + single-pixel write and native-wide call is recorded by value; queries are + answered from emulation-side mirrors or are sync points (table below). +- **Hand-off rules** — `gl_renderer_render_thread_frame_boundary()` + (`:8644`), called from `sdl_vblank_present()` after the present and before + wall-clock pacing (`main.cpp:8636`). It releases a held context when the + frame may run asynchronously (`gl_rth_eligible`, `:8167`), takes it when + it may not, then closes the frame. + +### Guest-visible VRAM stays on the emulation thread + +The GL backend is GPU-authoritative: `s_hr_fbo` holds VRAM, gpu.c's array is +refreshed only by readbacks (`ensure_cpu`, `:2394`), and pending uploads +stage from that same array (`flush_cpu_upload`, `:837`). gpu.c writes the +array directly while a GP0(A0) payload streams in (`gpu.c:6912`) and hands +the facade a copy at commit (`gpu.c:3156`). If the render thread staged from +the live array it would upload pixels of a later transfer (and its +`sw_vram_transfer_in` would write stale payloads back over the guest's +array). + +So the backend's `s_vram` is gpu.c's array whenever the emulation thread +runs GL, and a private copy while the render thread does +(`gl_rth_acquire`/`gl_rth_release`, `:8175`/`:8182`; the SW rasterizer's +pointer moves with it, `sw_renderer_rebind_vram`). The private copy gets +each upload payload in command order; a release first copies gpu.c's array +into it, so it matches the guest at every hand-off. Every readback runs on +the emulation thread under a sync point and writes gpu.c's array, as before. + +### Guest state the backend reads while drawing + +| State | Read by | Under the render thread | +|---|---|---| +| per-prim widescreen tags (`psx_ws_prim_in_backdrop`, `psx_ws_prim_is_tagged`) | `bd_prim_gate` `:2527` | captured per primitive in the record flags (`rth_prim_flags`) when native-wide and the backdrop stretch are on; replay reads them via `ctx_prim_*` (`:379`) | +| native-wide fast-path latch (`gpu_ws_background_requires_full_composite`, `gpu_ws_netplay_local_viewport_width`), flat backdrop | `wide_fast_center_valid` `:2563` (also at batch flush and present) | sent as an `RTH_STATE` record before the first call that would see a change | +| depth24 display (`gpu_display_is_depth24`) | `depth24_upload_policy` `:3834` and the depth24 helpers | a depth24 display is a sync point (`rth_record_mode`, `:8196`); the render thread only replays 15-bit frames, so `ctx_depth24()` is 0 there | +| host overlays (OSD, volume, rewind strip, savestate menu) | `gl_swap_with_osd` `:7537` | rasterized and copied at record time (`rth_record_present`, `:8210`); `host_osd_present_done` runs at record time | +| present-shot request | `gl_swap_with_osd` | already mutex-guarded (`main.cpp` `present_shot_take`) | + +Emulation-side answers without a sync point: `gl_renderer_projective_supported` +(`:3848`) and `gl_renderer_present_wide_fbo`'s return value (`:7975`) from a +mirror of the wide-surface bookkeeping (`rth_mirror_wide_for`, `:8115`, the +same lookup, limit check and slot allocation `wide_fbo_for` does), and +`gr_get_draw_area` from the recorded draw area. `gr_scale`, +`gr_texture_filter` and `gl_renderer_fit_wide_aspect` read values that only +change while the emulation thread holds the context. The mirrors are re-read +from the backend at every release. + +## Sync points found in the code + +Guest-visible: + +| Where | Why | +|---|---| +| GPUREAD `gpu_read_gpuread` (`gpu.c:3743`) -> `gr_vram_read` | VRAM->CPU transfer reads the FBO (`glb_vram_read` -> `ensure_cpu`) | +| GP0(A0) with mask check (`gpu.c:6912`) | per-pixel mask test reads VRAM | +| `gpu_vram_peek` (`gpu.c:6889`) | debug/mod VRAM reads | +| savestate / rewind save (`boot_state.c:148,159,189,356`) | `gr_vram_transfer_out` of all VRAM | +| savestate load (`gl_renderer_restage_vram_after_savestate`, `:5106`) | full restage from gpu.c's array | +| depth24 (FMV) display | the CPU mirror is presented; the emulation thread draws those frames | + +Not guest-visible but GL-owned (each is `GL_RT_SYNC` at the top of the entry +point): `gr_render_display*`, `gr_render_wide_display`, `gr_wide_dump_full` +(screenshots, CPU present fallback), every `gl_renderer_pass_*`/`stereo_*` +(render passes), `psx_mod_openxr_*`, texture-bank selection, settings +(scanlines, aspect, gamma, bezel, swap interval, interpolation), the CPU +present / blank / hold-last presents (pause, rewind, netplay hold), and the +debug-server diagnostics (`gl_*` rings, `frame_perf`, `gl_fbo_peek`, +`gl_vram_diff`). The display ring's per-frame readback is queued instead +(`gl_renderer_fbo_peek_deferred`, `:5200`); its readers and the present-image +ring readers sync first (`gl_renderer_render_thread_sync`). + +What is *not* a sync point: a frame of primitives, fills, copies, uploads, +native-wide draws and the 15-bit present (`gl_renderer_present_vram` / +`present_wide_fbo`). A Ridge Racer Type 4 race loaded from a savestate takes +one sync point (the load) and then none. + +## Frames, backpressure, stale presents + +At most N closed frames wait in the ring; past that the emulation thread +blocks in `rt_frame_end`. The render thread must replay every frame's +drawing: later frames sample what earlier frames rendered, so drawing cannot +be skipped. Only the present can. When two newer frames are already recorded +(the emulation thread is at the bound, waiting), the present of the frame +being replayed is skipped (`rth_replay_present`, `:8244`), never twice in a +row, so a saturated render thread still shows at least every other frame. + +Consequence: the render thread takes the GL cost off the guest's frame, but +if GL alone exceeds the frame budget the guest is still held back by the +bound. Choosing an internal resolution that fits is the job of the +resolution controller (roadmap Phase 1.2), not of this layer. + +## Interoperation + +| Feature | Behaviour | +|---|---| +| savestates / rewind | load and save are sync points; the frame stays synchronous, the next frame boundary releases | +| netplay / rollback resimulation | `psx_netplay_active()` or dual-raster makes every frame ineligible: the emulation thread holds the context (synchronous path). Not started when netplay is configured at boot | +| render passes | first `gl_renderer_pass_*` call in a frame is a sync point; the rest of that frame is synchronous. Recommended: leave `render_thread` off with the frame-rate mod | +| frame interpolation | ineligible (held) while enabled; not started when it is on at boot | +| OpenXR | ineligible while a session is active | +| native-wide / widescreen | recorded; tags, latch and wide-surface mirror as above | +| internal resolution changes | `gr_set_scale` and every resolution entry point are sync points | +| screenshots / debug captures | `screenshot*` sync; `present_shot` is fulfilled on the render thread | +| headless | `--headless` (software) never starts it; `--headless-opengl` runs it on the hidden context | +| Vulkan / software | never started (log line says why) | + +## Out of scope + +- Vulkan (the same split would apply; its backend has its own readbacks). +- Converting the remaining sync points to recorded commands (texture banks, + render passes, CPU/hold presents). Each is correct as a sync point. +- Making replay cheaper (batching across records, fewer GL state changes). +- Windows: `render_thread.c` has a Win32 path (SRW lock, condition variable, + `CreateThread`) that is compile-untested; the unit test is POSIX-only. +- Diagnostic rings written by both threads (`latency_ring`, `gpu_timeline`) + can interleave entries; they are diagnostics only. + +## Tests + +- `render_thread_test` (`runtime/tests/test_render_thread.c`): the core + against a fake executor and context token — ordering and payload integrity + over 400 frames of random-size records through ring wraps, oversize + refusal, the in-flight bound under a slow consumer (and that the one- and + two-frames-behind cases both occur), 300 randomized acquire/release rounds + with the context never current on two threads, stop while held. Clean + under ThreadSanitizer. +- `gl_render_thread_test` (`runtime/tests/run_gl_render_thread.py`, + `test_gl_render_thread.c`, label `gpu;opengl;hardware`): a real hidden GL + context, a scripted stream through the facade, render thread off vs on at + 1x and 4x; the values the guest read back, native VRAM, the frame at S and + the native-wide surface must be identical. The stream has mask set/check, + a mask-checked upload (per-pixel readback sync point), back-to-back + uploads of one rect with a catch-up between gpu.c's writes and its commit, + more than the ring's size recorded, readback frames, presents with a slow + render-thread present, and widescreen tags that change per primitive and + are poisoned after each call. It fails for each of these deliberate + mistakes: replay reading the tags live, staging uploads from the guest + array, dropping the stream state. +- `video_enhancement_settings_test`: the key defaults off and parses. +- In game (R4, see the PR): `tools/fp_identity.py` guest identity render + thread off vs on under `--headless-opengl`, and A/B frame rates from a race + savestate at Native, 4K and Match display, 4:3 and widescreen. + +## Debug server + +`{"cmd":"render_thread"}` — active, held, max_frames, records, bytes, +frames produced/consumed, presents and `presents_stale` (skipped), acquires +(sync points) with the 16 most recent reasons and wait times, +backpressure / ring-full waits and time, render busy/idle time, ring high +water. Reading it is not a sync point. diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index d35fc5d4..6a643202 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -194,6 +194,20 @@ re-dispatches the guest's true target. Counters in --- +## `render_thread` — OpenGL render thread state (native only) + +`{"cmd":"render_thread"}` reports the `[video] render_thread` pipeline +(docs/RENDER_THREAD.md): `active`, `held` (the emulation thread holds the GL +context for the rest of this frame), `max_frames`, `records`, `bytes`, +`frames_produced` / `frames_consumed`, `presents` recorded and +`presents_stale` skipped, `acquires` (sync points) with `recent_acquires` +(`frame`, `wait_us`, `reason` — the GL entry point that needed the context), +`backpressure_waits` / `backpressure_ms` (the emulation thread waited on the +in-flight bound), `ring_full_waits`, `acquire_ms`, `render_busy_ms` / +`render_idle_ms` and `ring_high_water`. With the thread off every counter is +0 and `active` is 0. Reading it is not a sync point; a steady game frame +should add no `acquires`. + ## `frame_fingerprint` — per-frame guest-write fingerprint (native only) Cumulative write hashes, snapshotted at every VBlank into a 32768-frame ring @@ -465,9 +479,9 @@ between Play and TCP availability. ## Complete command index (generated) -**352 commands registered** — 339 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**358 commands registered** — 345 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -69 of 352 have prose above; **283 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +72 of 358 have prose above; **286 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -558,6 +572,9 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `dma_trace_dump` | ✓ | | | | `dump_buffer` | ✓ | | | | `dump_ram` | ✓ | ✓ | ✓ | +| `dynres` | ✓ | | | +| `dynres_check` | ✓ | | | +| `dynres_force` | ✓ | | | | `evcb_snapshot` | ✓ | | | | `evcb_walk_dump` | ✓ | | | | `evcb_walk_stats` | ✓ | | | @@ -615,6 +632,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `gte_ring_dump` | ✓ | | | | `gte_state` | ✓ | | | | `guest_tty_dump` | ✓ | | | +| `hd_textures` | ✓ | | ✓ | | `history` | ✓ | ✓ | ✓ | | `hle_dump` | ✓ | | ✓ | | `host_launch_timings` | ✓ | | ✓ | @@ -709,6 +727,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `render_pass_dump` | ✓ | | ✓ | | `render_pass_refuse` | ✓ | | ✓ | | `render_pass_stats` | ✓ | | ✓ | +| `render_thread` | ✓ | | ✓ | | `resident_events` | ✓ | | | | `resident_status` | ✓ | | | | `restore_trace` | ✓ | | | diff --git a/docs/config_schema.md b/docs/config_schema.md index 562265bf..d00d9905 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -638,6 +638,21 @@ Settings surface. A game migrating Skip FMVs into its built-in mod catalog sets it to false. The runtime then hides the Settings row, ignores stale persisted values, and leaves activation to the selected trusted plugin. +### Render thread (`render_thread`, OpenGL) + +```toml +[video] +render_thread = true # default false; PSX_RENDER_THREAD=0/1 overrides +``` + +Runs the OpenGL backend on its own thread: the emulation thread records each +frame's draw work and the render thread replays and presents it, so GL driver +stalls leave the guest's frame. Everything the guest can read back is +identical; a readback, savestate, depth24 (FMV) display, render pass or +setting change makes that frame synchronous. Netplay, frame interpolation, +Vulkan and software keep the synchronous path. `PSX_RENDER_THREAD_FRAMES` +(default 2) bounds the frames in flight. See `docs/RENDER_THREAD.md`. + ### Texture-window batching (`texture_window_batching`, OpenGL) ```toml From e943e28d29fe29776519df56832e78db64dcd06d Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:06:54 -0700 Subject: [PATCH 19/57] Render thread: user-interactive QoS on macOS A default-QoS pthread is scheduled onto efficiency cores when the host is busy, which put the render thread, now on every frame's critical path, behind the emulation thread it is supposed to relieve. Co-Authored-By: Claude Opus 5.5 --- runtime/src/render_thread.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/runtime/src/render_thread.c b/runtime/src/render_thread.c index bdb86e5a..dd7fa91c 100644 --- a/runtime/src/render_thread.c +++ b/runtime/src/render_thread.c @@ -36,6 +36,9 @@ static void cpu_relax(void) { YieldProcessor(); } #else # include # include +# if defined(__APPLE__) +# include +# endif # include typedef pthread_mutex_t rt_mutex; typedef pthread_cond_t rt_cond; @@ -392,7 +395,16 @@ int rt_start(const RtConfig *cfg) { R.thread = CreateThread(NULL, 0, render_main, NULL, 0, NULL); int ok = R.thread != NULL; #else - int ok = pthread_create(&R.thread, NULL, render_main, NULL) == 0; + pthread_attr_t attr; + pthread_attr_init(&attr); +# if defined(__APPLE__) + /* A plain pthread gets the default QoS and, on a busy host, efficiency + * cores. The render thread is on the frame's critical path, like the + * emulation (main) thread, so it asks for the same class. */ + pthread_attr_set_qos_class_np(&attr, QOS_CLASS_USER_INTERACTIVE, 0); +# endif + int ok = pthread_create(&R.thread, &attr, render_main, NULL) == 0; + pthread_attr_destroy(&attr); #endif if (!ok) { atomic_store(&R.running, 0); From 003e1c44b6528f0d891652745a66227d2726c7c4 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:08:47 -0700 Subject: [PATCH 20/57] Docs: render thread QoS note Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_THREAD.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 20cb2838..9605f49f 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -46,7 +46,11 @@ Pieces, with file:line on `feat/render-thread`: before re-checking, the waker publishes its position before reading the flag). The consumer spins briefly before sleeping; the producer wakes a sleeping consumer every 128 KiB / 256 records and always at a frame end, - drain or sync point. + drain or sync point. On macOS the thread is created with + `QOS_CLASS_USER_INTERACTIVE`: with the default QoS a busy host scheduled + it onto efficiency cores and it fell behind the emulation thread (R4 4K + race on a host at load average ~40: 40 Hz with the default class, 60 Hz + with this one). - **Frame bound** — `rt_frame_end()` blocks while more than N closed frames are unconsumed (backpressure). `rt_frames_ahead()` tells the replaying thread how many complete frames are queued behind the current one. From 23270e62a801ff0cdb0ebd29198aef87c51768c1 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:23:40 -0700 Subject: [PATCH 21/57] Test: present-skip guard follows the overlay accessor The OSD query at the two present-skip sites now goes through ov_needs_present(), which falls through to host_osd_needs_present() unless the render thread is replaying a present with its recorded overlay state. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_present_skip_cadence_guard.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/runtime/tests/test_present_skip_cadence_guard.py b/runtime/tests/test_present_skip_cadence_guard.py index 2b90adc8..6f12a26e 100644 --- a/runtime/tests/test_present_skip_cadence_guard.py +++ b/runtime/tests/test_present_skip_cadence_guard.py @@ -31,7 +31,11 @@ assert RENDERER.count("!psx_present_vsync_owns_cadence() &&") == skips, ( "every present-skip site must be gated on psx_present_vsync_owns_cadence(); " "an ungated skip lets the guest free-run when driver vsync owns cadence" ) -assert RENDERER.count("!host_osd_needs_present() &&") == skips +# The OSD query goes through ov_needs_present(), which is host_osd_needs_present() +# except when the render thread replays a present recorded with its own copy +# of the overlay state (docs/RENDER_THREAD.md). +assert RENDERER.count("!ov_needs_present() &&") == skips +assert ": host_osd_needs_present();" in RENDERER assert RENDERER.count("!gl_renderer_interpolation_owns_cadence()) {") == skips # The query is declared beside the pacer it is the XOR partner of, and defined From f9c679e590e0baa73bb056277202e5eccf0bae78 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:33:42 -0700 Subject: [PATCH 22/57] Render thread: queue the headless presented-image capture --headless-opengl captured the presented image into the ring every frame through gl_renderer_capture_display_hires, a sync point, so a headless run (fp_identity) held the context for most of every frame. The capture is now recorded at the same point of the frame (gl_renderer_ring_capture) and done by the render thread; ring readers already sync first. A rect the GL surface cannot serve is skipped there rather than resolved through a CPU readback, and ensure_cpu refuses to run on the render thread at all: its readback would land in the private upload source and mark the CPU side current without gpu.c's array seeing the pixels. Co-Authored-By: Claude Opus 5.5 --- runtime/include/gpu_gl_renderer.h | 2 + runtime/src/gpu_gl_renderer.c | 61 ++++++++++++++++++++++++++++++- runtime/src/main.cpp | 14 +++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 41e29077..817a86f4 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -482,6 +482,8 @@ void gl_renderer_render_thread_frame_boundary(void); void gl_renderer_render_thread_sync(const char *reason); int gl_renderer_render_thread_json(char *out, size_t cap); int gl_renderer_fbo_peek_deferred(int x, int y, int w, int h, uint16_t *out); +void gl_renderer_ring_capture(uint32_t frame, int wide, int base_x, int disp_y, int disp_h, + int cx, int cy, int cw, int ch); #ifdef __cplusplus } diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 3d21e7a8..6dc287dd 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -407,13 +407,14 @@ enum { RTH_OFFSET, RTH_WIDE_CONFIGURE, RTH_WIDE_VIEW, RTH_WIDE_TARGET, RTH_WIDE_DISABLE, RTH_WIDE_CLEAR, RTH_WIDE_CLEAR_MARGINS, RTH_PROJ_TRI, RTH_WIDE_RECOVERY, RTH_INTERP_SUSPENDED, RTH_PRESENT_VRAM, - RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK + RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK, RTH_RING_CAPTURE }; static int rth_record_mode(void); static int rth_rec_ints(uint16_t op, uint16_t flags, int n, const int32_t *v); static uint16_t rth_prim_flags(void); static int rth_record_present(uint16_t op, int n, const int32_t *v); static int rth_mirror_wide_present_ok(int base_x); +static void rth_ring_capture_now(const int32_t *a); /* Emulation-side mirror: the recorded wide target is a live surface. */ static int s_rthm_cur = 0; #define RTH_REC(op, fl, ...) do { const int32_t rth_v_[] = { __VA_ARGS__ }; \ @@ -2599,6 +2600,11 @@ static int cpu_vram_authoritative(void) { static void ensure_cpu(void) { extern int psx_netplay_active(void); if (!s_raster_ok || !s_gpu_dirty) return; + /* Guest-visible readbacks run on the emulation thread under a sync point + * and land in gpu.c's array. On the render thread s_vram is the private + * upload source: reading back there would mark the CPU side current + * without the guest's array ever seeing the pixels. */ + if (rth_replaying()) return; /* Dual-raster / netplay and depth24: CPU VRAM is written on every GP0. * Packed RGB888 movie uploads are deliberately absent from the FBO; * reading it back would overwrite the movie with stale 1555 words. */ @@ -9606,6 +9612,9 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { case RTH_STATE: s_rths_flat_bd = v[0]; s_rths_vp_w = v[1]; s_rths_bg_full = v[2]; break; +#ifndef PSX_NO_DEBUG_TOOLS + case RTH_RING_CAPTURE: rth_ring_capture_now(v); break; +#endif case RTH_PEEK: { uint64_t ptr = (uint64_t)(uint32_t)v[4] | ((uint64_t)(uint32_t)v[5] << 32); (void)gl_renderer_fbo_peek(v[0], v[1], v[2], v[3], (uint16_t *)(uintptr_t)ptr); @@ -9725,4 +9734,54 @@ int gl_renderer_render_thread_json(char *out, size_t cap) { return k; } +#ifndef PSX_NO_DEBUG_TOOLS +/* Presented-image ring capture for --headless-opengl (main.cpp), at this point + * of the command stream: the native-wide surface slice when wide != 0, else + * the display rect at internal resolution. With the render thread recording + * it is queued (ring readers sync first); a rect the GL surface cannot serve + * is skipped there instead of resolving through a CPU readback. */ +static void rth_ring_capture_now(const int32_t *a) { + static uint32_t *buf = NULL; + static size_t cap = 0; + const uint32_t frame = (uint32_t)a[0]; + const int wide = a[1], base_x = a[2], disp_y = a[3], disp_h = a[4]; + const int cx = a[5], cy = a[6], cw = a[7], ch = a[8]; + size_t need = (size_t)1024 * 4 * 512 * 4; + if ((size_t)cw * s_out_scale * ch * s_out_scale > need) + need = (size_t)cw * s_out_scale * ch * s_out_scale; + if (cap < need) { + uint32_t *nb = (uint32_t *)realloc(buf, need * 4u); + if (!nb) return; + buf = nb; cap = need; + } + if (wide) { + int w = 0, h = 0; + if (glb_wide_dump_full(buf, (int)cap, &w, &h, base_x) > 0 && h >= 512) { + const int sc = h / 512, y0 = disp_y * sc, rows = disp_h * sc; + if (y0 + rows <= h) { + present_image_ring_push_argb(frame, buf + (size_t)y0 * w, w, rows, w); + return; + } + } + } + const int w = cw * s_out_scale, h = ch * s_out_scale; + int n; + if (rth_replaying()) + n = (s_raster_ok && !s_depth24_skip_up) + ? gl_read_display_argb(cx, cy, cw, ch, buf, w * 4, INT_MAX, NULL, NULL) : 0; + else + n = gl_renderer_capture_display_hires(buf, w * 4, cx, cy, cw, ch); + if (n == w * h) present_image_ring_push_argb(frame, buf, w, h, w); +} +void gl_renderer_ring_capture(uint32_t frame, int wide, int base_x, int disp_y, int disp_h, + int cx, int cy, int cw, int ch) { + const int32_t a[9] = { (int32_t)frame, wide, base_x, disp_y, disp_h, cx, cy, cw, ch }; + if (rth_record_mode()) { rth_rec_ints(RTH_RING_CAPTURE, 0, 9, a); return; } + GL_RT_SYNC("ring_capture"); + rth_ring_capture_now(a); +} +#else +static void rth_ring_capture_now(const int32_t *a) { (void)a; } +#endif + const GpuRenderBackend *gl_backend_get(void) { return s_rth_on ? &GL_RT_BACKEND : &GL_BACKEND; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0b725fe2..1b284237 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -7837,6 +7837,20 @@ static void headless_present_image_ring_capture(void) { gpu_get_display_info(&di); if (di.disabled || di.width == 0 || di.height == 0 || di.depth24) return; const bool fmv_frame = !g_ws_engaged || gpu_ws_present_native_43() != 0; +#ifndef PSX_SDL_NO_RENDER + /* Render thread: the same capture, queued at this point of the frame + * instead of a per-frame sync point (gl_renderer_ring_capture). */ + if (g_headless_opengl && gl_renderer_render_thread_active()) { + int slot = netplay_local_viewport_slot(); + int cw = slot >= 0 ? (int)di.width / 2 : (int)di.width; + int cx = (int)di.display_x + (slot == 1 ? (int)di.width - cw : 0); + gl_renderer_ring_capture((uint32_t)s_frame_count, + (!fmv_frame && ws_native_wide_active() && gr_wide_supported()) ? 1 : 0, + (int)di.display_x, (int)di.display_y, (int)di.height, + cx, (int)di.display_y, cw, (int)di.height); + return; + } +#endif static std::vector buf; int w = 0, h = 0; uint32_t vx = 0, vy = 0, vw = di.width, vh = di.height; From 91e4e82be5476de7d6a9c395a6d5c4f0a4e22ab4 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:48:49 -0700 Subject: [PATCH 23/57] Docs: queued debug captures, ensure_cpu on the render thread, line refs Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_THREAD.md | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 9605f49f..c91b49af 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -61,22 +61,22 @@ Pieces, with file:line on `feat/render-thread`: next frame boundary hands it back (`rt_release()`). So a sync point costs at most one hand-off per frame, and code that is not converted to recording stays correct. -- **Recording table** — `GL_RT_BACKEND` (`gpu_gl_renderer.c:8463`), selected +- **Recording table** — `GL_RT_BACKEND` (`gpu_gl_renderer.c:8469`), selected by `gl_backend_get()` while the thread runs (`gr_refresh_backend()` in `gpu_render.c`). Every state call, primitive, fill, copy, CPU->VRAM upload, single-pixel write and native-wide call is recorded by value; queries are answered from emulation-side mirrors or are sync points (table below). - **Hand-off rules** — `gl_renderer_render_thread_frame_boundary()` - (`:8644`), called from `sdl_vblank_present()` after the present and before - wall-clock pacing (`main.cpp:8636`). It releases a held context when the - frame may run asynchronously (`gl_rth_eligible`, `:8167`), takes it when + (`:8653`), called from `sdl_vblank_present()` after the present and before + wall-clock pacing (`main.cpp:8650`). It releases a held context when the + frame may run asynchronously (`gl_rth_eligible`, `:8173`), takes it when it may not, then closes the frame. ### Guest-visible VRAM stays on the emulation thread The GL backend is GPU-authoritative: `s_hr_fbo` holds VRAM, gpu.c's array is -refreshed only by readbacks (`ensure_cpu`, `:2394`), and pending uploads -stage from that same array (`flush_cpu_upload`, `:837`). gpu.c writes the +refreshed only by readbacks (`ensure_cpu`, `:2395`), and pending uploads +stage from that same array (`flush_cpu_upload`, `:838`). gpu.c writes the array directly while a GP0(A0) payload streams in (`gpu.c:6912`) and hands the facade a copy at commit (`gpu.c:3156`). If the render thread staged from the live array it would upload pixels of a later transfer (and its @@ -85,7 +85,7 @@ array). So the backend's `s_vram` is gpu.c's array whenever the emulation thread runs GL, and a private copy while the render thread does -(`gl_rth_acquire`/`gl_rth_release`, `:8175`/`:8182`; the SW rasterizer's +(`gl_rth_acquire`/`gl_rth_release`, `:8181`/`:8188`; the SW rasterizer's pointer moves with it, `sw_renderer_rebind_vram`). The private copy gets each upload payload in command order; a release first copies gpu.c's array into it, so it matches the guest at every hand-off. Every readback runs on @@ -95,15 +95,15 @@ the emulation thread under a sync point and writes gpu.c's array, as before. | State | Read by | Under the render thread | |---|---|---| -| per-prim widescreen tags (`psx_ws_prim_in_backdrop`, `psx_ws_prim_is_tagged`) | `bd_prim_gate` `:2527` | captured per primitive in the record flags (`rth_prim_flags`) when native-wide and the backdrop stretch are on; replay reads them via `ctx_prim_*` (`:379`) | -| native-wide fast-path latch (`gpu_ws_background_requires_full_composite`, `gpu_ws_netplay_local_viewport_width`), flat backdrop | `wide_fast_center_valid` `:2563` (also at batch flush and present) | sent as an `RTH_STATE` record before the first call that would see a change | -| depth24 display (`gpu_display_is_depth24`) | `depth24_upload_policy` `:3834` and the depth24 helpers | a depth24 display is a sync point (`rth_record_mode`, `:8196`); the render thread only replays 15-bit frames, so `ctx_depth24()` is 0 there | -| host overlays (OSD, volume, rewind strip, savestate menu) | `gl_swap_with_osd` `:7537` | rasterized and copied at record time (`rth_record_present`, `:8210`); `host_osd_present_done` runs at record time | +| per-prim widescreen tags (`psx_ws_prim_in_backdrop`, `psx_ws_prim_is_tagged`) | `bd_prim_gate` `:2533` | captured per primitive in the record flags (`rth_prim_flags`) when native-wide and the backdrop stretch are on; replay reads them via `ctx_prim_*` (`:379`) | +| native-wide fast-path latch (`gpu_ws_background_requires_full_composite`, `gpu_ws_netplay_local_viewport_width`), flat backdrop | `wide_fast_center_valid` `:2569` (also at batch flush and present) | sent as an `RTH_STATE` record before the first call that would see a change | +| depth24 display (`gpu_display_is_depth24`) | `depth24_upload_policy` `:4096` and the depth24 helpers | a depth24 display is a sync point (`rth_record_mode`, `:8202`); the render thread only replays 15-bit frames, so `ctx_depth24()` is 0 there | +| host overlays (OSD, volume, rewind strip, savestate menu) | `gl_swap_with_osd` `:7543` | rasterized and copied at record time (`rth_record_present`, `:8216`); `host_osd_present_done` runs at record time | | present-shot request | `gl_swap_with_osd` | already mutex-guarded (`main.cpp` `present_shot_take`) | Emulation-side answers without a sync point: `gl_renderer_projective_supported` -(`:3848`) and `gl_renderer_present_wide_fbo`'s return value (`:7975`) from a -mirror of the wide-surface bookkeeping (`rth_mirror_wide_for`, `:8115`, the +(`:3854`) and `gl_renderer_present_wide_fbo`'s return value (`:7981`) from a +mirror of the wide-surface bookkeeping (`rth_mirror_wide_for`, `:8121`, the same lookup, limit check and slot allocation `wide_fbo_for` does), and `gr_get_draw_area` from the recorded draw area. `gr_scale`, `gr_texture_filter` and `gl_renderer_fit_wide_aspect` read values that only @@ -120,7 +120,7 @@ Guest-visible: | GP0(A0) with mask check (`gpu.c:6912`) | per-pixel mask test reads VRAM | | `gpu_vram_peek` (`gpu.c:6889`) | debug/mod VRAM reads | | savestate / rewind save (`boot_state.c:148,159,189,356`) | `gr_vram_transfer_out` of all VRAM | -| savestate load (`gl_renderer_restage_vram_after_savestate`, `:5106`) | full restage from gpu.c's array | +| savestate load (`gl_renderer_restage_vram_after_savestate`, `:5112`) | full restage from gpu.c's array | | depth24 (FMV) display | the CPU mirror is presented; the emulation thread draws those frames | Not guest-visible but GL-owned (each is `GL_RT_SYNC` at the top of the entry @@ -130,9 +130,13 @@ point): `gr_render_display*`, `gr_render_wide_display`, `gr_wide_dump_full` (scanlines, aspect, gamma, bezel, swap interval, interpolation), the CPU present / blank / hold-last presents (pause, rewind, netplay hold), and the debug-server diagnostics (`gl_*` rings, `frame_perf`, `gl_fbo_peek`, -`gl_vram_diff`). The display ring's per-frame readback is queued instead -(`gl_renderer_fbo_peek_deferred`, `:5200`); its readers and the present-image -ring readers sync first (`gl_renderer_render_thread_sync`). +`gl_vram_diff`). Two per-frame debug captures are queued instead of being +sync points: the display ring's readback (`gl_renderer_fbo_peek_deferred`) +and the `--headless-opengl` presented-image capture +(`gl_renderer_ring_capture`); their ring readers sync first +(`gl_renderer_render_thread_sync`). `ensure_cpu` refuses to run on the render +thread: a readback there would land in the private upload source and mark the +CPU side current without gpu.c's array seeing the pixels. What is *not* a sync point: a frame of primitives, fills, copies, uploads, native-wide draws and the 15-bit present (`gl_renderer_present_vram` / @@ -146,7 +150,7 @@ blocks in `rt_frame_end`. The render thread must replay every frame's drawing: later frames sample what earlier frames rendered, so drawing cannot be skipped. Only the present can. When two newer frames are already recorded (the emulation thread is at the bound, waiting), the present of the frame -being replayed is skipped (`rth_replay_present`, `:8244`), never twice in a +being replayed is skipped (`rth_replay_present`, `:8250`), never twice in a row, so a saturated render thread still shows at least every other frame. Consequence: the render thread takes the GL cost off the guest's frame, but @@ -166,7 +170,7 @@ resolution controller (roadmap Phase 1.2), not of this layer. | native-wide / widescreen | recorded; tags, latch and wide-surface mirror as above | | internal resolution changes | `gr_set_scale` and every resolution entry point are sync points | | screenshots / debug captures | `screenshot*` sync; `present_shot` is fulfilled on the render thread | -| headless | `--headless` (software) never starts it; `--headless-opengl` runs it on the hidden context | +| headless | `--headless` (software) never starts it; `--headless-opengl` runs it on the hidden context (no present; the frame boundary still closes every frame), which is what `fp_identity` exercises | | Vulkan / software | never started (log line says why) | ## Out of scope From ac04b5311ec41ca86221b5c2ed8b0543736f0f2f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:49:58 -0700 Subject: [PATCH 24/57] Render thread: no direct GL call while recording, even for empty uploads An empty CPU->VRAM rect fell through to glb_vram_transfer_in on the emulation thread while the render thread owned the context; it is now recorded like any other upload. A projected triangle whose record cannot be reserved takes the context first. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 6dc287dd..ee85bc0d 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -4265,6 +4265,7 @@ void gl_renderer_draw_projected_triangle(const PSXProjectedVertex vertices[3], rt_cmd_commit(); return; } + gl_rth_acquire("projected_oversize"); } PSXProjectedVertex polygon[12]; const double left=fmin(s_area_x1, -wide_dx())-1.0; @@ -9420,12 +9421,12 @@ static void rtb_vram_write(int x, int y, uint16_t px) { RTH_DIRECT_OR(RTH_REC(RTH_VRAM_WRITE, 0, x, y, px)); glb_vram_write(x, y, px); } static uint16_t rtb_vram_read(int x, int y) { GL_RT_SYNC("vram_read"); return glb_vram_read(x, y); } static void rtb_vram_transfer_in(int x, int y, int w, int h, const uint16_t *d) { - if (rth_record_mode() && w > 0 && h > 0) { - size_t n = (size_t)w * (size_t)h; + if (rth_record_mode()) { + size_t n = (w > 0 && h > 0) ? (size_t)w * (size_t)h : 0; int32_t *p = (int32_t *)rt_cmd_begin(RTH_XFER_IN, 0, (uint32_t)(16u + n * 2u)); if (p) { p[0] = x; p[1] = y; p[2] = w; p[3] = h; - memcpy(p + 4, d, n * 2u); + if (n) memcpy(p + 4, d, n * 2u); rt_cmd_commit(); return; } From 779724c427ae7bef2e3cc6f51b275c802c47f51d Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:37:08 -0700 Subject: [PATCH 25/57] tests: build and run the render thread tests on Windows (MinGW) render_thread_test is registered on MinGW (winpthreads provides the fixture's pthreads and nanosleep; the core takes its Win32 path). gl_render_thread_test gains the MinGW link path run_gl_scale_invariance.py uses on test/gl-scale-invariance-windows (static SDL3, opengl32 and its Win32 libraries, LTO, probe.exe), and a host with no window or GL 3.3 core context (headless, Windows over SSH) exits 77 from a preflight run, reported by CTest as a skip. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 12 ++++++---- runtime/tests/run_gl_render_thread.py | 34 +++++++++++++++++++++++---- runtime/tests/test_gl_render_thread.c | 21 +++++++++++++++-- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 3147eaf6..fa08c52a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -214,7 +214,9 @@ if(BUILD_TESTING) # Render thread ([video] render_thread) on vs off: the values the # guest reads back, native VRAM, the frame at S and the native-wide # surface are identical for a scripted stream with readbacks, mask - # bits, uploads, ring wraps and per-primitive widescreen tags. + # bits, uploads, ring wraps and per-primitive widescreen tags. A host + # with no window/GL 3.3 context (headless, Windows over SSH) exits 77: + # reported as skipped. add_test(NAME gl_render_thread_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" @@ -223,7 +225,8 @@ if(BUILD_TESTING) --sdl-include "${_gl_scale_sdl_inc}" --sdl-library "$" --output "${CMAKE_CURRENT_BINARY_DIR}/gl-render-thread-results") - set_tests_properties(gl_render_thread_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300) + set_tests_properties(gl_render_thread_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") @@ -1134,8 +1137,9 @@ if(BUILD_TESTING) # Render thread core ([video] render_thread, docs/RENDER_THREAD.md): # ordering across ring wraps, the frame bound, stale-frame detection and # the drain + context hand-off sync point, against a fake executor. - # The fixture itself uses POSIX threads and nanosleep. - if(NOT WIN32) + # The fixture itself uses POSIX threads and nanosleep (MinGW has both; + # the core takes its Win32 path there). + if(NOT MSVC) add_executable(render_thread_test tests/test_render_thread.c src/render_thread.c) target_include_directories(render_thread_test PRIVATE include) find_package(Threads REQUIRED) diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index c6dc7c82..e78b39fd 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -8,12 +8,16 @@ the stream (its own checks). A third run per scale with one frame in flight (PSX_TEST_RT_FRAMES is not needed: the fixture takes the bound as argv) is not required; the in-flight bound itself is covered by render_thread_test. -macOS/Linux: pass the SDL3 include directory and static library and a C -compiler, as for run_gl_scale_invariance.py. Evidence (commands, output) is -written to receipt.json under --output. +macOS/Linux/Windows (MinGW): pass the SDL3 include directory and static +library and a C compiler, as for run_gl_scale_invariance.py. Evidence +(commands, output) is written to receipt.json under --output. When the host +cannot create a hidden window with a GL 3.3 core context (headless, or Windows +over SSH) the fixture exits SKIP_EXIT and so does this script: CTest reports a +skip, not a pass. """ import argparse import json +import os import pathlib import platform import re @@ -25,6 +29,11 @@ MAC_FRAMEWORKS = ["Cocoa", "OpenGL", "IOKit", "CoreVideo", "CoreAudio", "AudioTo "Carbon", "ForceFeedback", "GameController", "Metal", "QuartzCore", "CoreMedia", "AVFoundation", "Foundation", "CoreHaptics", "UniformTypeIdentifiers"] +# The static SDL3's Win32 dependencies (as run_gl_texture_filter.py links them). +WIN_LIBS = ["opengl32", "kernel32", "user32", "gdi32", "winmm", "imm32", "ole32", + "oleaut32", "version", "uuid", "advapi32", "setupapi", "shell32", "dinput8"] +SKIP_EXIT = 77 +WINDOWS = os.name == "nt" or platform.system().startswith(("MINGW", "MSYS", "CYGWIN")) KEYS = ("rb", "digest", "hires", "wide") @@ -78,6 +87,10 @@ def main(): ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), ("facade", src / "gpu_render.c")] sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] + # MinGW's PE linker reports undefined references from sections it later + # collects, so there LTO drops them first (as run_gl_scale_invariance.py). + if WINDOWS: + sections.append("-flto") objs = [] for name, path in sources: o = dest / (name + ".o") @@ -87,11 +100,15 @@ def main(): print(r.stderr[-3000:]) return 2 objs.append(o) - link = [args.cc, *objs, args.sdl_library, "-o", dest / "probe"] + probe = dest / ("probe.exe" if WINDOWS else "probe") + link = [args.cc, *objs, args.sdl_library, "-o", probe] if platform.system() == "Darwin": for f in MAC_FRAMEWORKS: link += ["-framework", f] link += ["-liconv", "-lm", "-lpthread", "-Wl,-dead_strip"] + elif WINDOWS: + # -static: no libwinpthread/libgcc DLLs needed next to probe.exe. + link += ["-flto", "-static", *["-l" + x for x in WIN_LIBS], "-lm", "-Wl,--gc-sections"] else: link += ["-lGL", "-lm", "-ldl", "-lpthread", "-Wl,--gc-sections"] r = run(link) @@ -100,10 +117,17 @@ def main(): return 2 ok = True + first = True for s in [int(v) for v in args.scales.split(",") if v]: runs = {} for threaded in (0, 1): - r = run([dest / "probe", s, threaded, args.frames]) + r = run([probe, s, threaded, args.frames]) + if first and r.returncode == SKIP_EXIT: + # No window/GL context on this host: skip. Only the first + # run may skip; after it, 77 is a failure like any other. + print("SKIP:", r.stderr.strip()[-600:]) + return SKIP_EXIT + first = False p = parse(r.stdout) tail = r.stdout.strip().splitlines()[-6:] print(f"scale {s} render_thread={threaded}: exit={r.returncode}", tail, diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index e2bb7035..3b73852b 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -210,7 +210,12 @@ int main(int argc, char **argv) { int scale = argc > 1 ? atoi(argv[1]) : 1; int threaded = argc > 2 && argv[2][0] == '1'; int frames = argc > 3 ? atoi(argv[3]) : 80; - if (SDL_Init(SDL_INIT_VIDEO) != 0) return 2; + /* No video device, window or GL context (headless, Windows over SSH): + * exit 77, which the harness reports as a CTest skip. */ + if (SDL_Init(SDL_INIT_VIDEO) != 0) { + fprintf(stderr, "SKIP no video (%s)\n", SDL_GetError()); + return 77; + } SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); @@ -219,7 +224,19 @@ int main(int argc, char **argv) { #endif SDL_Window *win = SDL_CreateWindow("Render thread hidden test", 0, 0, 320, 240, SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); - if (!win) return 2; + if (!win) { fprintf(stderr, "SKIP no window (%s)\n", SDL_GetError()); return 77; } + { /* Probe with the renderer's attributes: only a probe tells "no + * context on this host" from "the renderer's init broke". */ + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + SDL_GLContext probe = SDL_GL_CreateContext(win); + if (!probe) { + fprintf(stderr, "SKIP no GL 3.3 core context (%s)\n", SDL_GetError()); + return 77; + } + SDL_GL_MakeCurrent(win, NULL); + SDL_GL_DeleteContext(probe); + } gr_set_backend(GR_BACKEND_OPENGL); gr_init(vram); gr_set_scale(scale); From dde7b34f951d2750e28d3f7f9ac75f07c97eb63b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:45:45 -0700 Subject: [PATCH 26/57] Docs: the render thread builds and its tests run on Windows (MinGW) Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_THREAD.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index c91b49af..871c4703 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -179,8 +179,6 @@ resolution controller (roadmap Phase 1.2), not of this layer. - Converting the remaining sync points to recorded commands (texture banks, render passes, CPU/hold presents). Each is correct as a sync point. - Making replay cheaper (batching across records, fewer GL state changes). -- Windows: `render_thread.c` has a Win32 path (SRW lock, condition variable, - `CreateThread`) that is compile-untested; the unit test is POSIX-only. - Diagnostic rings written by both threads (`latency_ring`, `gpu_timeline`) can interleave entries; they are diagnostics only. @@ -206,6 +204,10 @@ resolution controller (roadmap Phase 1.2), not of this layer. mistakes: replay reading the tags live, staging uploads from the guest array, dropping the stream state. - `video_enhancement_settings_test`: the key defaults off and parses. +- Windows (MinGW): the runtime builds with the Win32 path of + `render_thread.c` (SRW lock, condition variable, `CreateThread`), and + `render_thread_test` and `gl_render_thread_test` run there (the GL test + exits 77, a CTest skip, on a host with no GL 3.3 context). - In game (R4, see the PR): `tools/fp_identity.py` guest identity render thread off vs on under `--headless-opengl`, and A/B frame rates from a race savestate at Native, 4K and Match display, 4:3 and widescreen. From c0bcb35ab83321547388a3719a0449af4c204642 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:50:16 -0700 Subject: [PATCH 27/57] Regenerate the OpenBIOS emitter stamp Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 52d394aa..e6c3e858 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -98ae5fba10ef4b45d5ce18b0c4af1b637a54135e12f0375d875a7e8e07f57dc7 +1dfc076d921b1165f6390d2926bad58b3c0528cafd25594528d603e1fcdd55bd From 9e8f0170a3abda394107586239a9bf15c589dc10 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:51:43 -0700 Subject: [PATCH 28/57] Test: render thread GL fixture stubs the HD texture pack hooks master's HD texture packs (#557 base) added gpu_hd_textures_* calls to the GL renderer and the gpu_render.c facade; the fixture runs without a pack. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_gl_render_thread.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index 3b73852b..11e79573 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -23,7 +23,17 @@ * surface), and with the thread on its record/acquire counters. * Original source-owned scene; no retail payload. */ #include "gpu_gl_renderer.c" +#include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" +/* gpu_render.c (the facade) observes draws for HD texture packs: no pack here. */ +int gpu_hd_textures_active(void){return 0;} +int gpu_hd_textures_dump_enabled(void){return 0;} +void gpu_hd_textures_observe_draw(uint16_t tp,uint16_t cx,uint16_t cy,const int l[4], + uint32_t w,int semi){(void)tp;(void)cx;(void)cy;(void)l;(void)w;(void)semi;} +void gpu_hd_textures_set_vram(const uint16_t* v){(void)v;} +void gpu_hd_textures_track_upload(int x,int y,int w,int h,const uint16_t* words){ + (void)x;(void)y;(void)w;(void)h;(void)words;} +void gpu_hd_textures_invalidate(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; From 961caac5cb4cf25c9ee5624aa80b99bd49806016 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:29:32 -0700 Subject: [PATCH 29/57] Test: render thread GL fixture stubs the HD upload/copy hooks master's facade now calls gpu_hd_textures_begin_upload/begin_copy/end_copy; the fixture runs without a pack, so it stubs them like the other hooks. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_gl_render_thread.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index 11e79573..8675b2ae 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -34,6 +34,10 @@ void gpu_hd_textures_set_vram(const uint16_t* v){(void)v;} void gpu_hd_textures_track_upload(int x,int y,int w,int h,const uint16_t* words){ (void)x;(void)y;(void)w;(void)h;(void)words;} void gpu_hd_textures_invalidate(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_hd_textures_begin_upload(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_hd_textures_begin_copy(int sx,int sy,int dx,int dy,int w,int h){ + (void)sx;(void)sy;(void)dx;(void)dy;(void)w;(void)h;} +void gpu_hd_textures_end_copy(void){} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; From c1aacbf2b99ec3ee46d589ed461504aa8e8985c0 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:14:38 -0700 Subject: [PATCH 30/57] Dynamic resolution on the render thread: recorded steps, per-frame render cost, render-thread controller Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 9 + runtime/include/dynamic_resolution.h | 115 ++++++++ runtime/include/gpu_gl_renderer.h | 9 + runtime/include/render_thread.h | 6 +- runtime/src/dynamic_resolution.c | 291 ++++++++++++++++++++ runtime/src/gpu_gl_renderer.c | 191 ++++++++++++- runtime/src/main.cpp | 141 +++++++++- runtime/src/render_thread.c | 1 + runtime/tests/test_dynamic_resolution_rt.c | 302 +++++++++++++++++++++ 9 files changed, 1056 insertions(+), 9 deletions(-) create mode 100644 runtime/tests/test_dynamic_resolution_rt.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index fa08c52a..91955388 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1209,6 +1209,15 @@ if(BUILD_TESTING) target_link_libraries(dynamic_resolution_test PRIVATE m) endif() add_test(NAME dynamic_resolution_test COMMAND dynamic_resolution_test) + # The render-thread controller (dynrt_*) against a synthetic pipeline. + add_executable(dynamic_resolution_rt_test + tests/test_dynamic_resolution_rt.c + src/dynamic_resolution.c) + target_include_directories(dynamic_resolution_rt_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(dynamic_resolution_rt_test PRIVATE m) + endif() + add_test(NAME dynamic_resolution_rt_test COMMAND dynamic_resolution_rt_test) # Render-pass time freeze against mock devices (psx_cycles.c only). add_executable(render_pass_freeze_test diff --git a/runtime/include/dynamic_resolution.h b/runtime/include/dynamic_resolution.h index afffd026..fdc90f42 100644 --- a/runtime/include/dynamic_resolution.h +++ b/runtime/include/dynamic_resolution.h @@ -133,6 +133,121 @@ double dynres_predict(const DynresController *c, double load, int from, int to); /* Up steps into `level` blocked for this many seconds from now_s (0 = not). */ double dynres_up_blocked_s(const DynresController *c, int level, double now_s); + +/* ---- Render-thread mode ([video] render_thread on) ------------------------- + * With the render thread the guest's frame no longer pays for GL, so the + * emulation thread's wall time says nothing about whether the resolution + * fits. This controller is fed what the render thread measured instead + * (docs/RENDER_THREAD.md, "Dynamic resolution"): + * + * COST. Per replayed guest frame, max(GPU time, render-thread CPU time): GPU + * time from a GL_TIMESTAMP pair (first record of the frame .. after its + * present), CPU time the render thread spent replaying the frame minus its + * idle waits for records and its block in the swap (the display's vsync). + * load = mean cost / the guest's nominal interval (one display refresh at + * 60 Hz); the budget is 1 - margin (15 %). + * + * BOUND. The render thread is the limit when the emulation thread blocked on + * the queue bound (backpressure) or the cost is over budget. When the guest + * runs below its nominal rate, never waits on the queue and the render + * thread had slack in the actual interval, the guest is the limit: no down + * step (resolution cannot help). Backpressure for bp_strong of a window is a + * strong signal: one window suffices. + * + * RULES (DynrtParams; dynrt_default_params): + * - down: ONE level, after down_windows consecutive over-budget windows (one + * if strong). Never below the floor. + * - judge: the window after a step settles (the cost lags the queue and the + * GPU readback); the next one judges it. A down step that removed less + * than verify_fraction of the predicted cost (and did not end the + * backpressure) is a strike: the window it was decided on may have mixed + * lighter frames in. A second such step in a row (within strike_s) means + * the cost is not the pixels: both are undone and down steps are blocked + * for verify_block_s, doubling per failure up to verify_block_max_s. + * - up: one level, after up_after_s of clean windows (no backpressure, the + * next level predicted at <= up_load), up_cooldown_s after the last step, + * and the level not blocked. The prediction is load * ((1-f) + f*(S'/S)^2) + * with f, the share of the cost that scales with pixels, learned from each + * judged step. down_load > up_load is the hysteresis. + * - relapse: a level reached by an up step and left by a down step within + * relapse_s is blocked for up steps for relapse_block_s, doubling up to + * relapse_block_max_s; holding it relapse_forget_s resets that. + * - holds and gaps discard the window in progress, as in the controller + * above; so does a window in which fewer than min_coverage of the guest + * frames have a measured cost (frames the emulation thread drew itself + * at a sync point). */ +typedef struct DynrtParams { + double window_s; + double margin; /* budget = 1 - margin of the interval */ + int down_windows; /* consecutive over-budget windows */ + double strong_load; /* load at or above: one window suffices */ + double bp_strong; /* backpressure share of wall: strong */ + double bp_weak; /* below: no backpressure */ + double guest_slow; /* interval > period * this: guest slow */ + double guest_slack; /* cost < interval * this: render slack */ + double up_load; + double up_after_s; + double up_cooldown_s; + double verify_fraction; + double verify_block_s, verify_block_max_s, verify_forget_s; + double strike_s; /* a first failed judgement lapses after */ + double relapse_s; + double relapse_block_s, relapse_block_max_s, relapse_forget_s; + double prior_scaled; + double learn_rate; + double gap_factor, gap_hold_s; + double min_coverage; +} DynrtParams; + +void dynrt_default_params(DynrtParams *p); + +typedef struct DynrtSample { + double period_s; /* the guest's nominal interval */ + double wall_s; /* this guest interval's wall time */ + int frames; /* render-thread frames whose cost arrived since the last sample */ + double cost_s; /* their summed cost (see COST) */ + double bp_s; /* emulation thread blocked on the queue bound */ + int held; /* the host holds: not a sample, the window restarts */ +} DynrtSample; + +typedef struct DynrtController { + DynrtParams p; + int floor, ceiling, level, forced; + double f; + /* the window in progress */ + double win_period, win_wall, win_cost, win_bp; + int win_n, win_frames; + /* the last closed window */ + double last_load, last_bp_share, last_hz; + int last_valid, last_guest_bound, last_over; + int over_streak; + double hold_until; + double up_streak_s; + double last_step_t, last_down_t; + double up_reached_t[DYNRES_MAX_LEVEL + 1]; + double up_block_until[DYNRES_MAX_LEVEL + 1]; + double relapse_dur[DYNRES_MAX_LEVEL + 1]; + double down_block_until, verify_dur, verify_last_fail; + int post_active, post_from, post_to, post_windows, post_down; + double post_load_before, post_pred, post_bp_before; + int verify_strikes, strike_from; + double strike_t; + /* telemetry */ + unsigned long long downs, ups, undos, relapses, windows, held_windows, + guest_bound_windows, thin_windows; + const char *last_reason; + double last_decision_t; +} DynrtController; + +void dynrt_init(DynrtController *c, const DynrtParams *p, int floor_level, + int ceiling, int level); +void dynrt_hold(DynrtController *c, double now_s, double tail_s); +/* One guest interval ending at now_s; returns the level to render at. */ +int dynrt_sample(DynrtController *c, double now_s, const DynrtSample *s); +int dynrt_force(DynrtController *c, int level); +double dynrt_predict(const DynrtController *c, double load, int from, int to); +double dynrt_up_blocked_s(const DynrtController *c, int level, double now_s); + #ifdef __cplusplus } #endif diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 817a86f4..0da34dd1 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -335,6 +335,15 @@ typedef struct GlHostLedger { uint64_t interp_presents, swaps; } GlHostLedger; void gl_renderer_host_ledger(GlHostLedger *out); +/* Render thread ([video] render_thread): the cost of each guest frame it + * replayed, max(GPU time, its CPU time without idle waits and the swap), as + * running totals the emulation thread differences. Measured only while + * gl_renderer_render_thread_measure(1) (dynamic resolution on). */ +typedef struct GlRthCosts { + uint64_t frames, cost_ns, cpu_ns, gpu_ns, gpu_frames, dropped; +} GlRthCosts; +void gl_renderer_render_thread_measure(int on); +void gl_renderer_render_thread_costs(GlRthCosts *out); /* Narrow a native-wide display aspect num:den to the widest whose surface * this context can allocate at its internal scale (psx_gl_fit_wide_aspect; diff --git a/runtime/include/render_thread.h b/runtime/include/render_thread.h index 681c5ca8..d57e5772 100644 --- a/runtime/include/render_thread.h +++ b/runtime/include/render_thread.h @@ -23,7 +23,8 @@ * thread at all. * - No lock is held while exec() runs. Locks only guard sleeping/waking. * - * Every function except the rt_on_render_thread() / rt_frames_ahead() + * Every function except the rt_on_render_thread() / rt_frames_ahead() / + * rt_render_idle_ns() * queries is emulation-thread only. */ #ifndef PSX_RENDER_THREAD_H #define PSX_RENDER_THREAD_H @@ -85,6 +86,9 @@ void rt_frame_end(void); /* Render-thread query while executing: how many complete frames are queued * after the frame whose record is being executed (0 = it is the newest). */ int rt_frames_ahead(void); +/* Render-thread query: the consumer's running idle total (waiting for + * records, or parked while the emulation thread holds the context). */ +uint64_t rt_render_idle_ns(void); /* Sync point. Drain, then move the context to the calling thread (held). * reason is a static string kept in the acquire ring. No-op when already held diff --git a/runtime/src/dynamic_resolution.c b/runtime/src/dynamic_resolution.c index c1285cf3..9a7c4978 100644 --- a/runtime/src/dynamic_resolution.c +++ b/runtime/src/dynamic_resolution.c @@ -299,3 +299,294 @@ int dynres_sample(DynresController *c, double now_s, const DynresSample *s) { if (c->win_period >= c->p.window_s - 1e-9) return close_window(c, now_s); return c->level; } + +/* ---- Render-thread mode (see dynamic_resolution.h) ----------------------- */ + +void dynrt_default_params(DynrtParams *p) { + memset(p, 0, sizeof *p); + p->window_s = 0.25; + p->margin = 0.15; + p->down_windows = 2; + p->strong_load = 1.0; + p->bp_strong = 0.05; + p->bp_weak = 0.01; + p->guest_slow = 1.03; + p->guest_slack = 0.90; + p->up_load = 0.70; + p->up_after_s = 3.0; + p->up_cooldown_s = 2.0; + p->verify_fraction = 0.30; + p->verify_block_s = 20.0; + p->verify_block_max_s = 320.0; + p->verify_forget_s = 300.0; + p->strike_s = 2.0; + p->relapse_s = 10.0; + p->relapse_block_s = 10.0; + p->relapse_block_max_s = 160.0; + p->relapse_forget_s = 60.0; + p->prior_scaled = 0.8; + p->learn_rate = 0.3; + p->gap_factor = 4.0; + p->gap_hold_s = 1.0; + p->min_coverage = 0.5; +} + +static int rt_clamp(const DynrtController *c, int l) { + if (l < c->floor) l = c->floor; + if (l > c->ceiling) l = c->ceiling; + return l; +} + +void dynrt_init(DynrtController *c, const DynrtParams *p, int floor_level, + int ceiling, int level) { + memset(c, 0, sizeof *c); + if (p) c->p = *p; else dynrt_default_params(&c->p); + if (ceiling < 1) ceiling = 1; + if (ceiling > DYNRES_MAX_LEVEL) ceiling = DYNRES_MAX_LEVEL; + if (floor_level < 1) floor_level = 1; + if (floor_level > ceiling) floor_level = ceiling; + c->floor = floor_level; + c->ceiling = ceiling; + c->level = rt_clamp(c, level); + c->f = c->p.prior_scaled; + c->last_step_t = c->last_down_t = -1e9; + c->verify_last_fail = -1e9; + c->verify_dur = c->p.verify_block_s; + for (int i = 0; i <= DYNRES_MAX_LEVEL; i++) { + c->up_reached_t[i] = -1e9; + c->relapse_dur[i] = c->p.relapse_block_s; + } + c->last_reason = "start"; +} + +double dynrt_predict(const DynrtController *c, double load, int from, int to) { + if (from < 1 || to < 1) return load; + double q = ((double)to * (double)to) / ((double)from * (double)from); + return load * ((1.0 - c->f) + c->f * q); +} + +double dynrt_up_blocked_s(const DynrtController *c, int level, double now_s) { + if (level < 0 || level > DYNRES_MAX_LEVEL) return 0.0; + double r = c->up_block_until[level] - now_s; + return r > 0.0 ? r : 0.0; +} + +static void rt_window_reset(DynrtController *c) { + c->win_period = c->win_wall = c->win_cost = c->win_bp = 0.0; + c->win_n = c->win_frames = 0; +} + +static void rt_discard(DynrtController *c) { + if (c->win_n) c->held_windows++; + rt_window_reset(c); + c->over_streak = 0; + c->up_streak_s = 0.0; + c->post_active = 0; +} + +void dynrt_hold(DynrtController *c, double now_s, double tail_s) { + double until = now_s + (tail_s > 0.0 ? tail_s : 0.0); + if (until > c->hold_until) c->hold_until = until; + rt_discard(c); +} + +int dynrt_force(DynrtController *c, int level) { + if (level <= 0) { + c->forced = 0; + c->last_reason = "force released"; + rt_discard(c); + return c->level; + } + c->forced = rt_clamp(c, level); + c->level = c->forced; + c->last_reason = "forced"; + rt_discard(c); + return c->level; +} + +static void rt_step(DynrtController *c, double now, int to, int down, double load, + double bp_share, const char *why) { + const int from = c->level; + c->post_active = 1; + c->post_from = from; + c->post_to = to; + c->post_windows = 0; + c->post_down = down; + c->post_load_before = load; + c->post_bp_before = bp_share; + c->post_pred = dynrt_predict(c, load, from, to); + if (down) { + if (now - c->up_reached_t[from] < c->p.relapse_s) { + c->up_block_until[from] = now + c->relapse_dur[from]; + c->relapse_dur[from] *= 2.0; + if (c->relapse_dur[from] > c->p.relapse_block_max_s) + c->relapse_dur[from] = c->p.relapse_block_max_s; + c->relapses++; + } + c->up_reached_t[from] = -1e9; + c->last_down_t = now; + c->downs++; + } else { + c->up_reached_t[to] = now; + c->ups++; + } + c->level = to; + c->last_step_t = now; + c->over_streak = 0; + c->up_streak_s = 0.0; + c->last_reason = why; + c->last_decision_t = now; +} + +static void rt_learn(DynrtController *c, double before, double after, int from, int to) { + if (before <= 0.05 || from == to) return; + double q = ((double)to * (double)to) / ((double)from * (double)from); + double fo = (after / before - 1.0) / (q - 1.0); + if (fo < 0.1) fo = 0.1; + if (fo > 0.95) fo = 0.95; + c->f += c->p.learn_rate * (fo - c->f); +} + +static int rt_close_window(DynrtController *c, double now) { + const double dur = c->win_period; + const int n = c->win_n, frames = c->win_frames; + const double wall = c->win_wall, cost = c->win_cost, bp = c->win_bp; + rt_window_reset(c); + c->windows++; + if (frames < 1 || (double)frames < c->p.min_coverage * (double)n) { + /* Mostly frames the emulation thread drew at a sync point, or the + * costs have not arrived yet: nothing to judge on. */ + c->thin_windows++; + c->over_streak = 0; + c->up_streak_s = 0.0; + c->post_active = 0; + return c->level; + } + const double period = dur / (double)n; + const double load = (cost / (double)frames) / period; + const double bp_share = wall > 0.0 ? bp / wall : 0.0; + const double interval = wall / (double)n; + const double budget = 1.0 - c->p.margin; + c->last_load = load; + c->last_bp_share = bp_share; + c->last_hz = wall > 0.0 ? (double)n / wall : 0.0; + c->last_valid = 1; + const int strong_bp = bp_share >= c->p.bp_strong; + const int no_bp = bp_share < c->p.bp_weak; + /* The guest is the limit: slow, never held by the queue, and the render + * thread had room in the interval it was actually given. */ + const int guest_bound = no_bp && interval > period * c->p.guest_slow && + (cost / (double)frames) < interval * c->p.guest_slack; + c->last_guest_bound = guest_bound; + if (guest_bound) c->guest_bound_windows++; + + /* Judge a recent step: the first window settles, the second judges. */ + if (c->post_active && ++c->post_windows >= 2) { + c->post_active = 0; + const double want = c->post_load_before - c->post_pred; + const double fell = c->post_load_before - load; + const int bp_cleared = c->post_bp_before >= c->p.bp_strong && no_bp; + const int failed = c->post_down && want > 0.0 && + fell < c->p.verify_fraction * want && !bp_cleared; + if (failed && !c->verify_strikes) { + /* One strike: the window before the step may have mixed lighter + * frames in (a scene getting heavier, the cost's lag), so it + * says little. Keep going; a second step that also removes + * nothing is the evidence. */ + c->verify_strikes = 1; + c->strike_from = c->post_from; + c->strike_t = now; + c->last_reason = "down step removed less than predicted (one strike)"; + c->last_decision_t = now; + } else if (failed) { + c->verify_strikes = 0; + if (now - c->verify_last_fail > c->p.verify_forget_s) + c->verify_dur = c->p.verify_block_s; + c->down_block_until = now + c->verify_dur; + c->verify_dur *= 2.0; + if (c->verify_dur > c->p.verify_block_max_s) + c->verify_dur = c->p.verify_block_max_s; + c->verify_last_fail = now; + const int to = rt_clamp(c, c->strike_from > c->post_from ? c->strike_from + : c->post_from); + if (to != c->level) { + c->undos++; + c->level = to; + c->last_step_t = now; + } + c->over_streak = 0; + c->up_streak_s = 0.0; + c->last_reason = "two down steps did not lower the cost: undone, down steps blocked"; + c->last_decision_t = now; + return c->level; + } else { + if (c->post_down) c->verify_strikes = 0; + rt_learn(c, c->post_load_before, load, c->post_from, c->post_to); + } + } + /* A strike is about the steps right after it. */ + if (c->verify_strikes && !c->post_active && now - c->strike_t > c->p.strike_s) + c->verify_strikes = 0; + if (now - c->up_reached_t[c->level] >= c->p.relapse_forget_s) { + c->relapse_dur[c->level] = c->p.relapse_block_s; + c->up_reached_t[c->level] = -1e9; + } + if (c->post_active) return c->level; + + const int over = !guest_bound && (load >= budget || strong_bp); + c->last_over = over; + if (over) { + c->up_streak_s = 0.0; + c->over_streak++; + const int strong = strong_bp || load >= c->p.strong_load; + if (c->level > c->floor && now >= c->down_block_until && + (strong || c->over_streak >= c->p.down_windows)) + rt_step(c, now, c->level - 1, 1, load, bp_share, + strong_bp ? "queue full (render thread behind)" + : strong ? "render cost over the interval" + : "render cost over budget"); + else if (c->level <= c->floor) + c->last_reason = "over budget at the floor"; + return c->level; + } + c->over_streak = 0; + if (guest_bound) c->last_reason = "guest-bound (no step)"; + if (c->level >= c->ceiling) { c->up_streak_s = 0.0; return c->level; } + const int next = c->level + 1; + if (no_bp && dynrt_predict(c, load, c->level, next) <= c->p.up_load) + c->up_streak_s += dur; + else + c->up_streak_s = 0.0; + if (c->up_streak_s >= c->p.up_after_s - 1e-9 && + now - c->last_step_t >= c->p.up_cooldown_s && + now >= c->up_block_until[next]) + rt_step(c, now, next, 0, load, bp_share, "headroom"); + return c->level; +} + +int dynrt_sample(DynrtController *c, double now_s, const DynrtSample *s) { + if (c->forced) return c->level; + if (!s || s->period_s <= 0.0) return c->level; + if (s->held || now_s < c->hold_until) { + if (s->held || c->win_n) rt_discard(c); + return c->level; + } + /* A long interval the queue explains is the render thread being slow, + * not a gap. */ + const double unexplained = s->wall_s - (s->bp_s > 0.0 ? s->bp_s : 0.0); + if (unexplained > s->period_s * c->p.gap_factor) { + dynrt_hold(c, now_s, c->p.gap_hold_s); + c->last_reason = "gap"; + return c->level; + } + c->win_period += s->period_s; + c->win_wall += s->wall_s; + c->win_n++; + if (s->frames > 0 && s->cost_s >= 0.0) { + c->win_frames += s->frames; + c->win_cost += s->cost_s; + } + if (s->bp_s > 0.0) c->win_bp += s->bp_s; + if (c->win_period >= c->p.window_s - 1e-9) return rt_close_window(c, now_s); + return c->level; +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index ee85bc0d..499c0d37 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -108,6 +108,7 @@ #include #include #include +#include #include "png_write.h" /* png_write_rgb — present_shot readback */ #ifndef GL_BGRA @@ -407,9 +408,13 @@ enum { RTH_OFFSET, RTH_WIDE_CONFIGURE, RTH_WIDE_VIEW, RTH_WIDE_TARGET, RTH_WIDE_DISABLE, RTH_WIDE_CLEAR, RTH_WIDE_CLEAR_MARGINS, RTH_PROJ_TRI, RTH_WIDE_RECOVERY, RTH_INTERP_SUSPENDED, RTH_PRESENT_VRAM, - RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK, RTH_RING_CAPTURE + RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK, RTH_RING_CAPTURE, RTH_DYN_STEP, + RTH_FRAME }; static int rth_record_mode(void); +/* Render-thread frame cost (dynamic resolution; defined with GL_RT_BACKEND). */ +static uint64_t s_rthf_swap_ns = 0; /* render thread: time in the swap */ +static uint64_t host_now_ns_rthf(void); static int rth_rec_ints(uint16_t op, uint16_t flags, int n, const int32_t *v); static uint16_t rth_prim_flags(void); static int rth_record_present(uint16_t op, int n, const int32_t *v); @@ -8186,7 +8191,9 @@ static void gl_swap_with_osd(void) { /* Dynamic resolution's ledger: time blocked in the swap is the driver's * vsync wait unless the frame was late (main.cpp). Keep one owned swap. */ uint64_t t0 = s_dyn_on ? SDL_GetPerformanceCounter() : 0; + const uint64_t rthf_t0 = rth_replaying() ? host_now_ns_rthf() : 0; SDL_GL_SwapWindow(s_win); + if (rthf_t0) s_rthf_swap_ns += host_now_ns_rthf() - rthf_t0; if (s_dyn_on) { s_dyn_last_swap_ticks = SDL_GetPerformanceCounter() - t0; s_dyn_ledger.swap_ticks += s_dyn_last_swap_ticks; @@ -8805,6 +8812,7 @@ static void dyn_context_ready(void) { } void gl_renderer_set_dynamic_resolution(int on) { + if (s_ctx) GL_RT_SYNC("set_dynamic_resolution"); s_dyn_on = on ? 1 : 0; if (!s_ctx || !s_raster_ok) return; /* dyn_context_ready decides */ if (s_dyn_on) { @@ -8897,6 +8905,15 @@ static void dyn_rescale_in_place(GLuint fbo, int surf_w, const int (*cols)[2], i p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); } +/* Render thread: the displayed rect a recorded step keeps at full detail, + * captured when the step was recorded (gpu.c's display state is the + * emulation thread's). */ +static int s_dyn_rth_disp_valid = 0, s_dyn_rth_disp[5]; +static atomic_flag s_dyn_stats_lock = ATOMIC_FLAG_INIT; /* s_dyn_stats: render thread writes */ +static void dyn_stats_lock(void) { while (atomic_flag_test_and_set_explicit(&s_dyn_stats_lock, memory_order_acquire)) {} } +static void dyn_stats_unlock(void) { atomic_flag_clear_explicit(&s_dyn_stats_lock, memory_order_release); } +static int s_rthm_scale = 1; /* emulation-side level (recording) */ + static int dyn_apply(int snew) { /* Never in the windowed high-resolution mode (s_hiw): dyn_eligible keeps * s_alloc_scale 0 there; refuse here too rather than step its tiles. */ @@ -8934,7 +8951,14 @@ static int dyn_apply(int snew) { int qn = 0, qr[2][4]; { GpuDisplayInfo di; - gpu_get_display_info(&di); + if (rth_replaying()) { + memset(&di, 0, sizeof di); + di.disabled = !s_dyn_rth_disp_valid || s_dyn_rth_disp[0]; + di.display_x = (uint32_t)s_dyn_rth_disp[1]; di.display_y = (uint32_t)s_dyn_rth_disp[2]; + di.width = (uint32_t)s_dyn_rth_disp[3]; di.height = (uint32_t)s_dyn_rth_disp[4]; + } else { + gpu_get_display_info(&di); + } if (!di.disabled && di.width > 0 && di.height > 0) { qr[qn][0] = (int)di.display_x; qr[qn][1] = (int)di.display_y; qr[qn][2] = (int)di.width; qr[qn][3] = (int)di.height; @@ -9036,6 +9060,7 @@ static int dyn_apply(int snew) { pass_gens_invalidate(); for (int r = 0; r < PRES_ROWS; r++) s_present_dirty[r] = ~0ull; const uint64_t t6 = SDL_GetPerformanceCounter(); + dyn_stats_lock(); s_dyn_stats.steps++; s_dyn_stats.last_from = sold; s_dyn_stats.last_to = snew; @@ -9045,6 +9070,7 @@ static int dyn_apply(int snew) { s_dyn_stats.last_rects_ms = dyn_ms(t3, t4); s_dyn_stats.last_wide_ms = dyn_ms(t4, t5); s_dyn_stats.last_rects = qn; + dyn_stats_unlock(); if (s_dyn_timing) fprintf(stdout, "psxrecomp: dynamic resolution %dx -> %dx in %.2f ms (drain %.2f, " "1x+rects %.2f, reseed %.2f, rects back %.2f, wide %.2f)\n", sold, snew, @@ -9054,16 +9080,37 @@ static int dyn_apply(int snew) { } static void dyn_after_present(void) { + /* Recording: the present was queued, not run; the step it would follow + * is recorded after it (gl_renderer_step_internal_scale_now). */ + if (s_rth_on && !rt_on_render_thread() && !rt_held()) return; if (!s_dyn_pending) return; int s = s_dyn_pending; s_dyn_pending = 0; (void)dyn_apply(s); /* inside a pass: re-queued */ } +/* Render thread: record the step in stream order (after the frame just + * closed, before the next one's first draw); the emulation side's level + * moves at once (s_rthm_scale), the backend's when the step is replayed. + * s_alloc_scale only changes under a sync point. 1 when recorded. */ +static int dyn_record_step(int scale) { + if (!(s_rth_on && !rt_on_render_thread() && !rt_held())) return 0; + if (gpu_display_is_depth24()) { gl_rth_acquire("depth24"); return 0; } + if (scale == s_rthm_scale) return 1; + GpuDisplayInfo di; + gpu_get_display_info(&di); + int32_t a[6] = { scale, di.disabled ? 1 : 0, (int32_t)di.display_x, + (int32_t)di.display_y, (int32_t)di.width, (int32_t)di.height }; + if (!rth_rec_ints(RTH_DYN_STEP, 0, 6, a)) { gl_rth_acquire("dyn_step"); return 0; } + s_rthm_scale = scale; + return 1; +} + int gl_renderer_request_internal_scale(int scale) { if (!s_alloc_scale || !s_raster_ok) return 0; if (scale < 1) scale = 1; if (scale > s_alloc_scale) scale = s_alloc_scale; + if (dyn_record_step(scale)) return 1; s_dyn_pending = scale == s_hr_scale ? 0 : scale; return 1; } @@ -9072,16 +9119,21 @@ int gl_renderer_step_internal_scale_now(int scale) { if (!s_alloc_scale || !s_raster_ok) return 0; if (scale > s_alloc_scale) scale = s_alloc_scale; if (scale < 1) scale = 1; + if (dyn_record_step(scale)) return 1; s_dyn_pending = 0; return dyn_apply(scale) && s_hr_scale == scale; } void gl_renderer_dynres_stats(GlDynresStats *out) { if (!out) return; + dyn_stats_lock(); *out = s_dyn_stats; - out->level = s_raster_ok ? s_hr_scale : 0; + dyn_stats_unlock(); + /* While recording, the level the emulation thread has asked for. */ + const int rec = s_rth_on && !rt_on_render_thread() && !rt_held(); + out->level = !s_raster_ok ? 0 : rec ? s_rthm_scale : s_hr_scale; out->ceiling = gl_renderer_dynamic_resolution_ceiling(); - out->pending = s_dyn_pending; + out->pending = rec ? 0 : s_dyn_pending; } void gl_renderer_host_ledger(GlHostLedger *out) { @@ -9149,6 +9201,7 @@ static uint64_t s_rth_presents = 0, s_rth_presents_stale = 0; #define RTHF_BD_SHIFT 2 /* bits 2-3: psx_ws_prim_in_backdrop() 0..3 */ static void rth_mirror_resync(void) { + s_rthm_scale = s_out_scale; s_rthm_wide_w = g_wide_w; s_rthm_wide_off = g_wide_off; s_rthm_cur = g_wide_cur != 0; @@ -9165,8 +9218,8 @@ static int rth_mirror_wide_for(int base_x) { for (int i = 0; i < WIDE_MAX_SURF; i++) if (s_rthm_base[i] == base_x) return 1; if (s_gl_max_dim > 0 && - ((int64_t)s_rthm_wide_w * s_out_scale > s_gl_max_dim || - (int64_t)VRAM_H * s_out_scale > s_gl_max_dim)) + ((int64_t)s_rthm_wide_w * alloc_scale_for(s_rthm_scale) > s_gl_max_dim || + (int64_t)VRAM_H * alloc_scale_for(s_rthm_scale) > s_gl_max_dim)) return 0; for (int i = 0; i < WIDE_MAX_SURF; i++) if (s_rthm_base[i] < 0) { s_rthm_base[i] = base_x; return 1; } @@ -9326,6 +9379,114 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { s_rth_ov.valid = 0; } + +/* ---- render-thread frame cost (dynamic resolution) ------------------------- + * docs/RENDER_THREAD.md, "Dynamic resolution". Measured on the render thread + * per replayed guest frame, between its first record and its RTH_FRAME + * marker (recorded at the frame boundary): CPU = wall time replaying it minus + * the render thread's idle waits for records and its time in the swap (the + * display's vsync); GPU = a GL_TIMESTAMP pair around the same span, read back + * when available (never waited on). cost = max(CPU, GPU). A frame during + * which the context went to the emulation thread (a sync point) is dropped: + * its span holds the emulation thread's own drawing. Published as running + * totals for the emulation thread (gl_renderer_render_thread_costs). */ +#define RTHF_Q 8 +static _Atomic int s_rthf_want = 0; /* host: measure (dynres active) */ +static int s_rthf_qok = -1; /* render thread: queries usable */ +static GLuint s_rthf_q[RTHF_Q][2]; +static uint64_t s_rthf_slot_cpu[RTHF_Q]; +static unsigned s_rthf_head = 0, s_rthf_tail = 0; +static int s_rthf_open = 0, s_rthf_taint = 0, s_rthf_has_q = 0; +static uint64_t s_rthf_t0 = 0, s_rthf_idle0 = 0, s_rthf_swap0 = 0; +static _Atomic uint64_t s_rthf_frames, s_rthf_cost_ns, s_rthf_cpu_ns, s_rthf_gpu_ns, + s_rthf_gpu_frames, s_rthf_dropped; + +static uint64_t host_now_ns_rthf(void) { + return (uint64_t)((double)SDL_GetPerformanceCounter() * 1.0e9 / + (double)SDL_GetPerformanceFrequency()); +} + +static void rthf_publish(uint64_t cpu, uint64_t gpu, int has_gpu) { + uint64_t cost = has_gpu && gpu > cpu ? gpu : cpu; + atomic_fetch_add(&s_rthf_cpu_ns, cpu); + if (has_gpu) { + atomic_fetch_add(&s_rthf_gpu_ns, gpu); + atomic_fetch_add(&s_rthf_gpu_frames, 1); + } + atomic_fetch_add(&s_rthf_cost_ns, cost); + atomic_fetch_add(&s_rthf_frames, 1); /* last: the totals above are in */ +} + +/* Read every finished query pair, oldest first, without waiting. */ +static void rthf_poll(void) { + while (s_rthf_tail != s_rthf_head) { + const unsigned i = s_rthf_tail % RTHF_Q; + GLuint64 avail = 0, a = 0, b = 0; + p_glGetQueryObjectui64v(s_rthf_q[i][1], GL_QUERY_RESULT_AVAILABLE, &avail); + if (!avail) break; + p_glGetQueryObjectui64v(s_rthf_q[i][0], GL_QUERY_RESULT, &a); + p_glGetQueryObjectui64v(s_rthf_q[i][1], GL_QUERY_RESULT, &b); + rthf_publish(s_rthf_slot_cpu[i], b > a ? (uint64_t)(b - a) : 0, 1); + s_rthf_tail++; + } +} + +static void rthf_begin(void) { + if (s_rthf_qok < 0) { + s_rthf_qok = (p_glGenQueries && p_glQueryCounter && p_glGetQueryObjectui64v && + !(getenv("PSX_DYNRES_GPU_TIMER") && getenv("PSX_DYNRES_GPU_TIMER")[0] == '0')) ? 1 : 0; + if (s_rthf_qok) p_glGenQueries(RTHF_Q * 2, &s_rthf_q[0][0]); + } + s_rthf_open = 1; + s_rthf_taint = 0; + s_rthf_t0 = host_now_ns_rthf(); + s_rthf_idle0 = rt_render_idle_ns(); + s_rthf_swap0 = s_rthf_swap_ns; + s_rthf_has_q = s_rthf_qok && s_rthf_head - s_rthf_tail < RTHF_Q; + if (s_rthf_has_q) p_glQueryCounter(s_rthf_q[s_rthf_head % RTHF_Q][0], GL_TIMESTAMP); +} + +static void rthf_end(void) { + if (!s_rthf_open) return; + s_rthf_open = 0; + if (s_rthf_taint) { + /* The query pair is still issued so the ring stays in order; the + * frame is not published. */ + if (s_rthf_has_q) { + p_glQueryCounter(s_rthf_q[s_rthf_head % RTHF_Q][1], GL_TIMESTAMP); + s_rthf_tail = ++s_rthf_head; /* drop it and anything older */ + } + atomic_fetch_add(&s_rthf_dropped, 1); + return; + } + const uint64_t now = host_now_ns_rthf(); + const uint64_t idle = rt_render_idle_ns() - s_rthf_idle0; + const uint64_t swap = s_rthf_swap_ns - s_rthf_swap0; + const uint64_t span = now - s_rthf_t0; + const uint64_t cpu = span > idle + swap ? span - idle - swap : 0; + if (s_rthf_has_q) { + const unsigned i = s_rthf_head % RTHF_Q; + p_glQueryCounter(s_rthf_q[i][1], GL_TIMESTAMP); + s_rthf_slot_cpu[i] = cpu; + s_rthf_head++; + } else { + rthf_publish(cpu, 0, 0); + } + if (s_rthf_qok > 0) rthf_poll(); +} + +void gl_renderer_render_thread_measure(int on) { atomic_store(&s_rthf_want, on ? 1 : 0); } + +void gl_renderer_render_thread_costs(GlRthCosts *out) { + if (!out) return; + out->frames = atomic_load(&s_rthf_frames); + out->cost_ns = atomic_load(&s_rthf_cost_ns); + out->cpu_ns = atomic_load(&s_rthf_cpu_ns); + out->gpu_ns = atomic_load(&s_rthf_gpu_ns); + out->gpu_frames = atomic_load(&s_rthf_gpu_frames); + out->dropped = atomic_load(&s_rthf_dropped); +} + /* ---- the recording vtable ------------------------------------------------ */ #define RTH_DIRECT_OR(rec) do { if (rth_record_mode()) { rec; return; } } while (0) static void rtb_init(uint16_t *vram) { @@ -9335,7 +9496,11 @@ static void rtb_init(uint16_t *vram) { if (s_rth_on && !rt_held()) sw_renderer_rebind_vram(s_rth_vram_priv); } static void rtb_set_scale(int sc) { GL_RT_SYNC("set_scale"); glb_set_scale(sc); } -static int rtb_scale(void) { return s_out_scale; } /* changes only under a sync point */ +/* The scale changes under a sync point, or by a recorded dynamic-resolution + * step: while recording, the level as of the recording position. */ +static int rtb_scale(void) { + return (s_rth_on && !rt_on_render_thread() && !rt_held()) ? s_rthm_scale : s_out_scale; +} static void rtb_set_texture_filter(int b) { GL_RT_SYNC("texture_filter"); glb_set_texture_filter(b); } static int rtb_texture_filter(void) { return s_tex_filter; } static void rtb_set_semi_transparency(int e, int m) { @@ -9545,6 +9710,8 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { s_rthx_prim = (c->flags & RTHF_PRIM) != 0; s_rthx_tagged = (c->flags & RTHF_TAGGED) != 0; s_rthx_backdrop = (c->flags >> RTHF_BD_SHIFT) & 3; + if (c->op == RTH_FRAME) { rthf_end(); return; } + if (!s_rthf_open && atomic_load_explicit(&s_rthf_want, memory_order_relaxed)) rthf_begin(); switch (c->op) { case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; @@ -9615,6 +9782,12 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { break; #ifndef PSX_NO_DEBUG_TOOLS case RTH_RING_CAPTURE: rth_ring_capture_now(v); break; + case RTH_DYN_STEP: + s_dyn_rth_disp_valid = 1; + memcpy(s_dyn_rth_disp, v + 1, sizeof s_dyn_rth_disp); + (void)dyn_apply(v[0]); + s_dyn_rth_disp_valid = 0; + break; #endif case RTH_PEEK: { uint64_t ptr = (uint64_t)(uint32_t)v[4] | ((uint64_t)(uint32_t)v[5] << 32); @@ -9633,6 +9806,7 @@ static void gl_rth_ctx(void *user, int current) { } else { glFlush(); SDL_GL_MakeCurrent(s_win, NULL); + if (rt_on_render_thread() && s_rthf_open) s_rthf_taint = 1; } } @@ -9699,6 +9873,9 @@ void gl_renderer_render_thread_frame_boundary(void) { } else if (!gl_rth_eligible()) { gl_rth_acquire("ineligible"); } + /* The render thread's per-frame cost (dynamic resolution) ends here. */ + if (!rt_held() && atomic_load_explicit(&s_rthf_want, memory_order_relaxed)) + RTH_REC(RTH_FRAME, 0, 0); rt_frame_end(); } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 1b284237..74e04073 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -12,6 +12,7 @@ #include "window_size.h" /* default game-window size */ #include "internal_resolution.h" /* Settings -> Display -> Internal resolution */ #include "dynamic_resolution.h" /* [video] dynamic_resolution: the step controller */ +#include "render_thread.h" /* rt_get_stats: queue backpressure (dynres) */ #include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */ #include "parity_trace.h" /* general two-process control-flow parity ring */ #include "device_trace.h" /* general two-process device-event cycle ring */ @@ -1319,6 +1320,17 @@ struct DynresHost { bool step_measure_next = false; FILE *trace = nullptr; double t0_s = 0.0; + /* Render-thread mode (dynres_tick_rt): the render thread's per-frame + * costs and the queue's backpressure replace the wall-time model. */ + bool rt_mode = false; + DynrtController rt{}; + GlRthCosts last_costs{}; + uint64_t last_bp_ns = 0; + unsigned long long rt_last_windows = 0; + bool rt_trace_header = false; + double rt_win_cpu_ms = 0.0, rt_win_gpu_ms = 0.0; /* last window's means */ + double rt_acc_cpu = 0.0, rt_acc_gpu = 0.0; + uint64_t rt_acc_frames = 0, rt_acc_gpu_frames = 0; }; static DynresHost g_dynres; /* A savestate load re-stages VRAM and re-anchors pacing: hold for a while. */ @@ -9239,12 +9251,16 @@ static void dynres_setup(void) { DynresParams params; dynres_default_params(¶ms); dynres_init(&g_dynres.ctl, ¶ms, floor_s, ceiling, ceiling); + DynrtParams rtp; + dynrt_default_params(&rtp); + dynrt_init(&g_dynres.rt, &rtp, floor_s, ceiling, ceiling); g_dynres.active = floor_s < ceiling; if (const char* e = std::getenv("PSX_DYNRES_FORCE")) { const int n = std::atoi(e); if (n > 0) { g_dynres.active = true; const int l = dynres_force(&g_dynres.ctl, n); + (void)dynrt_force(&g_dynres.rt, n); (void)gl_renderer_step_internal_scale_now(l); } } @@ -9278,6 +9294,57 @@ static void dynres_apply_level(int level) { } } +/* Render-thread mode: one sample per guest VBlank from what the render + * thread measured since the last one (its frames' costs arrive a few frames + * late) and the time the emulation thread spent blocked on the queue. */ +static void dynres_tick_rt(double now_s, double wall, double period, int held, + double tail, const char *why) { + DynrtController &c = g_dynres.rt; + if (tail > 0.0) dynrt_hold(&c, now_s, tail); + if (why) g_dynres.hold_reason = why; + else if (now_s >= c.hold_until) g_dynres.hold_reason = ""; + GlRthCosts co; + gl_renderer_render_thread_costs(&co); + RtStats rs; + rt_get_stats(&rs); + const GlRthCosts &c0 = g_dynres.last_costs; + const int frames = (int)(co.frames - c0.frames); + const double cost = (double)(co.cost_ns - c0.cost_ns) * 1e-9; + const uint64_t bp_ns = rs.backpressure_ns + rs.ring_full_ns; + const double bp = (double)(bp_ns - g_dynres.last_bp_ns) * 1e-9; + g_dynres.rt_acc_cpu += (double)(co.cpu_ns - c0.cpu_ns) * 1e-6; + g_dynres.rt_acc_gpu += (double)(co.gpu_ns - c0.gpu_ns) * 1e-6; + g_dynres.rt_acc_frames += co.frames - c0.frames; + g_dynres.rt_acc_gpu_frames += co.gpu_frames - c0.gpu_frames; + g_dynres.last_costs = co; + g_dynres.last_bp_ns = bp_ns; + DynrtSample smp{ period, wall, frames, cost, bp, held }; + const int level = dynrt_sample(&c, now_s, &smp); + if (c.windows != g_dynres.rt_last_windows) { + g_dynres.rt_last_windows = c.windows; + g_dynres.rt_win_cpu_ms = g_dynres.rt_acc_frames + ? g_dynres.rt_acc_cpu / (double)g_dynres.rt_acc_frames : 0.0; + g_dynres.rt_win_gpu_ms = g_dynres.rt_acc_gpu_frames + ? g_dynres.rt_acc_gpu / (double)g_dynres.rt_acc_gpu_frames : 0.0; + g_dynres.rt_acc_cpu = g_dynres.rt_acc_gpu = 0.0; + g_dynres.rt_acc_frames = g_dynres.rt_acc_gpu_frames = 0; + if (g_dynres.trace) { + if (!g_dynres.rt_trace_header) { + g_dynres.rt_trace_header = true; + std::fprintf(g_dynres.trace, "# render thread: t_s,level,load,bp_share," + "guest_hz,cpu_ms,gpu_ms,guest_bound,decision\n"); + } + std::fprintf(g_dynres.trace, "%.3f,%d,%.3f,%.3f,%.2f,%.2f,%.2f,%d,%s\n", + now_s - g_dynres.t0_s, c.level, c.last_load, c.last_bp_share, + c.last_hz, g_dynres.rt_win_cpu_ms, g_dynres.rt_win_gpu_ms, + c.last_guest_bound, + c.last_decision_t == now_s ? c.last_reason : ""); + std::fflush(g_dynres.trace); + } + } + dynres_apply_level(level); +} + /* One guest VBlank, after its present. */ static void dynres_tick(void) { if (!g_dynres.active || !g_gl_active) return; @@ -9351,6 +9418,38 @@ static void dynres_tick(void) { why = "savestate load"; g_dynres.hold_request_s = 0.0; } + /* The render thread on: its own model (dynres_tick_rt). The level carries + * over when the mode changes (the render thread starts at the first + * VBlank, after dynres_setup). */ + const bool rt = gl_renderer_render_thread_active() != 0; + if (rt != g_dynres.rt_mode) { + g_dynres.rt_mode = rt; + gl_renderer_render_thread_measure(rt ? 1 : 0); + GlDynresStats st; + gl_renderer_dynres_stats(&st); + if (rt) { + const DynrtParams rp = g_dynres.rt.p; + const int forced = g_dynres.rt.forced; + dynrt_init(&g_dynres.rt, &rp, g_dynres.ctl.floor, g_dynres.ctl.ceiling, st.level); + if (forced) (void)dynrt_force(&g_dynres.rt, forced); + gl_renderer_render_thread_costs(&g_dynres.last_costs); + RtStats rs; + rt_get_stats(&rs); + g_dynres.last_bp_ns = rs.backpressure_ns + rs.ring_full_ns; + } else { + const DynresParams cp = g_dynres.ctl.p; + const int forced = g_dynres.ctl.forced; + const int fl = g_dynres.ctl.floor, ce = g_dynres.ctl.ceiling; + dynres_init(&g_dynres.ctl, &cp, fl, ce, st.level); + if (forced) (void)dynres_force(&g_dynres.ctl, forced); + } + if (tail < 1.0) tail = 1.0; + if (!why) why = rt ? "render thread started" : "render thread stopped"; + } + if (rt) { + dynres_tick_rt(now_s, wall, period, held, tail, why); + return; + } if (tail > 0.0) dynres_hold(&g_dynres.ctl, now_s, tail); if (why) g_dynres.hold_reason = why; else if (now_s >= g_dynres.ctl.hold_until) g_dynres.hold_reason = ""; @@ -9380,7 +9479,8 @@ extern "C" void psx_dynres_summary(int *enabled, int *level, int *floor_s, int * extern "C" int psx_dynres_force(int scale) { if (!g_dynres.active) return -1; - const int l = dynres_force(&g_dynres.ctl, scale); + const int l = g_dynres.rt_mode ? dynrt_force(&g_dynres.rt, scale) + : dynres_force(&g_dynres.ctl, scale); dynres_apply_level(l); return l; } @@ -9388,6 +9488,45 @@ extern "C" int psx_dynres_force(int scale) { extern "C" int psx_dynres_status_json(char *out, int cap) { GlDynresStats st; gl_renderer_dynres_stats(&st); + if (g_dynres.rt_mode) { /* render-thread mode (dynres_tick_rt) */ + const DynrtController &r = g_dynres.rt; + const double now_s = (double)SDL_GetPerformanceCounter() / + (double)SDL_GetPerformanceFrequency(); + char blocked[256]; + int bp = 0; + blocked[0] = 0; + for (int l = r.floor; g_dynres.active && l <= r.ceiling && bp < (int)sizeof blocked - 24; l++) { + double b = dynrt_up_blocked_s(&r, l, now_s); + if (b > 0.0) + bp += std::snprintf(blocked + bp, sizeof blocked - (size_t)bp, "%s\"%d\":%.1f", + bp ? "," : "", l, b); + } + GlRthCosts co; + gl_renderer_render_thread_costs(&co); + const double hold = r.hold_until - now_s; + return std::snprintf(out, (size_t)cap, + "\"mode\":\"render_thread\",\"enabled\":%d,\"requested\":%d,\"ceiling\":%d," + "\"floor\":%d,\"level\":%d,\"internal_lines\":%d,\"forced\":%d,\"load\":%.3f," + "\"budget\":%.3f,\"bp_share\":%.3f,\"guest_hz\":%.2f,\"cpu_ms\":%.3f," + "\"gpu_ms\":%.3f,\"guest_bound\":%d,\"over\":%d,\"scaled_share\":%.3f," + "\"hold\":\"%s\",\"hold_s\":%.2f,\"last_reason\":\"%s\",\"downs\":%llu," + "\"ups\":%llu,\"undos\":%llu,\"relapses\":%llu,\"windows\":%llu," + "\"held_windows\":%llu,\"guest_bound_windows\":%llu,\"thin_windows\":%llu," + "\"steps\":%llu,\"last_from\":%d,\"last_to\":%d,\"last_ms\":%.3f," + "\"frames_measured\":%llu,\"gpu_frames\":%llu,\"frames_dropped\":%llu," + "\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", + g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, + g_dynres.active ? r.floor : 0, st.level, st.level * g_video_ref_lines, r.forced, + r.last_load, 1.0 - r.p.margin, r.last_bp_share, r.last_hz, g_dynres.rt_win_cpu_ms, + g_dynres.rt_win_gpu_ms, r.last_guest_bound, r.last_over, r.f, + g_dynres.hold_reason ? g_dynres.hold_reason : "", hold > 0.0 ? hold : 0.0, + r.last_reason ? r.last_reason : "", r.downs, r.ups, r.undos, r.relapses, + r.windows, r.held_windows, r.guest_bound_windows, r.thin_windows, + (unsigned long long)st.steps, st.last_from, st.last_to, st.last_ms, + (unsigned long long)co.frames, (unsigned long long)co.gpu_frames, + (unsigned long long)co.dropped, + r.down_block_until > now_s ? r.down_block_until - now_s : 0.0, blocked); + } const DynresController &c = g_dynres.ctl; const double now_s = (double)SDL_GetPerformanceCounter() / (double)SDL_GetPerformanceFrequency(); diff --git a/runtime/src/render_thread.c b/runtime/src/render_thread.c index dd7fa91c..7ec395f6 100644 --- a/runtime/src/render_thread.c +++ b/runtime/src/render_thread.c @@ -126,6 +126,7 @@ static __thread int t_render; int rt_running(void) { return atomic_load(&R.running); } int rt_on_render_thread(void) { return t_render; } int rt_held(void) { return R.held; } +uint64_t rt_render_idle_ns(void) { return atomic_load(&R.idle_ns); } static inline uint64_t align_up(uint64_t n) { return (n + (RT_ALIGN - 1)) & ~(uint64_t)(RT_ALIGN - 1); diff --git a/runtime/tests/test_dynamic_resolution_rt.c b/runtime/tests/test_dynamic_resolution_rt.c new file mode 100644 index 00000000..9d425bfc --- /dev/null +++ b/runtime/tests/test_dynamic_resolution_rt.c @@ -0,0 +1,302 @@ +/* The render-thread dynamic-resolution controller (dynamic_resolution.c, + * dynrt_*) against a synthetic two-stage pipeline: the guest takes E per + * frame, the render thread R(S) = a + b * S^2 (S the level), at most two + * frames in flight. A frame's interval is max(period, E, R); when R is the + * slowest stage the guest blocks on the queue for the difference + * (backpressure). Costs reach the controller `lag` frames late, at the level + * the frame was rendered at, like the GPU timestamp readback. + * Checks: overrun -> down one level at a time, headroom -> up only after the + * window, hysteresis (no oscillation), guest-bound -> no step, a down step + * that does not lower the cost -> undone and backed off, queue full -> + * fast down, floor and ceiling, holds. + * Build/run: ctest -R dynamic_resolution_rt_test */ +#include +#include +#include + +#include "dynamic_resolution.h" + +static int failures, checks; +#define CHECK(cond, ...) do { checks++; if (!(cond)) { \ + fprintf(stderr, "FAIL: "); fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); \ + failures++; } } while (0) + +static const double kPeriod = 1.0 / 59.94; +#define LAG 4 + +typedef struct Scene { + double a, b; /* render cost a + b*S^2 (seconds) */ + double emu; /* guest time per frame */ + double flat; /* > 0: render cost independent of S (seconds) */ + double under; /* report cost * (1 - under): a meter that underreads */ + double heavy_from, heavy_to, heavy_a; /* optional time-limited extra cost */ +} Scene; + +static double render_cost(const Scene *sc, int s, double t) { + double r = sc->flat > 0.0 ? sc->flat : sc->a + sc->b * (double)s * (double)s; + if (sc->heavy_to > sc->heavy_from && t >= sc->heavy_from && t < sc->heavy_to) + r += sc->heavy_a; + return r; +} + +typedef struct Run { + double t; + int level, steps, downs, ups; + int min_level, max_level; + double first_down_t, first_up_t; + double time_at[DYNRES_MAX_LEVEL + 1]; + double lagq[LAG]; + int lagn; + double frames; /* guest frames */ +} Run; + +static void run_init(Run *r, int level) { + memset(r, 0, sizeof *r); + r->level = level; + r->min_level = r->max_level = level; + r->first_down_t = r->first_up_t = -1.0; +} + +static void drive(DynrtController *c, Run *r, const Scene *sc, double seconds, + double held_until) { + const double end = r->t + seconds; + while (r->t < end) { + const double R = render_cost(sc, r->level, r->t); + double base = kPeriod > sc->emu ? kPeriod : sc->emu; + double interval = R > base ? R : base; + double bp = R > base ? R - base : 0.0; + r->t += interval; + r->frames += 1.0; + r->time_at[r->level] += interval; + /* the cost of the frame rendered LAG frames ago */ + int frames = 0; + double cost = 0.0; + if (r->lagn == LAG) { + frames = 1; + cost = r->lagq[0] * (1.0 - sc->under); + memmove(r->lagq, r->lagq + 1, sizeof(double) * (LAG - 1)); + r->lagn--; + } + r->lagq[r->lagn++] = R; + DynrtSample s = { kPeriod, interval, frames, cost, bp, r->t < held_until }; + int l = dynrt_sample(c, r->t, &s); + if (l != r->level) { + r->steps++; + if (l < r->level) { r->downs++; if (r->first_down_t < 0) r->first_down_t = r->t; } + else { r->ups++; if (r->first_up_t < 0) r->first_up_t = r->t; } + r->level = l; + if (l < r->min_level) r->min_level = l; + if (l > r->max_level) r->max_level = l; + } + } +} + +static void ctl(DynrtController *c, int floor_l, int ceil_l, int level) { + DynrtParams p; + dynrt_default_params(&p); + dynrt_init(c, &p, floor_l, ceil_l, level); +} + +/* Highest level whose cost fits the 15 % margin. */ +static int fit_level(const Scene *sc, int floor_l, int ceil_l) { + int best = floor_l; + for (int s = floor_l; s <= ceil_l; s++) + if (render_cost(sc, s, 0.0) <= kPeriod * 0.85) best = s; + return best; +} + +/* 1. Overrun at the ceiling: down one level at a time to the highest level + * that fits, fast, then no oscillation. */ +static void test_overrun_steps_down_and_settles(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.003, .b = 0.00030 }; /* 9x: 27.3 ms, 6x: 13.8 ms, 7x: 17.7 ms */ + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 20.0, 0.0); + int want = fit_level(&sc, 3, 9); + CHECK(r.level == want, "overrun: settled at %d, want %d", r.level, want); + CHECK(r.first_down_t >= 0.0 && r.first_down_t < 0.6, + "overrun: first down at %.2f s, want < 0.6 s", r.first_down_t); + CHECK(r.ups == 0 && r.min_level == want, "overrun: never below %d (min %d, ups %d)", + want, r.min_level, r.ups); + /* every step a single level */ + CHECK(r.downs == 9 - want, "overrun: %d downs for %d levels", r.downs, 9 - want); + int steps = r.steps; + drive(&c, &r, &sc, 120.0, 0.0); + CHECK(r.steps == steps, "overrun: %d steps after settling (oscillation)", r.steps - steps); + CHECK(r.time_at[want] > 115.0, "overrun: %.1f s at the settled level", r.time_at[want]); +} + +/* 2. Headroom: up one level, never before up_after_s, up to the ceiling. */ +static void test_headroom_steps_up_after_window(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.001, .b = 0.00005 }; /* 9x: 5 ms */ + ctl(&c, 3, 9, 4); + run_init(&r, 4); + drive(&c, &r, &sc, 2.9, 0.0); + CHECK(r.ups == 0, "headroom: up within %.1f s (%d ups)", 2.9, r.ups); + drive(&c, &r, &sc, 60.0, 0.0); + CHECK(r.first_up_t >= 3.0, "headroom: first up at %.2f s", r.first_up_t); + CHECK(r.level == 9 && r.downs == 0, "headroom: level %d, downs %d", r.level, r.downs); + CHECK(r.ups == 5, "headroom: %d ups for 5 levels (one at a time)", r.ups); +} + +/* 3. Hysteresis band: a level at 0.80 of the interval stays (under the 0.85 + * budget) and the next one (predicted over up_load) is never tried. */ +static void test_hysteresis(void) { + DynrtController c; Run r; + /* 5x: 0.80 * period */ + double target = 0.80 * kPeriod; + Scene sc = { .a = 0.0, .b = target / 25.0 }; + ctl(&c, 3, 9, 5); + run_init(&r, 5); + drive(&c, &r, &sc, 120.0, 0.0); + CHECK(r.steps == 0, "hysteresis: %d steps at a level inside the band", r.steps); +} + +/* 4. Guest-bound: the guest takes 25 ms per frame, the render thread 15.5 ms + * (0.93 of the nominal interval, over the budget). The guest never waits + * on the queue and the render thread has slack in the 25 ms it is given: + * resolution cannot help, so no down step. */ +static void test_guest_bound_no_step(void) { + DynrtController c; Run r; + Scene sc = { .flat = 0.0155, .emu = 0.025 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 60.0, 0.0); + CHECK(r.downs == 0, "guest-bound: %d down steps", r.downs); + CHECK(c.guest_bound_windows > 100, "guest-bound: %llu guest-bound windows", + c.guest_bound_windows); + /* The render cost scales with S here, the guest is still the limit. */ + DynrtController c2; Run r2; + Scene sc2 = { .a = 0.004, .b = 0.00014, .emu = 0.026 }; /* 9x: 15.3 ms */ + ctl(&c2, 3, 9, 9); + run_init(&r2, 9); + drive(&c2, &r2, &sc2, 60.0, 0.0); + CHECK(r2.downs == 0, "guest-bound (scaling): %d down steps", r2.downs); +} + +/* 5. A down step that does not lower the cost (render-thread submission + * bound, not pixels): undone, and down steps back off (doubling). */ +static void test_undo_and_backoff(void) { + DynrtController c; Run r; + Scene sc = { .flat = 0.0175 }; /* 1.05 of the period at every level */ + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 3.0, 0.0); + CHECK(c.undos == 1 && r.level == 9, "undo: undos %llu, level %d after 3 s", c.undos, r.level); + drive(&c, &r, &sc, 117.0, 0.0); + /* two steps per try, blocks of 20, 40, 80 s: at most 3 tries in 120 s */ + CHECK(r.downs <= 6, "back-off: %d down steps in 120 s", r.downs); + CHECK(r.time_at[9] > 112.0, "back-off: %.1f s at the ceiling", r.time_at[9]); + CHECK(r.min_level >= 7, "back-off: one level at a time (min %d)", r.min_level); +} + +/* 6. The queue is full but the meter underreads (load 0.75 reported): the + * backpressure alone steps down, one window each. */ +static void test_queue_full_steps_down(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.003, .b = 0.00030, .under = 0.45 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 0.4, 0.0); + CHECK(r.downs >= 1, "queue full: no down step within 0.4 s"); + drive(&c, &r, &sc, 20.0, 0.0); + CHECK(r.level <= fit_level(&sc, 3, 9) + 1 && r.level >= 3, + "queue full: level %d", r.level); + /* No backpressure left at the level it settled at. */ + CHECK(render_cost(&sc, r.level, 0.0) <= kPeriod + 1e-9, + "queue full: settled at %d still behind", r.level); +} + +/* 7. Floor and ceiling. */ +static void test_floor_and_ceiling(void) { + DynrtController c; Run r; + Scene heavy = { .a = 0.010, .b = 0.0010 }; /* over budget at every level */ + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &heavy, 60.0, 0.0); + CHECK(r.min_level == 3 && r.level == 3, "floor: min %d level %d", r.min_level, r.level); + DynrtController c2; Run r2; + Scene light = { .a = 0.001 }; + ctl(&c2, 3, 9, 9); + run_init(&r2, 9); + drive(&c2, &r2, &light, 60.0, 0.0); + CHECK(r2.max_level == 9 && r2.steps == 0, "ceiling: max %d steps %d", r2.max_level, r2.steps); +} + +/* 8. A heavy stretch: down, then back up once it is over (one level at a + * time, after the window), and no relapse churn. Its first window mixes + * light and heavy frames, so the first step looks like it removed + * nothing: one strike, not an undo. */ +static void test_heavy_stretch_recovers(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.002, .b = 0.00012, /* 9x: 11.7 ms (fits) */ + .heavy_from = 10.0, .heavy_to = 30.0, .heavy_a = 0.008 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 30.0, 0.0); + CHECK(r.level < 9, "stretch: still at %d during the heavy part", r.level); + drive(&c, &r, &sc, 90.0, 0.0); + CHECK(r.level == 9, "stretch: level %d after recovery", r.level); + CHECK(r.downs <= 4, "stretch: %d downs", r.downs); +} + +/* 9. Holds: nothing is sampled or decided while held. */ +static void test_holds(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.003, .b = 0.00030 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 5.0, 5.0); + CHECK(r.steps == 0, "holds: %d steps while held", r.steps); + dynrt_hold(&c, r.t, 1.0); + drive(&c, &r, &sc, 0.9, 0.0); + CHECK(r.steps == 0, "holds: %d steps in the hold tail", r.steps); + drive(&c, &r, &sc, 2.0, 0.0); + CHECK(r.downs >= 1, "holds: no step after the hold"); +} + +/* 10. Missing costs (frames drawn at sync points): thin windows, no step. */ +static void test_thin_windows(void) { + DynrtController c; + ctl(&c, 3, 9, 9); + double t = 0.0; + for (int i = 0; i < 600; i++) { + t += 0.02; + DynrtSample s = { kPeriod, 0.02, 0, 0.0, 0.0, 0 }; + (void)dynrt_sample(&c, t, &s); + } + CHECK(c.level == 9 && c.downs == 0 && c.thin_windows > 10, + "thin: level %d downs %llu thin %llu", c.level, c.downs, c.thin_windows); +} + +/* 11. Force pins the level; release resumes. */ +static void test_force(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.003, .b = 0.00030 }; + ctl(&c, 3, 9, 9); + CHECK(dynrt_force(&c, 7) == 7, "force: level"); + run_init(&r, 7); + drive(&c, &r, &sc, 10.0, 0.0); + CHECK(r.steps == 0, "force: %d steps while pinned", r.steps); + (void)dynrt_force(&c, 0); + drive(&c, &r, &sc, 10.0, 0.0); + CHECK(r.level == fit_level(&sc, 3, 9), "force: released to %d", r.level); +} + +int main(void) { + test_overrun_steps_down_and_settles(); + test_headroom_steps_up_after_window(); + test_hysteresis(); + test_guest_bound_no_step(); + test_undo_and_backoff(); + test_queue_full_steps_down(); + test_floor_and_ceiling(); + test_heavy_stretch_recovers(); + test_holds(); + test_thin_windows(); + test_force(); + printf("dynamic_resolution_rt_test: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} From 6b579a3c46d0b57f5ebe1543651eab18eb43b631 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:18:12 -0700 Subject: [PATCH 31/57] Render-thread frame cost: GL_TIME_ELAPSED (timestamps read 0 on macOS); frame_perf stands aside Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 47 +++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 499c0d37..17733b0a 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -6139,6 +6139,13 @@ static double s_pf_buf_cw[GLPERF_NBUF][5]; static GlPerfSample s_pf_ring[GLPERF_RING]; static uint64_t s_pf_ring_seq = 0; +/* Dynamic resolution's render-thread frame cost owns the TIME_ELAPSED target + * while it measures (rthf_begin): frame_perf stands aside. */ +static int rthf_owns_timer(void); +static void gl_perf_yield(void) { + if (s_pf_scene_active) { p_glEndQuery(GL_TIME_ELAPSED); s_pf_scene_active = 0; } +} + static void gl_perf_init(void) { #ifdef PSX_NO_DEBUG_TOOLS return; @@ -6172,7 +6179,7 @@ static void gl_perf_init(void) { /* Bracket ONE native-wide mirror pass (called from the wide-mirror draw sites). * Timestamp pairs, not TIME_ELAPSED — see the pool comment above. */ static void gl_perf_mirror_begin(void) { - if (!s_pf_on || !s_mq_ok) return; + if (!s_pf_on || !s_mq_ok || rthf_owns_timer()) return; int b = s_pf_b; if (s_mq_n[b] >= GLPERF_MIRQ) { s_mq_over[b]++; return; } p_glQueryCounter(s_mq_q[b][s_mq_n[b] * 2], GL_TIMESTAMP); @@ -6199,7 +6206,7 @@ static void gl_perf_present_enter(void) { s_bdg_applied = 0; s_bdg_prims = 0; s_bdg_clearx = -999999; /* Replay: taken on the emulation thread when the present was recorded. */ { extern void psx_ws_dbg_gate_frame_snapshot(void); if (!rth_replaying()) psx_ws_dbg_gate_frame_snapshot(); } - if (!s_pf_on) return; + if (!s_pf_on || rthf_owns_timer()) return; uint64_t now = SDL_GetPerformanceCounter(); s_pf_enter = now; s_pf_total_pending = s_pf_last_enter @@ -6220,7 +6227,7 @@ static void gl_perf_present_enter(void) { /* End of present (after SwapWindow). wide = native-wide path. */ static void gl_perf_present_exit(int wide) { - if (!s_pf_on) return; + if (!s_pf_on || rthf_owns_timer()) return; uint64_t now = SDL_GetPerformanceCounter(); p_glEndQuery(GL_TIME_ELAPSED); /* end present_q[b] */ s_pf_buf_total[s_pf_b] = s_pf_total_pending; @@ -9385,15 +9392,17 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { * per replayed guest frame, between its first record and its RTH_FRAME * marker (recorded at the frame boundary): CPU = wall time replaying it minus * the render thread's idle waits for records and its time in the swap (the - * display's vsync); GPU = a GL_TIMESTAMP pair around the same span, read back - * when available (never waited on). cost = max(CPU, GPU). A frame during + * display's vsync); GPU = a GL_TIME_ELAPSED query around the same span, read + * back when available (never waited on). cost = max(CPU, GPU). (GL_TIMESTAMP + * counters read 0 on macOS.) Queries of one target cannot nest, so while this + * measures, frame_perf's own TIME_ELAPSED brackets stand aside. A frame during * which the context went to the emulation thread (a sync point) is dropped: * its span holds the emulation thread's own drawing. Published as running * totals for the emulation thread (gl_renderer_render_thread_costs). */ #define RTHF_Q 8 static _Atomic int s_rthf_want = 0; /* host: measure (dynres active) */ static int s_rthf_qok = -1; /* render thread: queries usable */ -static GLuint s_rthf_q[RTHF_Q][2]; +static GLuint s_rthf_q[RTHF_Q]; static uint64_t s_rthf_slot_cpu[RTHF_Q]; static unsigned s_rthf_head = 0, s_rthf_tail = 0; static int s_rthf_open = 0, s_rthf_taint = 0, s_rthf_has_q = 0; @@ -9421,21 +9430,21 @@ static void rthf_publish(uint64_t cpu, uint64_t gpu, int has_gpu) { static void rthf_poll(void) { while (s_rthf_tail != s_rthf_head) { const unsigned i = s_rthf_tail % RTHF_Q; - GLuint64 avail = 0, a = 0, b = 0; - p_glGetQueryObjectui64v(s_rthf_q[i][1], GL_QUERY_RESULT_AVAILABLE, &avail); + GLuint64 avail = 0, ns = 0; + p_glGetQueryObjectui64v(s_rthf_q[i], GL_QUERY_RESULT_AVAILABLE, &avail); if (!avail) break; - p_glGetQueryObjectui64v(s_rthf_q[i][0], GL_QUERY_RESULT, &a); - p_glGetQueryObjectui64v(s_rthf_q[i][1], GL_QUERY_RESULT, &b); - rthf_publish(s_rthf_slot_cpu[i], b > a ? (uint64_t)(b - a) : 0, 1); + p_glGetQueryObjectui64v(s_rthf_q[i], GL_QUERY_RESULT, &ns); + rthf_publish(s_rthf_slot_cpu[i], (uint64_t)ns, 1); s_rthf_tail++; } } static void rthf_begin(void) { if (s_rthf_qok < 0) { - s_rthf_qok = (p_glGenQueries && p_glQueryCounter && p_glGetQueryObjectui64v && + s_rthf_qok = (p_glGenQueries && p_glBeginQuery && p_glEndQuery && + p_glGetQueryObjectui64v && !(getenv("PSX_DYNRES_GPU_TIMER") && getenv("PSX_DYNRES_GPU_TIMER")[0] == '0')) ? 1 : 0; - if (s_rthf_qok) p_glGenQueries(RTHF_Q * 2, &s_rthf_q[0][0]); + if (s_rthf_qok) p_glGenQueries(RTHF_Q, s_rthf_q); } s_rthf_open = 1; s_rthf_taint = 0; @@ -9443,7 +9452,10 @@ static void rthf_begin(void) { s_rthf_idle0 = rt_render_idle_ns(); s_rthf_swap0 = s_rthf_swap_ns; s_rthf_has_q = s_rthf_qok && s_rthf_head - s_rthf_tail < RTHF_Q; - if (s_rthf_has_q) p_glQueryCounter(s_rthf_q[s_rthf_head % RTHF_Q][0], GL_TIMESTAMP); + if (s_rthf_has_q) { + gl_perf_yield(); /* close frame_perf's open bracket, if any */ + p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[s_rthf_head % RTHF_Q]); + } } static void rthf_end(void) { @@ -9453,7 +9465,7 @@ static void rthf_end(void) { /* The query pair is still issued so the ring stays in order; the * frame is not published. */ if (s_rthf_has_q) { - p_glQueryCounter(s_rthf_q[s_rthf_head % RTHF_Q][1], GL_TIMESTAMP); + p_glEndQuery(GL_TIME_ELAPSED); s_rthf_tail = ++s_rthf_head; /* drop it and anything older */ } atomic_fetch_add(&s_rthf_dropped, 1); @@ -9466,7 +9478,7 @@ static void rthf_end(void) { const uint64_t cpu = span > idle + swap ? span - idle - swap : 0; if (s_rthf_has_q) { const unsigned i = s_rthf_head % RTHF_Q; - p_glQueryCounter(s_rthf_q[i][1], GL_TIMESTAMP); + p_glEndQuery(GL_TIME_ELAPSED); s_rthf_slot_cpu[i] = cpu; s_rthf_head++; } else { @@ -9476,6 +9488,9 @@ static void rthf_end(void) { } void gl_renderer_render_thread_measure(int on) { atomic_store(&s_rthf_want, on ? 1 : 0); } +static int rthf_owns_timer(void) { + return s_rth_on && atomic_load_explicit(&s_rthf_want, memory_order_relaxed) && s_rthf_qok > 0; +} void gl_renderer_render_thread_costs(GlRthCosts *out) { if (!out) return; From 281b451ef7d8b3f2fad2375719c98fe7f6dfe600 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:23:17 -0700 Subject: [PATCH 32/57] Render-thread dynres: cut the soft hold tails to half a second Co-Authored-By: Claude Opus 5.5 --- runtime/src/main.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 74e04073..08adf8fa 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9413,8 +9413,11 @@ static void dynres_tick(void) { } } #endif + /* Tails that only cover the wall-time model's settling (a savestate + * load re-anchors pacing, the game's first seconds). */ + bool soft_tail = !held && tail > 0.0 && why && std::strcmp(why, "game entry") == 0; if (g_dynres.hold_request_s > 0.0) { - if (g_dynres.hold_request_s > tail) tail = g_dynres.hold_request_s; + if (g_dynres.hold_request_s > tail) { tail = g_dynres.hold_request_s; soft_tail = !held; } why = "savestate load"; g_dynres.hold_request_s = 0.0; } @@ -9447,6 +9450,11 @@ static void dynres_tick(void) { if (!why) why = rt ? "render thread started" : "render thread stopped"; } if (rt) { + /* The render thread's per-frame cost and the queue need no settling + * beyond the frames in flight: a soft tail is cut to half a second, + * so a scene that is too heavy right after a load is not left + * running slow for seconds. */ + if (soft_tail && tail > 0.5) tail = 0.5; dynres_tick_rt(now_s, wall, period, held, tail, why); return; } From b24ce9d3ab722691294b3ff7723421aa39276792 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:44:54 -0700 Subject: [PATCH 33/57] Dynamic resolution on the render thread: replay recorded steps in every build; GL equivalence test steps the level The step record's replay sat inside the debug-tools block, so release builds dropped every recorded step. gl_render_thread_test gains a dynres mode: the same level steps between frames with the thread off (applied at once) and on (recorded), identical guest readbacks, VRAM, frame and wide surface, and the render thread's frame cost measured. Also drop the duplicate gpu_ws_background_requires_full_composite stub the #508 merge left in the scale-invariance fixture. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 2 +- runtime/tests/run_gl_render_thread.py | 18 ++++++++++ runtime/tests/test_gl_render_thread.c | 48 ++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 17733b0a..f7be6c58 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -9797,13 +9797,13 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { break; #ifndef PSX_NO_DEBUG_TOOLS case RTH_RING_CAPTURE: rth_ring_capture_now(v); break; +#endif case RTH_DYN_STEP: s_dyn_rth_disp_valid = 1; memcpy(s_dyn_rth_disp, v + 1, sizeof s_dyn_rth_disp); (void)dyn_apply(v[0]); s_dyn_rth_disp_valid = 0; break; -#endif case RTH_PEEK: { uint64_t ptr = (uint64_t)(uint32_t)v[4] | ((uint64_t)(uint32_t)v[5] << 32); (void)gl_renderer_fbo_peek(v[0], v[1], v[2], v[3], (uint16_t *)(uintptr_t)ptr); diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index e78b39fd..e338ba7f 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -140,6 +140,24 @@ def main(): print(f"FAIL scale {s}: {k} differs with the render thread on " f"({runs[0][k]} off, {runs[1][k]} on)") ok = False + # Dynamic resolution: the same level steps between frames, recorded with + # the thread on (scales above 1 only; 1x has no levels below it). + for s in [int(v) for v in args.scales.split(",") if v and int(v) > 1]: + runs = {} + for threaded in (0, 1): + r = run([probe, s, threaded, args.frames, "dynres"]) + p = parse(r.stdout) + tail = r.stdout.strip().splitlines()[-7:] + print(f"scale {s} dynres render_thread={threaded}: exit={r.returncode}", tail, + r.stderr.strip()[-800:]) + if r.returncode or p["failures"] != 0 or any(p[k] is None for k in KEYS): + ok = False + runs[threaded] = p + for k in KEYS: + if runs[0][k] != runs[1][k]: + print(f"FAIL scale {s} dynres: {k} differs with the render thread on " + f"({runs[0][k]} off, {runs[1][k]} on)") + ok = False print("PASS" if ok else "FAIL") return 0 if ok else 1 diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index 8675b2ae..7ab17992 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -18,6 +18,13 @@ * stream latch that flips mid-frame (sent as state); * - presents and frame boundaries every frame, with readbacks (GPUREAD * style gr_vram_read, VRAM->CPU transfer_out) in some frames only. + * With argv[4] == "dynres" ([video] dynamic_resolution) the surfaces are + * allocated at the scale and the level steps between frames + * (gl_renderer_step_internal_scale_now after a frame boundary, as the + * controller does): recorded in stream order with the thread on, run at once + * with it off, or at once when the frame is held at a sync point; the + * render thread's frame cost is measured too. Back at the ceiling before the + * final readbacks. * Printed: rb (digest of every value the "guest" read back), digest (native * VRAM at the end), hires (frame at internal resolution), wide (native-wide * surface), and with the thread on its record/acquire counters. @@ -224,6 +231,7 @@ int main(int argc, char **argv) { int scale = argc > 1 ? atoi(argv[1]) : 1; int threaded = argc > 2 && argv[2][0] == '1'; int frames = argc > 3 ? atoi(argv[3]) : 80; + int dynres = argc > 4 && !strcmp(argv[4], "dynres"); /* No video device, window or GL context (headless, Windows over SSH): * exit 77, which the harness reports as a CTest skip. */ if (SDL_Init(SDL_INIT_VIDEO) != 0) { @@ -254,6 +262,7 @@ int main(int argc, char **argv) { gr_set_backend(GR_BACKEND_OPENGL); gr_init(vram); gr_set_scale(scale); + if (dynres) gl_renderer_set_dynamic_resolution(1); gl_renderer_set_swap_interval(0); if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } check(gr_backend() == GR_BACKEND_OPENGL, "OpenGL backend"); @@ -263,7 +272,44 @@ int main(int argc, char **argv) { check(gl_renderer_render_thread_start(2) == 1, "render thread started"); check(gl_renderer_render_thread_active(), "render thread active"); } - for (int f = 0; f < frames; f++) frame(f, (f & 1) ? 512 : 0); + if (dynres) { + check(gl_renderer_dynamic_resolution_ceiling() == scale, "dynres ceiling"); + if (threaded) gl_renderer_render_thread_measure(1); + } + static const int levels[] = { 2, 1, 3, 1, 4, 2 }; + int nsteps = 0, level = scale; + for (int f = 0; f < frames; f++) { + frame(f, (f & 1) ? 512 : 0); + if (dynres && f % 7 == 6) { /* includes frames held at a sync point */ + int l = levels[(f / 7) % 6]; + if (l > scale) l = scale; + if (l != level) nsteps++; + level = l; + check(gl_renderer_step_internal_scale_now(l) == 1, "dynres step"); + GlDynresStats ds; + gl_renderer_dynres_stats(&ds); + check(ds.level == l, "dynres level as asked"); + check(gr_scale() == l, "gr_scale follows the level"); + } + } + if (dynres) { + if (level != scale) nsteps++; + check(gl_renderer_step_internal_scale_now(scale) == 1, "dynres back to the ceiling"); + gl_renderer_render_thread_sync("test"); + GlDynresStats ds; + gl_renderer_dynres_stats(&ds); + printf("dyn_steps=%llu asked=%d\n", (unsigned long long)ds.steps, nsteps); + check(ds.steps == (uint64_t)nsteps && ds.level == scale, "every step applied"); + if (threaded) { + GlRthCosts co; + gl_renderer_render_thread_costs(&co); + printf("rth_cost_frames=%llu gpu_frames=%llu dropped=%llu mean_ms=%.3f\n", + (unsigned long long)co.frames, (unsigned long long)co.gpu_frames, + (unsigned long long)co.dropped, + co.frames ? (double)co.cost_ns / (double)co.frames * 1e-6 : 0.0); + check(co.frames > 0, "render-thread frame costs measured"); + } + } /* Guest-visible VRAM, the frame at internal resolution, the wide surface. */ static uint16_t native[1024 * 512]; From 966be30a53bb2edaba32a6c79b2cf513e67f2fa6 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:45:44 -0700 Subject: [PATCH 34/57] Docs: dynamic resolution on the render thread Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_THREAD.md | 83 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 871c4703..9a6422f9 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -155,8 +155,8 @@ row, so a saturated render thread still shows at least every other frame. Consequence: the render thread takes the GL cost off the guest's frame, but if GL alone exceeds the frame budget the guest is still held back by the -bound. Choosing an internal resolution that fits is the job of the -resolution controller (roadmap Phase 1.2), not of this layer. +bound. Choosing an internal resolution that fits is the job of dynamic +resolution (below), not of this layer. ## Interoperation @@ -168,11 +168,76 @@ resolution controller (roadmap Phase 1.2), not of this layer. | frame interpolation | ineligible (held) while enabled; not started when it is on at boot | | OpenXR | ineligible while a session is active | | native-wide / widescreen | recorded; tags, latch and wide-surface mirror as above | -| internal resolution changes | `gr_set_scale` and every resolution entry point are sync points | +| internal resolution changes | `gr_set_scale` and every resolution entry point are sync points; dynamic-resolution level steps are recorded (below) | | screenshots / debug captures | `screenshot*` sync; `present_shot` is fulfilled on the render thread | | headless | `--headless` (software) never starts it; `--headless-opengl` runs it on the hidden context (no present; the frame boundary still closes every frame), which is what `fp_identity` exercises | | Vulkan / software | never started (log line says why) | +## Dynamic resolution (`[video] dynamic_resolution`) + +Opt-in as before (`dynamic_resolution = true`, `dynamic_resolution_min`; +`PSX_DYNRES=0/1`, `PSX_DYNRES_MIN` for one run); no new keys. The surfaces +are allocated at the configured internal resolution (the ceiling, e.g. Match +display) and the level steps between that and the minimum. With the render +thread off nothing changes: the emulation thread's wall-time controller +(`dynres_*` in `dynamic_resolution.c`) decides. With it on, the guest's +frame no longer pays for GL, so its wall time says nothing about whether the +resolution fits; a second controller (`dynrt_*`, same file) takes over when +the render thread starts and is fed what the render thread measured. The one +target is every guest frame at the guest's rate (60 on NTSC); resolution is +the only lever. + +**Cost.** Per replayed guest frame, between its first record and an +`RTH_FRAME` marker the frame boundary records: the render thread's CPU time +(wall time minus its idle waits for records and its time in the swap, the +display's vsync) and the GPU time of the same span (a `GL_TIME_ELAPSED` +query, read back when available, never waited on; `GL_TIMESTAMP` counters +read 0 on macOS). cost = max(CPU, GPU), published as running totals +(`gl_renderer_render_thread_costs`). A frame during which the context went +to the emulation thread is dropped (its span holds the emulation thread's +drawing). While this measures, `frame_perf`'s own TIME_ELAPSED brackets +stand aside (queries of one target cannot nest). `PSX_DYNRES_GPU_TIMER=0` +leaves CPU time only. + +**Decisions** (0.25 s windows of guest time; `dynamic_resolution.h` has the +full rules and `dynrt_default_params` the numbers): + +- load = mean cost / the guest's nominal interval (one display refresh at + 60 Hz). Budget 0.85 (15 % margin). +- Down, one level, after two consecutive windows over budget, or one window + in which the emulation thread spent 5 % or more of the time blocked on the + queue bound (backpressure: the render thread is behind) or the cost was + over the whole interval. Never below the minimum. +- Guest-bound: when the guest ran below its rate, never waited on the queue + and the render thread had slack in the interval it was given, the guest is + the limit and no down step is taken. +- Judged: the window after a step settles (the cost lags the queue and the + query readback), the next one judges it. A down step that removed less than + 30 % of the predicted cost (and did not end the backpressure) is a strike; + a second in a row means the cost is not the pixels: both steps are undone + and down steps are blocked for 20 s, doubling to 320 s. +- Up, one level, after 3 s of windows with no backpressure and the next level + predicted at 0.70 or less, 2 s after the last step, never above the + ceiling. The prediction (load × ((1−f) + f·(S′/S)²)) learns f from each + judged step. A level reached by an up step and left within 10 s is blocked + for up steps, 10 s doubling to 160 s. +- Holds as before (FMV, fast-forward, overlay compiles, resim, resizes, + boot); the savestate-load and game-entry tails, which only covered the + wall-time model's settling, are cut to 0.5 s. + +**Steps** are recorded (`RTH_DYN_STEP`) after the frame just closed, with the +displayed rect captured at record time, and applied by the render thread in +stream order (`dyn_apply`, the same reseed as without the thread). The +emulation side's level (`gr_scale`, the wide-surface mirror, +`gl_renderer_dynres_stats`) moves at record time. A frame the emulation +thread holds steps directly, as without the thread. + +**Telemetry.** `{"cmd":"dynres"}` reports `"mode":"render_thread"`, level, +load, budget, `bp_share`, `guest_hz`, `cpu_ms`/`gpu_ms` (last window's +means), `guest_bound`, the step counters and `up_blocked`. +`PSX_DYNRES_TRACE=` writes one line per window +(`t_s,level,load,bp_share,guest_hz,cpu_ms,gpu_ms,guest_bound,decision`). + ## Out of scope - Vulkan (the same split would apply; its backend has its own readbacks). @@ -204,6 +269,18 @@ resolution controller (roadmap Phase 1.2), not of this layer. mistakes: replay reading the tags live, staging uploads from the guest array, dropping the stream state. - `video_enhancement_settings_test`: the key defaults off and parses. +- `dynamic_resolution_rt_test` (`test_dynamic_resolution_rt.c`): the + render-thread controller against a synthetic two-stage pipeline (guest E, + render a + b·S², two frames in flight, costs four frames late): overrun -> + one level at a time to the highest level that fits, then no oscillation; + headroom -> up only after the window; the hysteresis band; guest-bound -> + no step; a cost that does not scale -> two strikes, undo, back-off; queue + full with an under-reading meter -> down; floor and ceiling; a heavy + stretch whose first window is mixed -> one strike, not an undo; holds; + thin windows; pins. +- `gl_render_thread_test` dynres mode: the same level steps between frames + with the thread off (applied at once) and on (recorded, and directly in + held frames) give identical readbacks, VRAM, frame and wide surface. - Windows (MinGW): the runtime builds with the Win32 path of `render_thread.c` (SRW lock, condition variable, `CreateThread`), and `render_thread_test` and `gl_render_thread_test` run there (the GL test From 5488489273e11de57385997d288aeedb766a91e6 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:25:41 -0700 Subject: [PATCH 35/57] Dynamic resolution: allocate native-wide surfaces at the current level A native-wide surface was allocated at the dynamic-resolution ceiling and rendered into its lower-left corner. On GL-on-Metal every render pass loads and stores the whole attachment, so the wide surfaces cost the ceiling's bandwidth at every level. Allocate them at the level they render at and reallocate at each step, rescaling the presented rows and margins from the old surface. The limit check stays at the ceiling. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_THREAD.md | 12 ++- runtime/include/gpu_gl_renderer.h | 1 + runtime/src/gpu_gl_renderer.c | 120 ++++++++++++++++------- runtime/src/main.cpp | 4 +- runtime/tests/test_gl_scale_invariance.c | 20 ++-- 5 files changed, 110 insertions(+), 47 deletions(-) diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 9a6422f9..5e95b5c1 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -176,9 +176,15 @@ resolution (below), not of this layer. ## Dynamic resolution (`[video] dynamic_resolution`) Opt-in as before (`dynamic_resolution = true`, `dynamic_resolution_min`; -`PSX_DYNRES=0/1`, `PSX_DYNRES_MIN` for one run); no new keys. The surfaces -are allocated at the configured internal resolution (the ceiling, e.g. Match -display) and the level steps between that and the minimum. With the render +`PSX_DYNRES=0/1`, `PSX_DYNRES_MIN` for one run); no new keys. The hr +(VRAM) surface is allocated at the configured internal resolution (the +ceiling, e.g. Match display) and the level steps between that and the +minimum. The native-wide surfaces are allocated at the current level and +reallocated at every step (the presented rows and margins rescaled from the +old surface into the new one): on Apple's GL-on-Metal every render pass into +an attachment loads and stores the whole attachment, so a wide surface held +at the ceiling cost the ceiling's bandwidth at every level (R4 2P VS: about +4 s at 35-40 Hz after the race starts at a 10x ceiling, see the PR). With the render thread off nothing changes: the emulation thread's wall-time controller (`dynres_*` in `dynamic_resolution.c`) decides. With it on, the guest's frame no longer pays for GL, so its wall time says nothing about whether the diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 0da34dd1..f97dbd49 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -323,6 +323,7 @@ typedef struct GlDynresStats { * the reseed, the rects back, the native-wide surfaces. */ double last_ms, last_prep_ms, last_seed_ms, last_rects_ms, last_wide_ms; int last_rects; /* rects kept at full detail */ + uint64_t wide_reallocs; /* native-wide surfaces reallocated at a step */ } GlDynresStats; void gl_renderer_dynres_stats(GlDynresStats *out); /* Host time the renderer spent, as running totals in performance-counter diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index f7be6c58..aba12ac0 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -449,11 +449,11 @@ static int s_hr_scale = 1; /* Dynamic internal resolution ([video] dynamic_resolution; the step block * near the end of this file). 0 = off, and then every surface is allocated at * the scale it renders at, exactly as before. Otherwise the scale the hr - * surface and the native-wide surfaces were allocated at (the ceiling): the - * current level (s_hr_scale == s_out_scale, never above it) renders into - * their lower-left VRAM*S corner. Only allocation and the present's - * normalised UVs read it; everything else keeps taking the two scales above - * at use. */ + * surface was allocated at (the ceiling): the current level (s_hr_scale == + * s_out_scale, never above it) renders into its lower-left VRAM*S corner. + * Only allocation and the present's normalised UVs read it; everything else + * keeps taking the two scales above at use. Native-wide surfaces are + * allocated at the level instead (s_wide_as) and reallocated at each step. */ static int s_alloc_scale = 0; /* The scale a surface rendering at `cur` is (to be) allocated at. */ static inline int alloc_scale_for(int cur) { @@ -827,6 +827,12 @@ static int s_wide_base[WIDE_MAX_SURF]; /* base_x per surface (-1 = free) * /* Native rows [y0, y1) each surface has presented (dynamic resolution: the * rows a scale step rescales; y1 <= y0 = none yet, then all of them). */ static int s_dyn_wide_y0[WIDE_MAX_SURF], s_dyn_wide_y1[WIDE_MAX_SURF]; +/* The scale each surface is allocated at. Dynamic resolution allocates a wide + * surface at the level it renders at (not the ceiling) and reallocates it at + * every step: on Apple's GL-on-Metal every render pass into an attachment + * loads and stores the whole attachment, so a surface allocated at the + * ceiling costs the ceiling's bandwidth at every level. */ +static int s_wide_as[WIDE_MAX_SURF]; static int g_wide_w = 0; /* wide width (native px); 0 = disabled */ static int g_wide_off = 0; /* centering OFFSET (native px) */ static GLuint g_wide_cur = 0; /* active mirror FBO (0 = no mirror) */ @@ -5782,11 +5788,43 @@ static void wide_free_all(void) { if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } if (s_wide_rb[i]) { p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; } s_wide_base[i] = -1; + s_wide_as[i] = 0; wst_clear(i); } g_wide_cur = 0; } +/* Create a native-wide surface (colour texture, depth-stencil RB, FBO) at + * scale S, cleared to black with a zero stencil. 0 on failure (nothing left + * allocated). */ +static int wide_alloc_surface(int S, GLuint *tex, GLuint *rb, GLuint *fbo) { + int w = g_wide_w * S, h = VRAM_H * S; + s_cw_fbo_creates++; + *tex = make_tex(GL_RGBA8, w, h, GL_RGBA, GL_UNSIGNED_BYTE); + /* Depth-stencil RB, same as the hr FBO: the stencil carries the PSX + * mask-bit mirror for the wide surface, and (the hard lesson) a + * stencil-less FBO turns every stencil-enabled mirror draw into ~0.6ms of + * driver-side work — the 16:9 GL perf collapse. */ + p_glGenRenderbuffers(1, rb); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, *rb); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, w, h); + if (!make_fbo(fbo, *tex, *rb)) { + glDeleteTextures(1, tex); *tex = 0; + p_glDeleteRenderbuffers(1, rb); *rb = 0; + *fbo = 0; + return 0; + } + /* Clear to black so unwritten margins are clean (not stale). */ + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, *fbo); + glDisable(GL_SCISSOR_TEST); + glClearColor(0, 0, 0, 0); + glClearStencil(0); + glStencilMask(0xFF); + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + return 1; +} + /* Lazily allocate (or find) the wide FBO+tex for base_x. Returns the FBO id, or * 0 on failure / more distinct buffers than WIDE_MAX_SURF. */ static GLuint wide_fbo_for(int base_x) { @@ -5801,8 +5839,8 @@ static GLuint wide_fbo_for(int base_x) { * Refuse that with one log line instead of failing mid-draw: the frame * then presents 4:3 through the CPU path at 1x, without the native-wide * margins. */ - /* Dynamic resolution: allocated at the ceiling (alloc_scale_for), like - * the hr surface, so a step never reallocates it. */ + /* Dynamic resolution: the limit is checked at the ceiling + * (alloc_scale_for), which every level may step up to. */ const int AS = alloc_scale_for(s_out_scale); if (s_gl_max_dim > 0 && ((int64_t)g_wide_w * AS > s_gl_max_dim || @@ -5817,29 +5855,13 @@ static GLuint wide_fbo_for(int base_x) { } for (int i = 0; i < WIDE_MAX_SURF; i++) { if (!s_wide_fbo[i]) { - int w = g_wide_w * AS, h = VRAM_H * AS; - s_cw_fbo_creates++; - s_wide_tex[i] = make_tex(GL_RGBA8, w, h, GL_RGBA, GL_UNSIGNED_BYTE); - /* Depth-stencil RB, same as the hr FBO: the stencil carries the - * PSX mask-bit mirror for the wide surface, and (the hard lesson) - * a stencil-less FBO turns every stencil-enabled mirror draw into - * ~0.6ms of driver-side work — the 16:9 GL perf collapse. */ - p_glGenRenderbuffers(1, &s_wide_rb[i]); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_wide_rb[i]); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, w, h); - if (!make_fbo(&s_wide_fbo[i], s_wide_tex[i], s_wide_rb[i])) { - glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; - p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; + /* Allocated at the level it renders at; a dynamic-resolution step + * reallocates it (dyn_apply). The limit above is checked at the + * ceiling, so no step can exceed it. */ + if (!wide_alloc_surface(s_out_scale, &s_wide_tex[i], &s_wide_rb[i], + &s_wide_fbo[i])) return 0; - } - /* Clear to black so unwritten margins are clean (not stale). */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_wide_fbo[i]); - glDisable(GL_SCISSOR_TEST); - glClearColor(0, 0, 0, 0); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + s_wide_as[i] = s_out_scale; s_wide_base[i] = base_x; s_dyn_wide_y0[i] = s_dyn_wide_y1[i] = 0; /* nothing presented yet */ wst_clear(i); /* colour and stencil cleared together */ @@ -8599,9 +8621,10 @@ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) } if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; GLuint fbo = 0, tex = 0; + int wide_as = 0; for (int i = 0; i < WIDE_MAX_SURF; i++) if (s_wide_fbo[i] && s_wide_base[i] == disp_x) { - fbo = s_wide_fbo[i]; tex = s_wide_tex[i]; + fbo = s_wide_fbo[i]; tex = s_wide_tex[i]; wide_as = s_wide_as[i]; if (s_alloc_scale) { /* the rows a scale step rescales */ int y0 = disp_y < 0 ? 0 : disp_y; int y1 = disp_y + disp_h > VRAM_H ? VRAM_H : disp_y + disp_h; @@ -8661,8 +8684,10 @@ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) s_last_dw = g_wide_w; s_last_dh = disp_h; return 1; } - present_target_quad(tex, present_alloc_extent(g_wide_w, s_out_scale), - present_alloc_extent(VRAM_H, s_out_scale), + /* The surface is allocated at its own scale (s_wide_as), the level. */ + const float wext = (wide_as > s_out_scale && s_out_scale >= 1) + ? (float)wide_as / (float)s_out_scale : 1.0f; + present_target_quad(tex, (float)g_wide_w * wext, (float)VRAM_H * wext, 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh, 1, 1, s_out_scale); pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); gpu_timeline_note(GTL_PRESENT, GTL_PATH_WIDE_FBO, @@ -9044,8 +9069,35 @@ static int dyn_apply(int snew) { if (!s_wide_fbo[i]) continue; int y0 = s_dyn_wide_y0[i], y1 = s_dyn_wide_y1[i]; if (y1 <= y0) { y0 = 0; y1 = VRAM_H; } - dyn_rescale_in_place(s_wide_fbo[i], g_wide_w, (const int (*)[2])cols, nc, - y0, y1, sold, snew); + /* Reallocated at the new level (s_wide_as): the same rows and + * columns rescaled from the old surface into a cleared new one. + * If the allocation fails, a step down rescales in place as + * before and the surface keeps its larger allocation; a step up + * past it drops the surface (recreated at the next target). */ + GLuint ntex = 0, nrb = 0, nfbo = 0; + if (wide_alloc_surface(snew, &ntex, &nrb, &nfbo)) { + for (int c = 0; c < nc; c++) + if (cols[c][1] > cols[c][0]) + dyn_rescale_rect(s_wide_tex[i], cols[c][0] * sold, y0 * sold, sold, + nfbo, cols[c][0], y0, cols[c][1] - cols[c][0], + y1 - y0, snew); + if (g_wide_cur == s_wide_fbo[i]) g_wide_cur = nfbo; + p_glDeleteFramebuffers(1, &s_wide_fbo[i]); + glDeleteTextures(1, &s_wide_tex[i]); + p_glDeleteRenderbuffers(1, &s_wide_rb[i]); + s_wide_fbo[i] = nfbo; s_wide_tex[i] = ntex; s_wide_rb[i] = nrb; + s_wide_as[i] = snew; + s_dyn_stats.wide_reallocs++; + } else if (snew <= s_wide_as[i]) { + dyn_rescale_in_place(s_wide_fbo[i], g_wide_w, (const int (*)[2])cols, nc, + y0, y1, sold, snew); + } else { + if (g_wide_cur == s_wide_fbo[i]) g_wide_cur = 0; + p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; + glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; + p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; + s_wide_base[i] = -1; s_wide_as[i] = 0; + } wst_all(i); /* colour only */ } } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 08adf8fa..13006f74 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9556,7 +9556,7 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { "\"held_windows\":%llu,\"steps\":%llu,\"deferred\":%llu,\"last_from\":%d," "\"last_to\":%d,\"last_ms\":%.3f,\"last_interval_ms\":%.3f,\"last_prep_ms\":%.3f," "\"last_seed_ms\":%.3f,\"last_rects_ms\":%.3f,\"last_wide_ms\":%.3f," - "\"step_cost_ms\":%.3f,\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", + "\"wide_reallocs\":%llu,\"step_cost_ms\":%.3f,\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, g_dynres.active ? c.floor : 0, st.level, st.level * g_video_ref_lines, c.forced, c.last_load, c.last_late, c.last_vblank_hz, c.f, @@ -9565,7 +9565,7 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { c.windows, c.held_windows, (unsigned long long)st.steps, (unsigned long long)st.deferred, st.last_from, st.last_to, st.last_ms, g_dynres.step_interval_ms, st.last_prep_ms, st.last_seed_ms, st.last_rects_ms, - st.last_wide_ms, c.step_cost_s * 1000.0, + st.last_wide_ms, (unsigned long long)st.wide_reallocs, c.step_cost_s * 1000.0, c.down_block_until > now_s ? c.down_block_until - now_s : 0.0, blocked); } diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index e5611f81..2fa85ede 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -62,8 +62,9 @@ * gl_renderer_pass_begin opens nothing there: a pass backs up and restores * only the authoritative surface, never the window's tiles. * - mode "steps" (dynamic resolution, argv: scale = the ceiling, then a - * comma list of levels, then "fresh" or "chain"): the surfaces stay - * allocated at the ceiling and the scale steps at run time. + * comma list of levels, then "fresh" or "chain"): the hr surface stays + * allocated at the ceiling, the native-wide surface at the current level + * (reallocated at each step), and the scale steps at run time. * "fresh" steps before anything is drawn, then renders the scene: the * runner requires the native digest, the frame at S and the wide surface * to equal the fixed-scale run at the final level (rendering into the @@ -978,15 +979,17 @@ static uint64_t steps_wide_digest(uint32_t **keep) { return d; } -/* The native-wide surface is allocated at the ceiling, whatever the level - * when it was created: a later step up must not outgrow it. */ -static void steps_check_wide_alloc(int ceiling) { +/* The native-wide surface is allocated at the level it renders at (not the + * ceiling, whose size every render pass would load and store on + * GL-on-Metal), and a step reallocates it at the new level. */ +static void steps_check_wide_alloc(int level) { GLint tw = 0, th = 0; glBindTexture(GL_TEXTURE_2D, s_wide_tex[0]); glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &tw); glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &th); glBindTexture(GL_TEXTURE_2D, 0); - check(tw == 426 * ceiling && th == 512 * ceiling, "wide surface allocated at the ceiling"); + check(tw == 426 * level && th == 512 * level && s_wide_as[0] == level, + "wide surface allocated at the level"); } static int steps_main(int ceiling, int window, const char *chain, int fresh) { @@ -1018,7 +1021,7 @@ static int steps_main(int ceiling, int window, const char *chain, int fresh) { uint64_t digest = steps_native_digest(1); uint64_t hires = steps_hires_digest(0, 0, FRAME_W, FRAME_H); steps_wide_part(); - steps_check_wide_alloc(ceiling); /* created below the ceiling */ + steps_check_wide_alloc(s_out_scale); /* created below the ceiling */ uint64_t wide = steps_wide_digest(NULL); check(glGetError() == GL_NO_ERROR, "GL error"); printf("level=%d\n", s_out_scale); @@ -1030,7 +1033,7 @@ static int steps_main(int ceiling, int window, const char *chain, int fresh) { } scene(); steps_wide_part(); - steps_check_wide_alloc(ceiling); + steps_check_wide_alloc(ceiling); /* the level is the ceiling here */ /* A back buffer with its own content, the draw area while stepping. */ glb_set_draw_area(0, STEPS_BACK_Y, FRAME_W - 1, STEPS_BACK_Y + FRAME_H - 1); glb_wide_disable_target(); @@ -1062,6 +1065,7 @@ static int steps_main(int ceiling, int window, const char *chain, int fresh) { char label[128]; snprintf(label, sizeof label, "step %dx -> %dx applied", sold, snew); check(ok && s_out_scale == snew && s_hr_scale == snew, label); + steps_check_wide_alloc(snew); GlDynresStats st; gl_renderer_dynres_stats(&st); uint64_t d1 = steps_native_digest(0); From 5d5c60657d00212b99ac059300efb0e1a05e0656 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:47:28 -0700 Subject: [PATCH 36/57] Dynamic resolution: allocate the hr surface at the current level too The hr (VRAM) surface stayed allocated at the ceiling. On GL-on-Metal every render pass loads and stores the whole attachment, so its cost followed the ceiling rather than the level: R4 2P VS at 5x ran 40-45 Hz for its first seconds with a 10x ceiling and 60 Hz with surfaces allocated at 5x. Allocate a new hr surface at each step and reseed it from the 1x image and the saved rects; a failed allocation falls back to the in-place step down, or refuses a step up. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_THREAD.md | 22 ++++--- runtime/include/gpu_gl_renderer.h | 2 +- runtime/src/gpu_gl_renderer.c | 80 +++++++++++++++++++++--- runtime/src/main.cpp | 4 +- runtime/tests/test_gl_scale_invariance.c | 21 ++++--- 5 files changed, 99 insertions(+), 30 deletions(-) diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 5e95b5c1..617ebdcc 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -176,15 +176,19 @@ resolution (below), not of this layer. ## Dynamic resolution (`[video] dynamic_resolution`) Opt-in as before (`dynamic_resolution = true`, `dynamic_resolution_min`; -`PSX_DYNRES=0/1`, `PSX_DYNRES_MIN` for one run); no new keys. The hr -(VRAM) surface is allocated at the configured internal resolution (the -ceiling, e.g. Match display) and the level steps between that and the -minimum. The native-wide surfaces are allocated at the current level and -reallocated at every step (the presented rows and margins rescaled from the -old surface into the new one): on Apple's GL-on-Metal every render pass into -an attachment loads and stores the whole attachment, so a wide surface held -at the ceiling cost the ceiling's bandwidth at every level (R4 2P VS: about -4 s at 35-40 Hz after the race starts at a 10x ceiling, see the PR). With the render +`PSX_DYNRES=0/1`, `PSX_DYNRES_MIN` for one run); no new keys. The level +steps between the configured internal resolution (the ceiling, e.g. Match +display, which the GPU limit and memory budget are checked against) and the +minimum. Every surface (hr VRAM and native-wide) is allocated at the current +level and reallocated at each step: the hr surface is reseeded into the new +allocation from the 1x image plus the displayed rect and draw area at full +detail, the wide surfaces' presented rows and margins are rescaled into +theirs. On Apple's GL-on-Metal every render pass into an attachment loads and +stores the whole attachment, so surfaces held at the ceiling cost the +ceiling's bandwidth at every level; with a 10x ceiling, R4's 2P VS start +(which needs about 4x) ran at 35-40 Hz for its first seconds at any level. +If a new allocation fails, a step down rescales in place inside the old +allocation and a step up past it is refused. With the render thread off nothing changes: the emulation thread's wall-time controller (`dynres_*` in `dynamic_resolution.c`) decides. With it on, the guest's frame no longer pays for GL, so its wall time says nothing about whether the diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index f97dbd49..d403dffc 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -323,7 +323,7 @@ typedef struct GlDynresStats { * the reseed, the rects back, the native-wide surfaces. */ double last_ms, last_prep_ms, last_seed_ms, last_rects_ms, last_wide_ms; int last_rects; /* rects kept at full detail */ - uint64_t wide_reallocs; /* native-wide surfaces reallocated at a step */ + uint64_t hr_reallocs, wide_reallocs; /* surfaces reallocated at a step */ } GlDynresStats; void gl_renderer_dynres_stats(GlDynresStats *out); /* Host time the renderer spent, as running totals in performance-counter diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index aba12ac0..c119b58f 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -449,11 +449,14 @@ static int s_hr_scale = 1; /* Dynamic internal resolution ([video] dynamic_resolution; the step block * near the end of this file). 0 = off, and then every surface is allocated at * the scale it renders at, exactly as before. Otherwise the scale the hr - * surface was allocated at (the ceiling): the current level (s_hr_scale == - * s_out_scale, never above it) renders into its lower-left VRAM*S corner. - * Only allocation and the present's normalised UVs read it; everything else - * keeps taking the two scales above at use. Native-wide surfaces are - * allocated at the level instead (s_wide_as) and reallocated at each step. */ + * ceiling: the highest level (the scale the surfaces were first allocated + * at, which the GPU limit and memory budget were checked against). Every + * surface is allocated at the current level (s_hr_scale == s_out_scale, + * never above the ceiling) and reallocated at each step (dyn_apply): on + * Apple's GL-on-Metal every render pass into an attachment loads and stores + * the whole attachment, so a surface held at the ceiling cost the ceiling's + * bandwidth at every level. s_hr_alloc and s_wide_as hold the allocations + * (above the level only where a reallocation failed). */ static int s_alloc_scale = 0; /* The scale a surface rendering at `cur` is (to be) allocated at. */ static inline int alloc_scale_for(int cur) { @@ -652,6 +655,9 @@ static int s_selected_bank_live_clut, s_tb_bank_live_clut; /* Authoritative VRAM: hr color texture + stencil (mask bit) FBO. */ static GLuint s_hr_tex = 0, s_hr_fbo = 0, s_hr_rb = 0; +/* The scale s_hr_tex/s_hr_rb are allocated at (dynamic resolution + * reallocates them at every step; see s_alloc_scale). */ +static int s_hr_alloc = 0; /* Native raw-1555 sampling mirror + readback source. */ static GLuint s_raw_tex = 0, s_raw_fbo = 0; /* CPU->VRAM upload staging (native RGBA8). */ @@ -4808,6 +4814,38 @@ static int make_fbo(GLuint *out_fbo, GLuint color_tex, GLuint stencil_rb) { return 1; } +/* A new hr surface (colour texture, depth-stencil RB, FBO) at scale S for a + * dynamic-resolution step, depth and stencil cleared. 0 on failure (nothing + * left allocated). */ +static int hr_alloc_surface(int S, GLuint *tex, GLuint *rb, GLuint *fbo) { + while (glGetError() != GL_NO_ERROR) {} + int hw = VRAM_W * S, hh = VRAM_H * S; + *tex = make_tex(GL_RGBA8, hw, hh, GL_RGBA, GL_UNSIGNED_BYTE); + p_glGenRenderbuffers(1, rb); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, *rb); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, hw, hh); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); + int ok = glGetError() == GL_NO_ERROR; + if (ok) ok = make_fbo(fbo, *tex, *rb); + if (ok) { + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, *fbo); + glDisable(GL_SCISSOR_TEST); + glClearStencil(0); + glClearDepth(1.0); + glStencilMask(0xFF); + glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + ok = glGetError() == GL_NO_ERROR; + } + if (!ok) { + if (*fbo) p_glDeleteFramebuffers(1, fbo); + if (*tex) glDeleteTextures(1, tex); + if (*rb) p_glDeleteRenderbuffers(1, rb); + *fbo = *tex = *rb = 0; + } + return ok; +} + /* Release the scale-dependent render targets (hr colour + depth-stencil + * copy scratch). Used when an allocation fails and init steps S down. */ static void free_hr_targets(void) { @@ -4845,6 +4883,7 @@ static int alloc_hr_targets(int S) { if (ok) ok = make_fbo(&s_scratch_fbo, s_scratch_tex, 0); if (ok) ok = glGetError() == GL_NO_ERROR; if (!ok) free_hr_targets(); + s_hr_alloc = ok ? S : 0; return ok; } @@ -5154,8 +5193,8 @@ int gl_renderer_scale_info(GlScaleInfo *out) { out->clamp_reason = s_scale_clamp_reason; out->alloc_retries = s_scale_alloc_retries; out->budget_mib = (int)(s_vram_budget >> 20); - out->fbo_w = s_raster_ok ? VRAM_W * alloc_scale_for(s_hr_scale) : 0; - out->fbo_h = s_raster_ok ? VRAM_H * alloc_scale_for(s_hr_scale) : 0; + out->fbo_w = s_raster_ok ? VRAM_W * s_hr_alloc : 0; + out->fbo_h = s_raster_ok ? VRAM_H * s_hr_alloc : 0; out->hr_scale = s_raster_ok ? s_hr_scale : 0; out->windowed = s_hiw; { @@ -8428,7 +8467,7 @@ int gl_renderer_present_hold_last(void) { * alloc_scale_for(scale) that holds `units` native columns or rows: units * itself unless dynamic resolution renders it below its allocation. */ static float present_alloc_extent(int units, int scale) { - const int as = alloc_scale_for(scale); + const int as = s_hr_alloc > scale ? s_hr_alloc : scale; if (as <= scale || scale < 1) return (float)units; return (float)units * (float)as / (float)scale; } @@ -8835,7 +8874,7 @@ static void dyn_context_ready(void) { if (dyn_resources()) { s_alloc_scale = s_hr_scale; fprintf(stdout, "psxrecomp: GL dynamic resolution: levels 1x..%dx " - "(surfaces allocated at %dx)\n", s_alloc_scale, s_alloc_scale); + "(surfaces allocated at the level, ceiling %dx)\n", s_alloc_scale, s_alloc_scale); } else { fprintf(stdout, "psxrecomp: GL dynamic resolution unavailable (step " "resources failed); the scale stays %dx\n", s_hr_scale); @@ -9035,7 +9074,28 @@ static int dyn_apply(int snew) { p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); } const uint64_t t2 = dyn_mark(); - /* 3. Reseed the corner at the new scale. */ + /* 3. The hr surface at the new level: a new allocation of exactly + * VRAM*snew (the old one is only read through the 1x image and the + * scratch from here on), reseeded whole. If the allocation fails, a + * step down reseeds the old surface's corner in place; a step up past + * the old allocation is refused. */ + GLuint ntex = 0, nrb = 0, nfbo = 0; + if (!hr_alloc_surface(snew, &ntex, &nrb, &nfbo)) { + if (snew > s_hr_alloc) { + s_dyn_stats.refused++; + fprintf(stdout, "psxrecomp: dynamic resolution %dx -> %dx refused (the " + "surface could not be allocated)\n", sold, snew); + return 0; + } + } else { + p_glDeleteFramebuffers(1, &s_hr_fbo); + glDeleteTextures(1, &s_hr_tex); + p_glDeleteRenderbuffers(1, &s_hr_rb); + s_hr_fbo = nfbo; s_hr_tex = ntex; s_hr_rb = nrb; + s_hr_alloc = snew; + s_dyn_stats.hr_reallocs++; + } + /* Reseed at the new scale. */ dyn_pass_begin(s_dyn_seed_prog, s_hr_fbo, 0, 0, VRAM_W * snew, VRAM_H * snew, s_dyn_tl_tex); p_glUniform1i(s_dyn_seed_uSrc, 0); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 13006f74..25ecfaf3 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9556,7 +9556,7 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { "\"held_windows\":%llu,\"steps\":%llu,\"deferred\":%llu,\"last_from\":%d," "\"last_to\":%d,\"last_ms\":%.3f,\"last_interval_ms\":%.3f,\"last_prep_ms\":%.3f," "\"last_seed_ms\":%.3f,\"last_rects_ms\":%.3f,\"last_wide_ms\":%.3f," - "\"wide_reallocs\":%llu,\"step_cost_ms\":%.3f,\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", + "\"hr_reallocs\":%llu,\"wide_reallocs\":%llu,\"step_cost_ms\":%.3f,\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, g_dynres.active ? c.floor : 0, st.level, st.level * g_video_ref_lines, c.forced, c.last_load, c.last_late, c.last_vblank_hz, c.f, @@ -9565,7 +9565,7 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { c.windows, c.held_windows, (unsigned long long)st.steps, (unsigned long long)st.deferred, st.last_from, st.last_to, st.last_ms, g_dynres.step_interval_ms, st.last_prep_ms, st.last_seed_ms, st.last_rects_ms, - st.last_wide_ms, (unsigned long long)st.wide_reallocs, c.step_cost_s * 1000.0, + st.last_wide_ms, (unsigned long long)st.hr_reallocs, (unsigned long long)st.wide_reallocs, c.step_cost_s * 1000.0, c.down_block_until > now_s ? c.down_block_until - now_s : 0.0, blocked); } diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 2fa85ede..a47a43cc 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -62,13 +62,12 @@ * gl_renderer_pass_begin opens nothing there: a pass backs up and restores * only the authoritative surface, never the window's tiles. * - mode "steps" (dynamic resolution, argv: scale = the ceiling, then a - * comma list of levels, then "fresh" or "chain"): the hr surface stays - * allocated at the ceiling, the native-wide surface at the current level - * (reallocated at each step), and the scale steps at run time. + * comma list of levels, then "fresh" or "chain"): the scale steps at run + * time between the ceiling and 1x; the hr and native-wide surfaces are + * reallocated at the new level at each step. * "fresh" steps before anything is drawn, then renders the scene: the * runner requires the native digest, the frame at S and the wide surface - * to equal the fixed-scale run at the final level (rendering into the - * corner of a larger surface is rendering at that scale). + * to equal the fixed-scale run at the final level. * "chain" renders the scene and the native-wide margins at the ceiling, * then steps through the list. Every step must leave the native VRAM * bit-identical; the displayed rect and the draw area must come back as @@ -979,9 +978,9 @@ static uint64_t steps_wide_digest(uint32_t **keep) { return d; } -/* The native-wide surface is allocated at the level it renders at (not the - * ceiling, whose size every render pass would load and store on - * GL-on-Metal), and a step reallocates it at the new level. */ +/* The hr and native-wide surfaces are allocated at the level they render at + * (not the ceiling, whose size every render pass would load and store on + * GL-on-Metal), and a step reallocates them at the new level. */ static void steps_check_wide_alloc(int level) { GLint tw = 0, th = 0; glBindTexture(GL_TEXTURE_2D, s_wide_tex[0]); @@ -990,6 +989,12 @@ static void steps_check_wide_alloc(int level) { glBindTexture(GL_TEXTURE_2D, 0); check(tw == 426 * level && th == 512 * level && s_wide_as[0] == level, "wide surface allocated at the level"); + glBindTexture(GL_TEXTURE_2D, s_hr_tex); + glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &tw); + glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &th); + glBindTexture(GL_TEXTURE_2D, 0); + check(tw == 1024 * level && th == 512 * level && s_hr_alloc == level, + "hr surface allocated at the level"); } static int steps_main(int ceiling, int window, const char *chain, int fresh) { From fb6ef185a85453172c20acaca1313e5bfb95d6ac Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:50:20 -0700 Subject: [PATCH 37/57] Dynamic resolution: shrink the copy/stencil scratch after a step A step grows the scratch to hold the old level's displayed rect and draw area, and it stayed that size: every tiled stencil rebuild renders into it, so on GL-on-Metal its size cost bandwidth on each one. Return it to the size a surface allocated at the new level starts with. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index c119b58f..e3b461e5 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -9161,6 +9161,21 @@ static int dyn_apply(int snew) { wst_all(i); /* colour only */ } } + /* 5b. The copy/stencil scratch back to its size for a surface allocated + * at snew (alloc_hr_targets): the step grew it to the old level's + * rects, and every tiled stencil rebuild renders into it. */ + { + int hw = VRAM_W * snew, hh = VRAM_H * snew; + int sw = snew <= 2 ? hw : (hw < GL_SCRATCH_TILE ? hw : GL_SCRATCH_TILE); + int sh = snew <= 2 ? hh : (hh < GL_SCRATCH_TILE ? hh : GL_SCRATCH_TILE); + if (s_scratch_w > sw || s_scratch_h > sh) { + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_scratch_tex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, sw, sh, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); + glBindTexture(GL_TEXTURE_2D, 0); + s_scratch_w = sw; s_scratch_h = sh; + } + } const uint64_t t5 = dyn_mark(); /* 6. The new scale. */ s_hr_scale = s_out_scale = snew; From f69ce834ebc55f45f8db5ed77c462e9c6ef526c4 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:39:10 -0700 Subject: [PATCH 38/57] Frame generation on the render thread from recorded draw lists [video] frame_generation (opt-in, with render_thread): the render thread keeps each game frame's replayed draw list, matches the triangles of the last two (frame_gen.c), and presents in-between frames at the display refresh by redrawing the newer list into separate surfaces with matched vertices interpolated. It never runs guest code. Real frames are composed at their flip and presented after the generated ones, with the same pixels and order. Generation uses only measured surplus: a plan from render-thread CPU, generated-frame cost and swap time, a breaker after backpressure or late guest frames, and a pause while dynamic resolution is over budget. The render-thread core gains a timed tick hook. Frame-cost queries pause around generation, so dynamic resolution measures real frames only. Tests: frame_gen_test, render_thread_test (tick), gl_frame_gen_test (real frames identical off/on; phase 0/1 equal the endpoints). Docs: docs/FRAME_GENERATION.md. Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_GENERATION.md | 96 ++ docs/RENDER_THREAD.md | 1 + recompiler/src/config_loader.cpp | 3 + recompiler/src/config_loader.h | 9 + .../tests/video_enhancement_settings_test.cpp | 16 + runtime/CMakeLists.txt | 21 + runtime/include/frame_gen.h | 95 ++ runtime/include/gpu_gl_renderer.h | 12 + runtime/include/render_thread.h | 15 + runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 13 + runtime/src/frame_gen.c | 192 +++ runtime/src/gpu_gl_renderer.c | 1146 ++++++++++++++++- runtime/src/main.cpp | 22 + runtime/src/render_thread.c | 53 +- runtime/tests/run_gl_frame_gen.py | 147 +++ runtime/tests/run_gl_readback_region.py | 2 + runtime/tests/run_gl_render_thread.py | 2 +- runtime/tests/run_gl_scale_invariance.py | 2 + runtime/tests/run_gl_texture_filter.py | 2 + runtime/tests/test_frame_gen.c | 151 +++ runtime/tests/test_gl_frame_gen.c | 278 ++++ runtime/tests/test_render_thread.c | 47 + 23 files changed, 2307 insertions(+), 19 deletions(-) create mode 100644 docs/FRAME_GENERATION.md create mode 100644 runtime/include/frame_gen.h create mode 100644 runtime/src/frame_gen.c create mode 100644 runtime/tests/run_gl_frame_gen.py create mode 100644 runtime/tests/test_frame_gen.c create mode 100644 runtime/tests/test_gl_frame_gen.c diff --git a/docs/FRAME_GENERATION.md b/docs/FRAME_GENERATION.md new file mode 100644 index 00000000..aa1d1f11 --- /dev/null +++ b/docs/FRAME_GENERATION.md @@ -0,0 +1,96 @@ +# Frame generation (`[video] frame_generation`) + +Opt-in, OpenGL with the render thread only, off by default +(`PSX_FRAME_GEN=0/1` overrides for one run). Off, nothing in this document +runs and the renderer is the render thread of docs/RENDER_THREAD.md. + +```toml +[video] +render_thread = true +frame_generation = true +``` + +## Rule + +Real frames at the guest's rate come first and resolution is the only +quality lever for them (dynamic resolution). Frames above that are a bonus, +drawn by the render thread **from recorded data only**: no guest code runs +again, nothing is snapshotted or rolled back, and only when the render thread +has measured time to spare. + +## How + +- **Lists.** The render thread keeps a copy of every replayed state and + drawing record (not transfers, copies, presents, steps). Display buffers are + learned from presents; a list is one game frame's drawing into one buffer and + closes when drawing moves to another buffer, or when the game flips to the + buffer being drawn. (R4 starts drawing the next frame a VBlank before it + flips, so a flip alone is not a frame boundary.) +- **Cadence.** A flip (a present of a different display address) is a new + game frame; a present of the same buffer is the same frame again. R4 + updates every other VBlank, so generation interpolates between flips (30 Hz + game frames), not between VBlanks. +- **Matching** (`frame_gen.c`). Triangles are keyed by op, texture page, + CLUT, texture coordinates and the draw area they were clipped to + (relative to their buffer); positions are relative to the buffer. Each + newer triangle pairs, in draw order, with the nearest older one of the same + key whose three vertices moved at most 96 native px and alike (within + 24 px of each other). R4 races pair 87-97 % of their triangles. +- **Drawing.** An in-between frame redraws the newer list into separate + surfaces (a copy of the displayed buffer's hr rect and wide rows) with each + matched triangle at `lerp(older, newer, t)`, sub-pixel via the precise + triangle path; unmatched triangles, rects, lines, fills (2D, HUD) draw as + the newer frame has them. The raw texture mirror is not packed from the + generated surfaces; all bookkeeping the draws touch is restored, so the + real stream is unaffected. +- **Schedule.** With `n` in-between frames per game frame, at the flip the + real frame is composed and kept (its buffer may be drawn over before it is + shown), frames `t = k/(n+1)` are presented `flip/(n+1)` apart, then the + kept real frame. Timed work runs from the render-thread core's `tick` + hook between records and while idle. +- **Latency.** Interpolation shows a real frame after the in-between frames + that lead to it: up to `n/(n+1)` of a game frame later than without + generation (R4 at 30 Hz on 120 Hz: 25 ms at n = 3, 17 ms at n = 1). + Extrapolation (no delay, guessed motion) is not offered. +- **Plan.** `n = fg_plan(...)`: slots = round(flip interval × refresh) + (R4 on 120 Hz: 4), limited to what fits in 85 % of the interval after the + real frames' render-thread CPU time and one swap, at the measured cost of a + generated frame (CPU, GPU via `GL_TIME_ELAPSED`, plus a swap). On macOS a + real frame's `TIME_ELAPSED` span reads close to the whole interval (it + counts the GPU waiting for records), so it is not used here; GPU overload + shows as backpressure. The measured frame cost dynamic resolution uses + excludes generation (its query is paused, segments summed). +- **Breaker.** Off for 3 s (doubling to 24 s on repeats within 10 s) after + the queue backed up (the guest waited), the render thread fell two frames + behind, or a guest frame took over 1.5 intervals. While dynamic resolution + is over budget or stepping down, generation pauses (1 s, no escalation). + One frame behind just shows the waiting real frame at once. +- **Sync points** show the waiting real frame and restart the lists at the + next flip. + +## Limits + +- Only triangles move; sprites, lines and fills are the newer frame's. +- A texture the frame drew earlier into its own displayed buffer is sampled + as the raw mirror holds it when the in-between frame is drawn. +- Single-buffered games never flip, so nothing is generated. +- Windowed high-resolution mode and depth24 frames are not generated. + +## Debug + +`{"cmd":"frame_gen"}` (not a sync point): generated / real presents, flips, +dups, plan (`last_n`, `slots`), costs (`real_ms`, `gen_ms`, `swap_ms`), last +match counts, breaker, last hold, total swaps. + +## Tests + +- `frame_gen_test`: matching (moved scenes, keys, move and deformation + limits, duplicates, pair-once), interpolation endpoints, the plan, the + breaker. +- `render_thread_test`: the tick hook runs on the render thread with the + context, never early, and wakes an idle thread. +- `gl_frame_gen_test` (real GL, `gpu;opengl;hardware`): a double-buffered + 30 Hz scene, VRAM and native-wide presents, flipping right after drawing + and a VBlank late: the distinct real presented images are identical with + generation off and on, generated frames are presented, and an in-between + frame composed at t = 1 / t = 0 equals the newer / older real frame. diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 617ebdcc..6a81fbd8 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -167,6 +167,7 @@ resolution (below), not of this layer. | render passes | first `gl_renderer_pass_*` call in a frame is a sync point; the rest of that frame is synchronous. Recommended: leave `render_thread` off with the frame-rate mod | | frame interpolation | ineligible (held) while enabled; not started when it is on at boot | | OpenXR | ineligible while a session is active | +| frame generation | `[video] frame_generation`: in-between frames drawn by the render thread from recorded lists, docs/FRAME_GENERATION.md | | native-wide / widescreen | recorded; tags, latch and wide-surface mirror as above | | internal resolution changes | `gr_set_scale` and every resolution entry point are sync points; dynamic-resolution level steps are recorded (below) | | screenshots / debug captures | `screenshot*` sync; `present_shot` is fulfilled on the render thread | diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 05483809..6ef03792 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -802,6 +802,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("render_thread")) { rt.video_render_thread = toml::find(video, "render_thread"); } + if (video.contains("frame_generation")) { + rt.video_frame_generation = toml::find(video, "frame_generation"); + } if (video.contains("vsync")) { const auto mode = toml::find(video, "vsync"); if (mode == "on" || mode == "vsync") rt.video_vsync = 1; diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 81fbe07e..ebaa58a8 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -574,6 +574,15 @@ struct RuntimeConfig { // default; PSX_RENDER_THREAD=0/1 overrides. bool video_render_thread = false; + // frame_generation: with render_thread, the render thread draws + // in-between frames at the display's refresh from the last two game + // frames' draw lists when it has time to spare (never by running guest + // code). The real frames are unchanged and come first; a breaker turns + // generation off for seconds after a late frame. Adds up to one game + // frame of latency (docs/FRAME_GENERATION.md). Off by default; + // PSX_FRAME_GEN=0/1 overrides. + bool video_frame_generation = false; + // low_latency_input: re-sample the pad after the wall-clock pacer (just // before present) so the next CPU frame reads near-fresh input instead of // input ~one frame stale. Default on. vsync: present/swap mode — diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 6df8a99f..a775acfd 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -335,6 +335,21 @@ static void test_render_thread() { fs::remove(q); } +/* [video] frame_generation (docs/FRAME_GENERATION.md): opt-in, off by default. */ +static void test_frame_generation() { + fs::path p = write_game_toml("psxrecomp_fg_default.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(!gc.runtime.video_frame_generation, "frame_generation defaults OFF"); + fs::remove(p); + fs::path q = write_game_toml("psxrecomp_fg_on.toml", + "[video]\n" + "render_thread = true\n" + "frame_generation = true\n"); + auto gq = PSXRecompV4::load_game_config(q); + check(gq.runtime.video_frame_generation, "[video] frame_generation = true is honoured"); + fs::remove(q); +} + /* docs/ENHANCEMENTS.md G1.11: the PGXP title keys. Defaults keep the * historical behaviour (tolerance 0.5, position cache consulted, IR-path * shadows); a title built with the hooks sets all three. */ @@ -400,6 +415,7 @@ int main() { test_user_settings_fov_scale(); test_texture_window_batching(); test_render_thread(); + test_frame_generation(); test_pgxp_title_keys(); if (failures) { diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 91955388..c8de9c7b 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -227,6 +227,20 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-render-thread-results") set_tests_properties(gl_render_thread_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) + # Frame generation ([video] frame_generation): the real presented + # images are identical with it off and on (VRAM and native-wide + # presents), generated frames are presented, and an in-between frame + # at phase 1 / 0 equals the newer / older real frame. + add_test(NAME gl_frame_gen_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_frame_gen.py" + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_frame_gen.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-frame-gen-results") + set_tests_properties(gl_frame_gen_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") @@ -1218,6 +1232,13 @@ if(BUILD_TESTING) target_link_libraries(dynamic_resolution_rt_test PRIVATE m) endif() add_test(NAME dynamic_resolution_rt_test COMMAND dynamic_resolution_rt_test) + # Frame generation's matching, interpolation, plan and breaker. + add_executable(frame_gen_test tests/test_frame_gen.c src/frame_gen.c) + target_include_directories(frame_gen_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(frame_gen_test PRIVATE m) + endif() + add_test(NAME frame_gen_test COMMAND frame_gen_test) # Render-pass time freeze against mock devices (psx_cycles.c only). add_executable(render_pass_freeze_test diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h new file mode 100644 index 00000000..5c83a03a --- /dev/null +++ b/runtime/include/frame_gen.h @@ -0,0 +1,95 @@ +#ifndef PSX_FRAME_GEN_H +#define PSX_FRAME_GEN_H +/* + * Frame generation from recorded draw lists ([video] frame_generation, + * docs/FRAME_GENERATION.md). The renderer-independent half: the primitives + * of two consecutive game frames (one per display flip), their matching, + * vertex interpolation, how many in-between frames fit, and the breaker. + * The GL backend (gpu_gl_renderer.c) owns the record lists themselves, + * drawing and presenting; nothing here touches GL or guest state. + * + * A primitive is a triangle of one frame's draw list with a match key (the + * draw op, texture page, CLUT, texture coordinates, the draw area it was + * clipped to) and its three screen positions in native pixels relative to + * that frame's displayed buffer. Matching pairs each primitive of the newer + * frame with one of the older frame's, in draw order: the same key, every + * vertex moved at most max_move, and the three vertices moved alike (a + * rigid-ish motion, at most max_deform apart). A primitive with no partner + * is drawn where the newer frame has it. + */ +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct FgPrim { + uint32_t rec; /* index of its record in the owner's list */ + uint32_t key; /* match key (fg_hash) */ + float x[3], y[3]; /* native px, relative to the list's display origin */ +} FgPrim; + +typedef struct FgPrimList { + FgPrim *v; + uint32_t n, cap; +} FgPrimList; + +void fg_prims_reset(FgPrimList *l); +void fg_prims_free(FgPrimList *l); +/* Append; returns 0 when out of memory. */ +int fg_prims_add(FgPrimList *l, const FgPrim *p); + +/* FNV-1a over 32-bit words, for building keys. */ +uint32_t fg_hash(uint32_t h, const int32_t *w, int n); +#define FG_HASH_INIT 2166136261u + +typedef struct FgMatchParams { + float max_move; /* native px a vertex may move between the frames */ + float max_deform; /* native px the vertices' moves may differ by */ + int window; /* same-key candidates considered per primitive */ +} FgMatchParams; +void fg_match_defaults(FgMatchParams *p); + +typedef struct FgMatchStats { + uint32_t prims, matched, moved, unmatched; +} FgMatchStats; + +/* For every primitive of `newer`, the index of its partner in `older` or -1 + * (match[newer->n]). Each older primitive pairs at most once. Returns the + * matched count. */ +uint32_t fg_match(const FgPrimList *older, const FgPrimList *newer, + const FgMatchParams *p, int32_t *match, FgMatchStats *st); + +/* Positions at phase t between a (t = 0) and b (t = 1). */ +void fg_lerp(const FgPrim *a, const FgPrim *b, double t, float x[3], float y[3]); + +/* How many in-between frames to draw per game frame. + * flip_s the game frame's interval (time between flips) + * refresh_hz the display's refresh rate + * real_cost_s the render thread's cost of one game frame (all its VBlank + * frames), 0 when unknown + * gen_cost_s the cost of one generated frame, 0 when not yet measured + * budget share of the interval the real and generated work may use + * Returns 0..slots-1, where slots = round(flip_s * refresh_hz); an unknown + * generation cost allows one frame while the real cost leaves half the + * interval, so the cost gets measured. */ +int fg_plan(double flip_s, double refresh_hz, double real_cost_s, + double gen_cost_s, double budget, int max_gens); + +/* Breaker: after a trip, generation stays off for hold seconds; a trip + * within `repeat_s` of the end of the last hold doubles the hold, up to max. */ +typedef struct FgBreaker { + double until, hold, last_end; + double base_hold, max_hold, repeat_s; + uint32_t trips; + const char *reason; +} FgBreaker; +void fg_breaker_init(FgBreaker *b, double base_hold, double max_hold, double repeat_s); +void fg_breaker_trip(FgBreaker *b, double now, const char *reason); +int fg_breaker_open(const FgBreaker *b, double now); /* 1 = generation allowed */ + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index d403dffc..6efa688f 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -326,6 +326,18 @@ typedef struct GlDynresStats { uint64_t hr_reallocs, wide_reallocs; /* surfaces reallocated at a step */ } GlDynresStats; void gl_renderer_dynres_stats(GlDynresStats *out); +/* Frame generation ([video] frame_generation, docs/FRAME_GENERATION.md): + * in-between frames drawn by the render thread from the last two game + * frames' draw lists when it has time to spare. Off by default; with it off + * nothing changes. set: a sync point. configure: the display's refresh and + * the guest's frame rate (any thread). hold: trip the breaker (generation + * stays off for a few seconds), e.g. while dynamic resolution is over budget. + * json: the debug server's {"cmd":"frame_gen"} fields (no sync point). */ +void gl_renderer_set_frame_generation(int on); +int gl_renderer_frame_generation(void); +void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz); +void gl_renderer_frame_gen_hold(const char *reason); +int gl_renderer_frame_gen_json(char *out, int cap); /* Host time the renderer spent, as running totals in performance-counter * ticks (only kept while dynamic resolution is on): waits for the frame * blend's next present, render passes, blend presents' own work, and time diff --git a/runtime/include/render_thread.h b/runtime/include/render_thread.h index d57e5772..cf6a9978 100644 --- a/runtime/include/render_thread.h +++ b/runtime/include/render_thread.h @@ -52,6 +52,12 @@ typedef struct RtCmd { * (current == 0; the backend flushes first). */ typedef void (*RtExecFn)(void *user, const RtCmd *cmd, const void *payload); typedef void (*RtCtxFn)(void *user, int current); +/* tick (optional): timed work on the render thread while it holds the + * context (frame generation). Called with the current time before a record + * is executed and while the ring is empty, once the deadline it last + * returned has passed; returns the next deadline (rt_now_ns clock), 0 for + * none. An idle render thread sleeps no later than that deadline. */ +typedef uint64_t (*RtTickFn)(void *user, uint64_t now_ns); typedef struct RtConfig { size_t ring_bytes; /* power of two; >= 1 MiB */ @@ -59,6 +65,7 @@ typedef struct RtConfig { RtExecFn exec; RtCtxFn ctx; void *user; + RtTickFn tick; /* optional (NULL) */ } RtConfig; /* Start: the context must be current on the calling thread; on success it is @@ -89,6 +96,14 @@ int rt_frames_ahead(void); /* Render-thread query: the consumer's running idle total (waiting for * records, or parked while the emulation thread holds the context). */ uint64_t rt_render_idle_ns(void); +/* Monotonic nanoseconds (the clock tick deadlines use). */ +uint64_t rt_now_ns(void); +/* Any thread: running count of times the emulation thread blocked on the + * in-flight bound or a full ring (it waited on the render thread). */ +uint64_t rt_backpressure_events(void); +/* Render thread, from tick or exec: ask for tick at `deadline_ns` (earliest + * wins over the one tick last returned). */ +void rt_tick_at(uint64_t deadline_ns); /* Sync point. Drain, then move the context to the calling thread (held). * reason is a static string kept in the acquire ring. No-op when already held diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 097e65b8..25e86950 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -371,6 +371,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/gpu_vram_dirty.c ${PSXRECOMP_ROOT}/runtime/src/gpu_render.c ${PSXRECOMP_ROOT}/runtime/src/render_thread.c + ${PSXRECOMP_ROOT}/runtime/src/frame_gen.c ${PSXRECOMP_ROOT}/runtime/src/gpu_gl_renderer.c ${PSXRECOMP_ROOT}/runtime/src/gpu_hd_textures.cpp ${PSXRECOMP_ROOT}/runtime/src/hd_texture_pack.cpp diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index dfd5f7d4..9481d3bc 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -10424,6 +10424,18 @@ static void handle_render_thread(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf[0] ? buf : "\"active\":0"); } +/* Frame generation ([video] frame_generation, docs/FRAME_GENERATION.md): + * {"cmd":"frame_gen"} -> enabled, active, generated / real presents, flips, + * the last plan (in-between frames per game frame, display slots), costs, + * match counts, breaker. Not a sync point (render-thread counters, racy). */ +static void handle_frame_gen(int id, const char *json) +{ + (void)json; + char buf[1024]; + if (gl_renderer_frame_gen_json(buf, sizeof buf) <= 0) buf[0] = 0; + send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf[0] ? buf : "\"enabled\":0"); +} + /* Presented-image ring (see present_image_ring.h): * {"cmd":"present_image_ring_stats"} -> oldest, newest, count, frozen * {"cmd":"present_image_ring_get","frame":N,"path":"x.png"} */ @@ -15498,6 +15510,7 @@ static const CmdEntry s_commands[] = { { "capture_mark", handle_capture_mark }, { "present_image_ring_stats", handle_present_image_ring_stats }, { "render_thread", handle_render_thread }, + { "frame_gen", handle_frame_gen }, { "present_image_ring_get", handle_present_image_ring_get }, { "kernel_bless", handle_kernel_bless }, { "ws_aspect", handle_ws_aspect }, diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c new file mode 100644 index 00000000..867defb4 --- /dev/null +++ b/runtime/src/frame_gen.c @@ -0,0 +1,192 @@ +/* Frame generation: matching, interpolation, planning (include/frame_gen.h, + * docs/FRAME_GENERATION.md). */ +#include "frame_gen.h" + +#include +#include +#include + +void fg_prims_reset(FgPrimList *l) { l->n = 0; } + +void fg_prims_free(FgPrimList *l) { + free(l->v); + l->v = NULL; + l->n = l->cap = 0; +} + +int fg_prims_add(FgPrimList *l, const FgPrim *p) { + if (l->n == l->cap) { + uint32_t nc = l->cap ? l->cap * 2u : 1024u; + FgPrim *nv = (FgPrim *)realloc(l->v, (size_t)nc * sizeof *nv); + if (!nv) return 0; + l->v = nv; + l->cap = nc; + } + l->v[l->n++] = *p; + return 1; +} + +uint32_t fg_hash(uint32_t h, const int32_t *w, int n) { + for (int i = 0; i < n; i++) { + uint32_t x = (uint32_t)w[i]; + for (int b = 0; b < 4; b++) { + h ^= (x >> (8 * b)) & 0xFFu; + h *= 16777619u; + } + } + return h; +} + +void fg_match_defaults(FgMatchParams *p) { + p->max_move = 96.0f; + p->max_deform = 24.0f; + p->window = 16; +} + +/* Open-addressing table from key to the first older primitive with it; the + * older primitives with one key are chained in draw order. */ +typedef struct { + uint32_t *keys; + int32_t *head; + uint32_t mask; +} KeyTab; + +static int tab_init(KeyTab *t, uint32_t n) { + uint32_t cap = 64; + while (cap < n * 2u) cap <<= 1; + t->keys = (uint32_t *)malloc((size_t)cap * sizeof *t->keys); + t->head = (int32_t *)malloc((size_t)cap * sizeof *t->head); + t->mask = cap - 1; + if (!t->keys || !t->head) return 0; + for (uint32_t i = 0; i < cap; i++) t->head[i] = -1; + return 1; +} + +static int32_t *tab_slot(KeyTab *t, uint32_t key) { + uint32_t i = (key * 2654435761u) & t->mask; + while (t->head[i] >= 0 && t->keys[i] != key) i = (i + 1) & t->mask; + t->keys[i] = key; + return &t->head[i]; +} + +uint32_t fg_match(const FgPrimList *older, const FgPrimList *newer, + const FgMatchParams *p, int32_t *match, FgMatchStats *st) { + FgMatchStats s; + memset(&s, 0, sizeof s); + s.prims = newer->n; + for (uint32_t i = 0; i < newer->n; i++) match[i] = -1; + if (!older->n || !newer->n) { + s.unmatched = newer->n; + if (st) *st = s; + return 0; + } + KeyTab t; + int32_t *next = (int32_t *)malloc((size_t)older->n * sizeof *next); + uint8_t *used = (uint8_t *)calloc(older->n, 1); + if (!tab_init(&t, older->n) || !next || !used) { + free(t.keys); free(t.head); free(next); free(used); + s.unmatched = newer->n; + if (st) *st = s; + return 0; + } + /* Chains in draw order: insert from the back. */ + for (uint32_t i = older->n; i-- > 0;) { + int32_t *h = tab_slot(&t, older->v[i].key); + next[i] = *h; + *h = (int32_t)i; + } + const float mm = p->max_move * p->max_move; + const float md = p->max_deform * p->max_deform; + for (uint32_t j = 0; j < newer->n; j++) { + const FgPrim *b = &newer->v[j]; + int32_t *h = tab_slot(&t, b->key); + /* Drop used entries at the chain head so later lookups stay short. */ + while (*h >= 0 && used[*h]) *h = next[*h]; + int32_t best = -1; + float best_d = 0.0f; + int seen = 0; + for (int32_t i = *h; i >= 0 && seen < p->window; i = next[i]) { + if (used[i]) continue; + seen++; + const FgPrim *a = &older->v[i]; + float dx[3], dy[3], d = 0.0f; + int ok = 1; + for (int k = 0; k < 3 && ok; k++) { + dx[k] = b->x[k] - a->x[k]; + dy[k] = b->y[k] - a->y[k]; + float d2 = dx[k] * dx[k] + dy[k] * dy[k]; + if (d2 > mm) ok = 0; + d += d2; + } + for (int k = 1; k < 3 && ok; k++) { + float ex = dx[k] - dx[0], ey = dy[k] - dy[0]; + if (ex * ex + ey * ey > md) ok = 0; + } + if (!ok) continue; + if (best < 0 || d < best_d) { + best = i; + best_d = d; + if (d == 0.0f) break; /* did not move: nothing closer */ + } + } + if (best >= 0) { + used[best] = 1; + match[j] = best; + s.matched++; + if (best_d > 0.0f) s.moved++; + } else { + s.unmatched++; + } + } + free(t.keys); free(t.head); free(next); free(used); + if (st) *st = s; + return s.matched; +} + +void fg_lerp(const FgPrim *a, const FgPrim *b, double t, float x[3], float y[3]) { + for (int k = 0; k < 3; k++) { + if (t >= 1.0) { x[k] = b->x[k]; y[k] = b->y[k]; continue; } + if (t <= 0.0) { x[k] = a->x[k]; y[k] = a->y[k]; continue; } + x[k] = (float)((double)a->x[k] + ((double)b->x[k] - (double)a->x[k]) * t); + y[k] = (float)((double)a->y[k] + ((double)b->y[k] - (double)a->y[k]) * t); + } +} + +int fg_plan(double flip_s, double refresh_hz, double real_cost_s, + double gen_cost_s, double budget, int max_gens) { + if (flip_s <= 0.0 || refresh_hz <= 0.0 || max_gens <= 0) return 0; + int slots = (int)floor(flip_s * refresh_hz + 0.5); + if (slots < 2) return 0; + int n = slots - 1; + if (n > max_gens) n = max_gens; + const double room = budget * flip_s - (real_cost_s > 0.0 ? real_cost_s : 0.0); + if (room <= 0.0) return 0; + if (gen_cost_s <= 0.0) return room >= 0.5 * budget * flip_s ? 1 : 0; + int fit = (int)floor(room / gen_cost_s); + return fit < n ? (fit > 0 ? fit : 0) : n; +} + +void fg_breaker_init(FgBreaker *b, double base_hold, double max_hold, double repeat_s) { + memset(b, 0, sizeof *b); + b->base_hold = base_hold; + b->max_hold = max_hold; + b->repeat_s = repeat_s; + b->last_end = -1e30; + b->until = -1e30; +} + +void fg_breaker_trip(FgBreaker *b, double now, const char *reason) { + b->trips++; + b->reason = reason; + if (now < b->until) return; /* already open: the hold stands */ + if (b->hold > 0.0 && now - b->last_end <= b->repeat_s) { + b->hold *= 2.0; + if (b->hold > b->max_hold) b->hold = b->max_hold; + } else { + b->hold = b->base_hold; + } + b->until = now + b->hold; + b->last_end = b->until; +} + +int fg_breaker_open(const FgBreaker *b, double now) { return now >= b->until; } diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index e3b461e5..186d71d9 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -375,6 +375,15 @@ static int s_rthx_prim = 0; /* current record carries prim tags * static int s_rthx_tagged = 0, s_rthx_backdrop = 0; static int s_rths_flat_bd = 0, s_rths_vp_w = 0, s_rths_bg_full = 0; static inline int rth_replaying(void) { return s_rth_on && rt_on_render_thread(); } +/* Frame generation (defined with the render-thread code near the end). */ +static int s_fg_on; +static int s_fg_drawing, s_fg_presenting, s_fg_broken, s_fg_capture_real; +static void fg_capture(const RtCmd *c, const void *payload); +static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stale); +static void fg_flush(void); +static void fg_invalidate(void); +static uint64_t fg_tick(void *user, uint64_t now); +static void fg_note_guest_frame(void); extern int psx_ws_prim_is_tagged(void); /* gpu.c: is the current GP0 prim sprite-tagged? */ extern int psx_ws_prim_in_backdrop(void); /* gpu.c: is its source addr in the flower-field struct? */ extern int gpu_ws_nw_flat_backdrop_enabled(void); /* gpu.c: per-title flat backdrop opt-in */ @@ -2546,6 +2555,9 @@ static const HiwTile *hiw_ensure(int x0, int x1) { /* ---- coherency: hr FBO -> raw mirror (pack) ------------------------------ */ static void pack_flush(void) { if (!s_raster_ok || !s_pack_dirty.set) return; + /* A generated frame draws into its own surfaces: the raw mirror keeps + * the real VRAM (its pack dirt is restored afterwards). */ + if (s_fg_drawing) return; hiw_flush_queue(); /* queued window draws sample the raw mirror as it is now */ int x = s_pack_dirty.x0, y = s_pack_dirty.y0; int w = s_pack_dirty.x1 - s_pack_dirty.x0 + 1; @@ -8179,6 +8191,10 @@ static int ov_needs_present(void) { : host_osd_needs_present(); } +static uint64_t s_swaps_total = 0; /* every swap, real or generated (diagnostic) */ +static void gl_swap_now(int generated); +static int fg_keep_real(void); /* frame generation */ +static void fg_note_swap(uint64_t ns); static void gl_swap_with_osd(void) { openxr_present_native(); /* Copy guest content before host-only overlays. */ s_native_surface_pending=0; /* Hold-last/resim cannot reuse a native source. */ @@ -8256,12 +8272,30 @@ static void gl_swap_with_osd(void) { present_shot_done(wrote); } } + /* Frame generation: a real frame shown after its in-between frames is + * composed now (its buffer may be drawn over before it is shown) and + * kept; fg_present_real swaps it later. */ + if (s_fg_capture_real && fg_keep_real()) return; + gl_swap_now(s_fg_presenting); +} + +/* The swap itself (and its accounting). */ +static void gl_swap_now(int generated) { + (void)generated; /* Dynamic resolution's ledger: time blocked in the swap is the driver's * vsync wait unless the frame was late (main.cpp). Keep one owned swap. */ +#ifdef GL_PRESENT_TEST_HOOK + GL_PRESENT_TEST_HOOK(generated); +#endif + s_swaps_total++; uint64_t t0 = s_dyn_on ? SDL_GetPerformanceCounter() : 0; const uint64_t rthf_t0 = rth_replaying() ? host_now_ns_rthf() : 0; SDL_GL_SwapWindow(s_win); - if (rthf_t0) s_rthf_swap_ns += host_now_ns_rthf() - rthf_t0; + if (rthf_t0) { + const uint64_t sw = host_now_ns_rthf() - rthf_t0; + s_rthf_swap_ns += sw; + fg_note_swap(sw); + } if (s_dyn_on) { s_dyn_last_swap_ticks = SDL_GetPerformanceCounter() - t0; s_dyn_ledger.swap_ticks += s_dyn_last_swap_ticks; @@ -9476,11 +9510,13 @@ static int rth_record_present(uint16_t op, int n, const int32_t *v) { return 1; } +static void rth_present_payload(uint16_t op, const uint8_t *p); static void rth_replay_present(const RtCmd *c, const uint8_t *p) { - RthOvHdr hd; - memcpy(&hd, p, sizeof hd); static int skipped_last = 0; - if (rt_frames_ahead() >= 2 && !skipped_last) { + const int stale = rt_frames_ahead() >= 2 && !skipped_last; + /* Frame generation schedules (or drops) presents itself. */ + if (s_fg_on && fg_on_present(c->op, p, c->payload, stale)) { skipped_last = 0; return; } + if (stale) { /* Two later frames are already recorded, so the emulation thread is * at (or near) the in-flight bound waiting on us. Showing this frame * would only delay those; skip its present (its drawing has been @@ -9493,6 +9529,13 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { return; } skipped_last = 0; + rth_present_payload(c->op, p); +} + +/* Present a recorded present's payload (header, overlay copies). */ +static void rth_present_payload(uint16_t op, const uint8_t *p) { + RthOvHdr hd; + memcpy(&hd, p, sizeof hd); const uint8_t *q = p + sizeof hd; memset(&s_rth_ov, 0, sizeof s_rth_ov); s_rth_ov.valid = 1; @@ -9506,7 +9549,7 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { q += (size_t)hd.w[i] * hd.h[i] * 4u; } const int32_t *a = hd.args; - if (c->op == RTH_PRESENT_VRAM) + if (op == RTH_PRESENT_VRAM) gl_renderer_present_vram(a[0], a[1], a[2], a[3], a[4], a[5]); else (void)gl_renderer_present_wide_fbo(a[0], a[1], a[2], a[3]); @@ -9527,10 +9570,17 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { * its span holds the emulation thread's own drawing. Published as running * totals for the emulation thread (gl_renderer_render_thread_costs). */ #define RTHF_Q 8 +/* Frame generation pauses a frame's measurement while it draws (the frame + * then has up to RTHF_SEG queries, summed). */ +#define RTHF_SEG 4 static _Atomic int s_rthf_want = 0; /* host: measure (dynres active) */ static int s_rthf_qok = -1; /* render thread: queries usable */ -static GLuint s_rthf_q[RTHF_Q]; +static GLuint s_rthf_q[RTHF_Q][RTHF_SEG]; +static int s_rthf_nseg[RTHF_Q]; static uint64_t s_rthf_slot_cpu[RTHF_Q]; +static int s_rthf_paused = 0; /* a pause ended this frame's query */ +static uint64_t s_rthf_pause_t0 = 0, s_rthf_pause_swap = 0, s_rthf_excl = 0; +static void fg_note_real_cost(uint64_t cost_ns); /* frame generation */ static unsigned s_rthf_head = 0, s_rthf_tail = 0; static int s_rthf_open = 0, s_rthf_taint = 0, s_rthf_has_q = 0; static uint64_t s_rthf_t0 = 0, s_rthf_idle0 = 0, s_rthf_swap0 = 0; @@ -9551,17 +9601,28 @@ static void rthf_publish(uint64_t cpu, uint64_t gpu, int has_gpu) { } atomic_fetch_add(&s_rthf_cost_ns, cost); atomic_fetch_add(&s_rthf_frames, 1); /* last: the totals above are in */ + /* Frame generation budgets with the CPU time: on macOS a TIME_ELAPSED + * span also counts the GPU waiting for a frame's records to arrive, so + * it reads close to the whole interval whatever the work. GPU overload + * shows up as a slow swap and a queue that backs up (the breaker). */ + fg_note_real_cost(cpu); } /* Read every finished query pair, oldest first, without waiting. */ static void rthf_poll(void) { while (s_rthf_tail != s_rthf_head) { const unsigned i = s_rthf_tail % RTHF_Q; - GLuint64 avail = 0, ns = 0; - p_glGetQueryObjectui64v(s_rthf_q[i], GL_QUERY_RESULT_AVAILABLE, &avail); + GLuint64 avail = 0, ns = 0, sum = 0; + /* Queries complete in order: the last segment's result means all. */ + p_glGetQueryObjectui64v(s_rthf_q[i][s_rthf_nseg[i] - 1], GL_QUERY_RESULT_AVAILABLE, + &avail); if (!avail) break; - p_glGetQueryObjectui64v(s_rthf_q[i], GL_QUERY_RESULT, &ns); - rthf_publish(s_rthf_slot_cpu[i], (uint64_t)ns, 1); + for (int k = 0; k < s_rthf_nseg[i]; k++) { + ns = 0; + p_glGetQueryObjectui64v(s_rthf_q[i][k], GL_QUERY_RESULT, &ns); + sum += ns; + } + rthf_publish(s_rthf_slot_cpu[i], (uint64_t)sum, 1); s_rthf_tail++; } } @@ -9571,22 +9632,54 @@ static void rthf_begin(void) { s_rthf_qok = (p_glGenQueries && p_glBeginQuery && p_glEndQuery && p_glGetQueryObjectui64v && !(getenv("PSX_DYNRES_GPU_TIMER") && getenv("PSX_DYNRES_GPU_TIMER")[0] == '0')) ? 1 : 0; - if (s_rthf_qok) p_glGenQueries(RTHF_Q, s_rthf_q); + if (s_rthf_qok) p_glGenQueries(RTHF_Q * RTHF_SEG, &s_rthf_q[0][0]); } s_rthf_open = 1; s_rthf_taint = 0; + s_rthf_paused = 0; + s_rthf_excl = 0; s_rthf_t0 = host_now_ns_rthf(); s_rthf_idle0 = rt_render_idle_ns(); s_rthf_swap0 = s_rthf_swap_ns; s_rthf_has_q = s_rthf_qok && s_rthf_head - s_rthf_tail < RTHF_Q; if (s_rthf_has_q) { gl_perf_yield(); /* close frame_perf's open bracket, if any */ - p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[s_rthf_head % RTHF_Q]); + const unsigned i = s_rthf_head % RTHF_Q; + s_rthf_nseg[i] = 1; + p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[i][0]); + } +} + +/* Frame generation draws between a frame's records: its work is not the + * frame's. Pause ends the frame's GPU query and starts excluding wall time + * (and its swaps); resume opens the next query segment. */ +static void rthf_pause(void) { + if (!s_rthf_open || s_rthf_paused) return; + s_rthf_paused = 1; + s_rthf_pause_t0 = host_now_ns_rthf(); + s_rthf_pause_swap = s_rthf_swap_ns; + if (s_rthf_has_q) p_glEndQuery(GL_TIME_ELAPSED); +} +static void rthf_resume(void) { + if (!s_rthf_open || !s_rthf_paused) return; + s_rthf_paused = 0; + s_rthf_excl += host_now_ns_rthf() - s_rthf_pause_t0; + s_rthf_swap_ns = s_rthf_pause_swap; /* the generated frames' swaps */ + if (s_rthf_has_q) { + const unsigned i = s_rthf_head % RTHF_Q; + if (s_rthf_nseg[i] < RTHF_SEG) { + p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[i][s_rthf_nseg[i]++]); + } else { + /* Out of segments: the rest of the frame goes unmeasured. */ + s_rthf_taint = 1; + p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[i][RTHF_SEG - 1]); + } } } static void rthf_end(void) { if (!s_rthf_open) return; + if (s_rthf_paused) rthf_resume(); s_rthf_open = 0; if (s_rthf_taint) { /* The query pair is still issued so the ring stays in order; the @@ -9602,7 +9695,8 @@ static void rthf_end(void) { const uint64_t idle = rt_render_idle_ns() - s_rthf_idle0; const uint64_t swap = s_rthf_swap_ns - s_rthf_swap0; const uint64_t span = now - s_rthf_t0; - const uint64_t cpu = span > idle + swap ? span - idle - swap : 0; + const uint64_t skip = idle + swap + s_rthf_excl; + const uint64_t cpu = span > skip ? span - skip : 0; if (s_rthf_has_q) { const unsigned i = s_rthf_head % RTHF_Q; p_glEndQuery(GL_TIME_ELAPSED); @@ -9616,7 +9710,8 @@ static void rthf_end(void) { void gl_renderer_render_thread_measure(int on) { atomic_store(&s_rthf_want, on ? 1 : 0); } static int rthf_owns_timer(void) { - return s_rth_on && atomic_load_explicit(&s_rthf_want, memory_order_relaxed) && s_rthf_qok > 0; + return s_rth_on && (atomic_load_explicit(&s_rthf_want, memory_order_relaxed) || s_fg_on) && + s_rthf_qok > 0; } void gl_renderer_render_thread_costs(GlRthCosts *out) { @@ -9629,6 +9724,1013 @@ void gl_renderer_render_thread_costs(GlRthCosts *out) { out->dropped = atomic_load(&s_rthf_dropped); } +/* ---- frame generation ([video] frame_generation) --------------------------- + * docs/FRAME_GENERATION.md. Render thread only. Each game frame's draw list + * (the replayed records between two display flips) is kept; with the last + * two, the render thread draws in-between frames at the display's refresh + * from data alone: the newer list redrawn into separate surfaces with each + * matched triangle moved part of the way back to where the older frame had + * it (frame_gen.c matches them). Unmatched, 2D and HUD primitives, fills and + * every other record draw as the newer frame has them. Nothing here writes + * the real surfaces, VRAM or guest state: the real frames are the same + * images, presented in the same order, one game frame later at most (the + * delay interpolation needs: the in-between frames before a real frame are + * shown in the time it would have been shown alone). */ +#include "frame_gen.h" + +/* Captured record: header + payload copy. */ +typedef struct { uint16_t op, flags; uint32_t bytes; } FgRec; +typedef struct { + int semi_en, semi_mode, mask_set, mask_check, tw[4], mod[4]; + int area[4], off[2], wide_on, wide_base, view[4], rths[3]; +} FgState; +typedef struct { + uint8_t *buf; size_t len, cap; + uint32_t *off; uint32_t n, ncap; /* record offsets into buf */ + FgPrimList prims; /* triangles, local coords */ + int32_t *rec2prim; uint32_t r2p_cap; /* record -> prim index, -1 */ + FgState start; /* draw state at the list start */ + int valid, wide, disp[4]; /* closed: display rect it shows */ + int wide_w, wide_off, scale, linear, force43; +} FgList; +/* Raw triangle captured while recording a list (absolute coords). */ +typedef struct { uint32_t rec, key0; float x[3], y[3]; int area[4]; } FgRaw; + +static int s_fg_on = 0; /* configured (any thread reads) */ +static int s_fg_force = 0; /* PSX_FRAME_GEN_FORCE: tests */ +static _Atomic double s_fg_refresh_hz = 0.0, s_fg_guest_hz = 59.94; +static _Atomic int s_fg_late = 0; /* emulation thread: a late frame */ +static const char *_Atomic s_fg_hold_reason = NULL; +static _Atomic uint64_t s_fg_hold_until = 0; /* rt_now_ns: no generation before */ +static uint64_t s_fg_emu_last_ns = 0; /* emulation thread */ +static FgList s_fg_l[4]; /* older, newer, capturing, spare */ +static int s_fg_older = 0, s_fg_newer = 1, s_fg_cur = 2; +static int s_fg_pa = -1, s_fg_pb = -1; /* the pair a schedule draws from */ +static FgRaw *s_fg_raw = NULL; static uint32_t s_fg_nraw = 0, s_fg_rawcap = 0; +static int s_fg_pc_valid = 0; static int32_t s_fg_pc[6]; /* pending PRECISE */ +static int s_fg_have_last = 0, s_fg_last_dx = 0, s_fg_last_dy = 0; +/* Display buffers seen in presents (x, y, w, h), and the one the capturing + * list draws into: a list is one game frame's drawing into one buffer, closed + * when drawing moves to another buffer (games flip at a VBlank after they + * start the next frame, so a flip is not a list boundary). */ +static int s_fg_buf[4][4], s_fg_nbuf = 0, s_fg_cur_buf = -1; +static int s_fg_vblanks = 0, s_fg_flip_vb = 1; /* presents per flip */ +static int32_t *s_fg_match = NULL; static uint32_t s_fg_match_cap = 0; +static FgMatchStats s_fg_mst; +static FgBreaker s_fg_brk; +static int s_fg_brk_init = 0; +static uint64_t s_fg_bp_seen = 0; +static double s_fg_real_ema = 0.0, s_fg_gen_ema = 0.0; /* seconds */ +static const char *s_fg_hold_why = NULL; +static double s_fg_gen_gpu_ms = 0.0, s_fg_gen_cpu_ms = 0.0; /* the last one measured */ +/* Schedule after a flip: n generated frames, then the real one. */ +static int s_fg_pending = 0, s_fg_n = 0, s_fg_k = 0; +static uint64_t s_fg_t0 = 0, s_fg_step_ns = 0; +static uint16_t s_fg_real_op = 0; +static uint8_t *s_fg_real_p = NULL; static size_t s_fg_real_cap = 0; +/* Generated-frame surfaces (the displayed buffer's hr rect and wide rows). */ +static GLuint s_fg_hr_tex = 0, s_fg_hr_rb = 0, s_fg_hr_fbo = 0; +static int s_fg_hr_S = 0; +static GLuint s_fg_w_tex = 0, s_fg_w_rb = 0, s_fg_w_fbo = 0; +static int s_fg_w_S = 0, s_fg_w_w = 0; +static int s_fg_drawing = 0, s_fg_presenting = 0; /* generated draws / swap */ +static int s_fg_broken = 0; /* the context was handed away */ +static int s_fg_capture_real = 0; /* gl_swap_with_osd keeps the image */ +static GLuint s_fg_real_tex = 0, s_fg_real_fbo = 0; +static int s_fg_real_w = 0, s_fg_real_h = 0, s_fg_real_kept = 0; +static int s_fg_partial = 0; /* the capturing list is incomplete */ +static GLuint s_fg_q[4]; static int s_fg_qok = -1; static unsigned s_fg_qh = 0, s_fg_qt = 0; +static uint64_t s_fg_q_cpu[4]; +/* Statistics (render thread writes; the debug server reads, racy by design). */ +static uint64_t s_fg_generated = 0, s_fg_real_presents = 0, s_fg_flips = 0, + s_fg_flushed = 0, s_fg_skipped_plan = 0, s_fg_dups = 0; +static int s_fg_last_n = 0, s_fg_last_slots = 0; +static double s_fg_last_match_ms = 0.0; + +static double fg_now_s(void) { return (double)rt_now_ns() * 1e-9; } + +/* Render thread: every swap's wall time. A swap waits for the compositor + * (and on a busy WindowServer for its round trip); each generated frame adds + * one, so the plan counts it. */ +static double s_fg_swap_ema = 0.0; +static void fg_note_swap(uint64_t ns) { + const double c = (double)ns * 1e-9; + s_fg_swap_ema = s_fg_swap_ema > 0.0 ? s_fg_swap_ema * 0.9 + c * 0.1 : c; +} + +static void fg_note_real_cost(uint64_t cost_ns) { + const double c = (double)cost_ns * 1e-9; + s_fg_real_ema = s_fg_real_ema > 0.0 ? s_fg_real_ema * 0.9 + c * 0.1 : c; +} + +static void fg_list_clear(FgList *l) { + l->len = 0; l->n = 0; l->valid = 0; + fg_prims_reset(&l->prims); +} + +static void fg_state_capture(FgState *st) { + st->semi_en = s_semi_en; st->semi_mode = s_semi_mode; + st->mask_set = s_mask_set; st->mask_check = s_mask_check; + st->tw[0] = s_tw_mask_x; st->tw[1] = s_tw_mask_y; st->tw[2] = s_tw_off_x; st->tw[3] = s_tw_off_y; + st->mod[0] = s_mod_r; st->mod[1] = s_mod_g; st->mod[2] = s_mod_b; st->mod[3] = s_mod_raw; + st->area[0] = s_area_x1; st->area[1] = s_area_y1; st->area[2] = s_area_x2; st->area[3] = s_area_y2; + st->off[0] = s_off_x; st->off[1] = s_off_y; + st->wide_on = g_wide_cur != 0; st->wide_base = g_wide_cur_base; + st->view[0] = view_enabled; st->view[1] = view_shift; + st->view[2] = view_pad_left; st->view[3] = view_pad_right; + st->rths[0] = s_rths_flat_bd; st->rths[1] = s_rths_vp_w; st->rths[2] = s_rths_bg_full; +} + +/* Restore draw state without the setters' side effects (stencil rebuilds, + * wide-surface allocation); batches were flushed by the caller. */ +static void fg_state_apply(const FgState *st, GLuint wide_cur) { + s_semi_en = st->semi_en; s_semi_mode = st->semi_mode; + s_mask_set = st->mask_set; s_mask_check = st->mask_check; + s_tw_mask_x = st->tw[0]; s_tw_mask_y = st->tw[1]; s_tw_off_x = st->tw[2]; s_tw_off_y = st->tw[3]; + s_mod_r = st->mod[0]; s_mod_g = st->mod[1]; s_mod_b = st->mod[2]; s_mod_raw = st->mod[3]; + s_area_x1 = st->area[0]; s_area_y1 = st->area[1]; s_area_x2 = st->area[2]; s_area_y2 = st->area[3]; + s_off_x = st->off[0]; s_off_y = st->off[1]; + g_wide_cur = wide_cur; g_wide_cur_base = st->wide_base; + view_enabled = st->view[0]; view_shift = st->view[1]; + view_pad_left = st->view[2]; view_pad_right = st->view[3]; + s_rths_flat_bd = st->rths[0]; s_rths_vp_w = st->rths[1]; s_rths_bg_full = st->rths[2]; + s_pc_valid = 0; s_pq_valid = 0; s_projected_uv_valid = 0; +} + +static int fg_list_append(FgList *l, uint16_t op, uint16_t flags, const void *p, uint32_t bytes) { + size_t need = l->len + sizeof(FgRec) + ((bytes + 3u) & ~3u); + if (need > l->cap) { + size_t nc = l->cap ? l->cap : (size_t)1 << 20; + while (nc < need) nc *= 2; + uint8_t *nb = (uint8_t *)realloc(l->buf, nc); + if (!nb) return -1; + l->buf = nb; l->cap = nc; + } + if (l->n == l->ncap) { + uint32_t nc = l->ncap ? l->ncap * 2u : 8192u; + uint32_t *no = (uint32_t *)realloc(l->off, (size_t)nc * sizeof *no); + if (!no) return -1; + l->off = no; l->ncap = nc; + } + FgRec h = { op, flags, bytes }; + memcpy(l->buf + l->len, &h, sizeof h); + if (bytes) memcpy(l->buf + l->len + sizeof h, p, bytes); + l->off[l->n] = (uint32_t)l->len; + l->len = need; + return (int)l->n++; +} + +static int fg_raw_add(const FgRaw *r) { + if (s_fg_nraw == s_fg_rawcap) { + uint32_t nc = s_fg_rawcap ? s_fg_rawcap * 2u : 4096u; + FgRaw *nr = (FgRaw *)realloc(s_fg_raw, (size_t)nc * sizeof *nr); + if (!nr) return 0; + s_fg_raw = nr; s_fg_rawcap = nc; + } + s_fg_raw[s_fg_nraw++] = *r; + return 1; +} + +/* Which records a generated frame redraws: draw state and drawing, never + * transfers, copies, presents, steps or diagnostics. */ +static int fg_op_kept(uint16_t op) { + switch (op) { + case RTH_SEMI: case RTH_MASK: case RTH_TWIN: case RTH_MOD: case RTH_PRECISE: + case RTH_PERSP: case RTH_FILL: case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: + case RTH_TEX_TRI: case RTH_SHADED_TEX_TRI: case RTH_FLAT_RECT: case RTH_TEX_RECT: + case RTH_TEX_RECT_SCALED: case RTH_LINE: case RTH_SHADED_LINE: case RTH_AREA: + case RTH_OFFSET: case RTH_WIDE_VIEW: case RTH_WIDE_TARGET: case RTH_WIDE_DISABLE: + case RTH_WIDE_CLEAR: case RTH_WIDE_CLEAR_MARGINS: case RTH_PROJ_TRI: case RTH_STATE: + return 1; + default: + return 0; + } +} + +/* Positions of a triangle record (absolute native px) and its key words. */ +static int fg_tri_geom(uint16_t op, const void *payload, const int32_t *pc, + float x[3], float y[3], uint32_t *key) { + const int32_t *v = (const int32_t *)payload; + int xi[3], k = 0; + int32_t kw[12]; + switch (op) { + case RTH_FLAT_TRI: + for (int i = 0; i < 3; i++) { x[i] = (float)v[2 * i]; y[i] = (float)v[2 * i + 1]; } + kw[k++] = op; kw[k++] = v[6]; + break; + case RTH_GOURAUD_TRI: + for (int i = 0; i < 3; i++) { x[i] = (float)v[3 * i]; y[i] = (float)v[3 * i + 1]; } + kw[k++] = op; + break; + case RTH_TEX_TRI: + for (int i = 0; i < 3; i++) { + x[i] = (float)v[4 * i]; y[i] = (float)v[4 * i + 1]; + kw[k++] = v[4 * i + 2] | (v[4 * i + 3] << 16); + } + kw[k++] = op; kw[k++] = v[12]; kw[k++] = v[13]; kw[k++] = v[14]; + break; + case RTH_SHADED_TEX_TRI: + for (int i = 0; i < 3; i++) { + x[i] = (float)v[5 * i]; y[i] = (float)v[5 * i + 1]; + kw[k++] = v[5 * i + 2] | (v[5 * i + 3] << 16); + } + kw[k++] = op; kw[k++] = v[15]; kw[k++] = v[16]; kw[k++] = v[17]; kw[k++] = v[18]; + break; + case RTH_PROJ_TRI: { + PSXProjectedVertex vx[3]; + memcpy(vx, payload, sizeof vx); + const int32_t *a = (const int32_t *)((const uint8_t *)payload + sizeof vx); + for (int i = 0; i < 3; i++) { + x[i] = (float)vx[i].x; y[i] = (float)vx[i].y; + kw[k++] = (int32_t)floor(vx[i].u * 16.0) | ((int32_t)floor(vx[i].v * 16.0) << 16); + } + kw[k++] = op; kw[k++] = a[0]; kw[k++] = a[1]; kw[k++] = a[2]; kw[k++] = a[3]; + pc = NULL; + break; + } + default: + return 0; + } + (void)xi; + if (pc) /* a sub-pixel position set for this triangle (16.16) */ + for (int i = 0; i < 3; i++) { + x[i] = (float)pc[2 * i] / 65536.0f; + y[i] = (float)pc[2 * i + 1] / 65536.0f; + } + *key = fg_hash(FG_HASH_INIT, kw, k); + return 1; +} + +static int fg_buf_of(int x, int y) { + for (int i = 0; i < s_fg_nbuf; i++) + if (x >= s_fg_buf[i][0] && x < s_fg_buf[i][0] + s_fg_buf[i][2] && + y >= s_fg_buf[i][1] && y < s_fg_buf[i][1] + s_fg_buf[i][3]) return i; + return -1; +} +static void fg_list_close(FgList *l, const int disp[4]); +static void fg_list_open(void); + +/* Close the capturing list as the frame of its buffer; it becomes the newer + * frame, the newer one the older. */ +static void fg_rotate(void) { + fg_list_close(&s_fg_l[s_fg_cur], s_fg_buf[s_fg_cur_buf]); + if (s_fg_partial) { s_fg_l[s_fg_cur].valid = 0; s_fg_partial = 0; } + s_fg_older = s_fg_newer; s_fg_newer = s_fg_cur; + /* A slot no frame in use holds (a schedule pins its pair). */ + int free_slot = -1; + for (int k = 0; k < 4 && free_slot < 0; k++) + if (k != s_fg_older && k != s_fg_newer && + !(s_fg_pending && (k == s_fg_pa || k == s_fg_pb))) free_slot = k; + if (free_slot < 0) { + fg_flush(); + for (int k = 0; k < 4 && free_slot < 0; k++) + if (k != s_fg_older && k != s_fg_newer) free_slot = k; + } + s_fg_cur = free_slot; + fg_list_open(); +} + +/* Called for every replayed record (gl_rth_exec), before it executes. */ +static void fg_capture(const RtCmd *c, const void *payload) { + if (!fg_op_kept(c->op)) return; + /* Which buffer a draw goes to: a fill's rect, else the draw area. */ + int tb = -1; + switch (c->op) { + case RTH_FILL: { + const int32_t *v = (const int32_t *)payload; + tb = fg_buf_of(v[0], v[1]); + break; + } + case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: case RTH_SHADED_TEX_TRI: + case RTH_PROJ_TRI: case RTH_FLAT_RECT: case RTH_TEX_RECT: case RTH_TEX_RECT_SCALED: + case RTH_LINE: case RTH_SHADED_LINE: + tb = fg_buf_of(s_area_x1, s_area_y1); + break; + default: break; + } + if (tb >= 0 && tb != s_fg_cur_buf) { + if (s_fg_cur_buf >= 0) { + fg_rotate(); /* drawing moved on: the list so far is that buffer's frame */ + } else { + /* The first buffer seen: what was captured belongs to no frame. */ + fg_list_open(); + s_fg_partial = 0; + } + s_fg_cur_buf = tb; + } + FgList *l = &s_fg_l[s_fg_cur]; + int r = fg_list_append(l, c->op, c->flags, payload, c->payload); + if (r < 0) return; + if (c->op == RTH_PRECISE) { + const int32_t *v = (const int32_t *)payload; + s_fg_pc_valid = v[0] != 0; + if (s_fg_pc_valid) memcpy(s_fg_pc, v + 1, sizeof s_fg_pc); + return; + } + FgRaw raw; + if (fg_tri_geom(c->op, payload, s_fg_pc_valid ? s_fg_pc : NULL, raw.x, raw.y, &raw.key0)) { + raw.rec = (uint32_t)r; + raw.area[0] = s_area_x1; raw.area[1] = s_area_y1; + raw.area[2] = s_area_x2; raw.area[3] = s_area_y2; + (void)fg_raw_add(&raw); + } + switch (c->op) { /* a precise override describes one triangle */ + case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: + case RTH_SHADED_TEX_TRI: case RTH_PROJ_TRI: + s_fg_pc_valid = 0; + break; + default: break; + } +} + +static int fg_area_meets(const int *area, const int *disp) { + return area[2] >= disp[0] && area[0] < disp[0] + disp[2] && + area[3] >= disp[1] && area[1] < disp[1] + disp[3]; +} + +/* Close the capturing list as the frame of buffer disp (x, y, w, h). */ +static void fg_list_close(FgList *l, const int disp[4]) { + l->valid = 1; + memcpy(l->disp, disp, sizeof l->disp); + l->wide_w = g_wide_w; l->wide_off = g_wide_off; l->scale = s_out_scale; + if (l->r2p_cap < l->n) { + int32_t *nr = (int32_t *)realloc(l->rec2prim, (size_t)l->n * sizeof *nr); + if (!nr) { l->valid = 0; return; } + l->rec2prim = nr; l->r2p_cap = l->n; + } + for (uint32_t i = 0; i < l->n; i++) l->rec2prim[i] = -1; + fg_prims_reset(&l->prims); + for (uint32_t i = 0; i < s_fg_nraw; i++) { + const FgRaw *r = &s_fg_raw[i]; + if (!fg_area_meets(r->area, disp)) continue; + FgPrim p; + p.rec = r->rec; + const int32_t aw[4] = { r->area[0] - disp[0], r->area[1] - disp[1], + r->area[2] - disp[0], r->area[3] - disp[1] }; + p.key = fg_hash(r->key0, aw, 4); + for (int k = 0; k < 3; k++) { p.x[k] = r->x[k] - (float)disp[0]; p.y[k] = r->y[k] - (float)disp[1]; } + if (!fg_prims_add(&l->prims, &p)) { l->valid = 0; return; } + l->rec2prim[r->rec] = (int32_t)(l->prims.n - 1); + } +} + +static void fg_list_open(void) { + FgList *l = &s_fg_l[s_fg_cur]; + fg_list_clear(l); + fg_state_capture(&l->start); + s_fg_nraw = 0; + s_fg_pc_valid = 0; +} + +static void fg_invalidate(void) { + for (int i = 0; i < 4; i++) s_fg_l[i].valid = 0; + s_fg_have_last = 0; + s_fg_cur_buf = -1; + s_fg_partial = 0; + s_fg_broken = 0; + fg_list_open(); +} + +/* Surfaces for the generated image, at the current level. */ +static int fg_surfaces_ensure(int wide) { + const int S = s_hr_scale; + if (s_fg_hr_S != S) { + if (s_fg_hr_fbo) { + p_glDeleteFramebuffers(1, &s_fg_hr_fbo); glDeleteTextures(1, &s_fg_hr_tex); + p_glDeleteRenderbuffers(1, &s_fg_hr_rb); + s_fg_hr_fbo = s_fg_hr_tex = s_fg_hr_rb = 0; + } + s_fg_hr_S = 0; + if (!hr_alloc_surface(S, &s_fg_hr_tex, &s_fg_hr_rb, &s_fg_hr_fbo)) return 0; + s_fg_hr_S = S; + } + if (wide && (s_fg_w_S != S || s_fg_w_w != g_wide_w)) { + if (s_fg_w_fbo) { + p_glDeleteFramebuffers(1, &s_fg_w_fbo); glDeleteTextures(1, &s_fg_w_tex); + p_glDeleteRenderbuffers(1, &s_fg_w_rb); + s_fg_w_fbo = s_fg_w_tex = s_fg_w_rb = 0; + } + s_fg_w_S = 0; + if (!wide_alloc_surface(S, &s_fg_w_tex, &s_fg_w_rb, &s_fg_w_fbo)) return 0; + s_fg_w_S = S; s_fg_w_w = g_wide_w; + } + return 1; +} + +static void fg_surfaces_free(void) { + if (s_fg_hr_fbo) { p_glDeleteFramebuffers(1, &s_fg_hr_fbo); glDeleteTextures(1, &s_fg_hr_tex); + p_glDeleteRenderbuffers(1, &s_fg_hr_rb); } + if (s_fg_w_fbo) { p_glDeleteFramebuffers(1, &s_fg_w_fbo); glDeleteTextures(1, &s_fg_w_tex); + p_glDeleteRenderbuffers(1, &s_fg_w_rb); } + s_fg_hr_fbo = s_fg_hr_tex = s_fg_hr_rb = 0; s_fg_hr_S = 0; + s_fg_w_fbo = s_fg_w_tex = s_fg_w_rb = 0; s_fg_w_S = 0; s_fg_w_w = 0; + if (s_fg_real_fbo) { p_glDeleteFramebuffers(1, &s_fg_real_fbo); glDeleteTextures(1, &s_fg_real_tex); } + s_fg_real_fbo = s_fg_real_tex = 0; s_fg_real_w = s_fg_real_h = 0; s_fg_real_kept = 0; +} + +static void fg_blit(GLuint src, GLuint dst, int x, int y, int w, int h) { + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, src); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, dst); + glDisable(GL_SCISSOR_TEST); + glStencilMask(0xFF); + p_glBlitFramebuffer(x, y, x + w, y + h, x, y, x + w, y + h, + GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); +} + +static void fg_draw_tri_at(uint16_t op, const int32_t *v, const void *payload, + const float x[3], const float y[3]) { + int32_t w[19]; + int xi[3], yi[3], integral = 1; + for (int i = 0; i < 3; i++) { + xi[i] = (int)floorf(x[i] + 0.5f); yi[i] = (int)floorf(y[i] + 0.5f); + if ((float)xi[i] != x[i] || (float)yi[i] != y[i]) integral = 0; + } + if (op == RTH_PROJ_TRI) { + PSXProjectedVertex vx[3]; + memcpy(vx, payload, sizeof vx); + const int32_t *a = (const int32_t *)((const uint8_t *)payload + sizeof vx); + for (int i = 0; i < 3; i++) { vx[i].x = x[i]; vx[i].y = y[i]; } + gl_renderer_draw_projected_triangle(vx, (uint16_t)a[0], (uint16_t)a[1], (uint16_t)a[2], + a[3], a[4], a[5]); + return; + } + if (!integral) { + glb_set_precise_triangle(1, (int32_t)lrintf(x[0] * 65536.0f), (int32_t)lrintf(y[0] * 65536.0f), + (int32_t)lrintf(x[1] * 65536.0f), (int32_t)lrintf(y[1] * 65536.0f), + (int32_t)lrintf(x[2] * 65536.0f), (int32_t)lrintf(y[2] * 65536.0f)); + } + switch (op) { + case RTH_FLAT_TRI: + glb_draw_flat_triangle(xi[0], yi[0], xi[1], yi[1], xi[2], yi[2], (uint16_t)v[6]); + break; + case RTH_GOURAUD_TRI: + glb_draw_gouraud_triangle(xi[0], yi[0], (uint16_t)v[2], xi[1], yi[1], (uint16_t)v[5], + xi[2], yi[2], (uint16_t)v[8]); + break; + case RTH_TEX_TRI: + memcpy(w, v, 15 * sizeof *w); + for (int i = 0; i < 3; i++) { w[4 * i] = xi[i]; w[4 * i + 1] = yi[i]; } + glb_draw_textured_triangle(w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], + w[8], w[9], w[10], w[11], + (uint16_t)w[12], (uint16_t)w[13], (uint16_t)w[14]); + break; + case RTH_SHADED_TEX_TRI: + memcpy(w, v, 19 * sizeof *w); + for (int i = 0; i < 3; i++) { w[5 * i] = xi[i]; w[5 * i + 1] = yi[i]; } + glb_draw_shaded_textured_triangle(w[0], w[1], w[2], w[3], (uint32_t)w[4], + w[5], w[6], w[7], w[8], (uint32_t)w[9], + w[10], w[11], w[12], w[13], (uint32_t)w[14], + (uint16_t)w[15], (uint16_t)w[16], (uint16_t)w[17], w[18]); + break; + default: break; + } +} + +static int fg_rect_meets(int x, int y, int w, int h, const int *disp) { + return x < disp[0] + disp[2] && x + w > disp[0] && y < disp[1] + disp[3] && y + h > disp[1]; +} + +/* Redraw list b at phase t (0 = a's positions, 1 = b's) into the surfaces + * currently bound as hr / the displayed wide surface. */ +static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wide) { + const int *disp = b->disp; + const float ddx = (float)(b->disp[0]), ddy = (float)(b->disp[1]); + fg_state_apply(&b->start, (b->start.wide_on && b->start.wide_base == disp[0]) ? gen_wide : 0); + int pc_pending = 0; int32_t pc[7]; + for (uint32_t r = 0; r < b->n; r++) { + FgRec h; + memcpy(&h, b->buf + b->off[r], sizeof h); + const void *pl = b->buf + b->off[r] + sizeof h; + const int32_t *v = (const int32_t *)pl; + s_rthx_prim = (h.flags & RTHF_PRIM) != 0; + s_rthx_tagged = (h.flags & RTHF_TAGGED) != 0; + s_rthx_backdrop = (h.flags >> RTHF_BD_SHIFT) & 3; + const int area[4] = { s_area_x1, s_area_y1, s_area_x2, s_area_y2 }; + const int in = fg_area_meets(area, disp); + switch (h.op) { + case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; + case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; + case RTH_TWIN: glb_set_texture_window((uint32_t)v[0]); break; + case RTH_MOD: glb_set_color_modulation(v[0], v[1], v[2], v[3]); break; + case RTH_PRECISE: pc_pending = v[0] != 0; memcpy(pc, v, sizeof pc); break; + case RTH_PERSP: { + float q[3]; + memcpy(q, &v[1], sizeof q); + glb_set_perspective_triangle(v[0], q[0], q[1], q[2]); + break; + } + case RTH_AREA: glb_set_draw_area(v[0], v[1], v[2], v[3]); break; + case RTH_OFFSET: glb_set_draw_offset(v[0], v[1]); break; + case RTH_STATE: s_rths_flat_bd = v[0]; s_rths_vp_w = v[1]; s_rths_bg_full = v[2]; break; + case RTH_WIDE_VIEW: glb_wide_set_view(v[0], v[1], v[2], v[3]); break; + case RTH_WIDE_TARGET: + flush_flat_batch(); flush_tex_batch(); + g_wide_cur = (v[0] == disp[0]) ? gen_wide : 0; + g_wide_cur_base = v[0]; + break; + case RTH_WIDE_DISABLE: glb_wide_disable_target(); break; + case RTH_WIDE_CLEAR: + if (v[0] == disp[0] && gen_wide) glb_wide_clear(v[0], v[1], v[2], (uint16_t)v[3]); + break; + case RTH_WIDE_CLEAR_MARGINS: + if (v[0] == disp[0] && gen_wide) + glb_wide_clear_margins(v[0], v[1], v[2], (uint16_t)v[3], v[4]); + break; + case RTH_FILL: + if (fg_rect_meets(v[0], v[1], v[2], v[3], disp)) + glb_fill_rect(v[0], v[1], v[2], v[3], (uint16_t)v[4]); + break; + case RTH_FLAT_RECT: + if (in) glb_draw_flat_rect(v[0], v[1], v[2], v[3], (uint16_t)v[4]); + break; + case RTH_TEX_RECT: + if (in) glb_draw_textured_rect(v[0], v[1], v[2], v[3], v[4], v[5], + (uint16_t)v[6], (uint16_t)v[7], (uint16_t)v[8]); + break; + case RTH_TEX_RECT_SCALED: + if (in) glb_draw_textured_rect_scaled(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], + (uint16_t)v[8], (uint16_t)v[9], (uint16_t)v[10]); + break; + case RTH_LINE: + if (in) glb_draw_line(v[0], v[1], v[2], v[3], (uint16_t)v[4]); + break; + case RTH_SHADED_LINE: + if (in) glb_draw_shaded_line(v[0], v[1], (uint16_t)v[2], v[3], v[4], (uint16_t)v[5]); + break; + case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: + case RTH_SHADED_TEX_TRI: case RTH_PROJ_TRI: { + const int pi = b->rec2prim ? b->rec2prim[r] : -1; + if (pi < 0) { /* outside the display: drawn into VRAM already */ + pc_pending = 0; + break; + } + const int32_t ai = s_fg_match ? s_fg_match[pi] : -1; + const FgPrim *pb = &b->prims.v[pi]; + float x[3], y[3]; + if (ai >= 0 && t < 1.0) { + fg_lerp(&a->prims.v[ai], pb, t, x, y); + } else { + for (int k = 0; k < 3; k++) { x[k] = pb->x[k]; y[k] = pb->y[k]; } + } + for (int k = 0; k < 3; k++) { x[k] += ddx; y[k] += ddy; } + fg_draw_tri_at(h.op, v, pl, x, y); + precise_consumed(); + pc_pending = 0; + break; + } + default: break; + } + (void)pc_pending; + } + s_rthx_prim = 0; + flush_line_batch(); + flush_flat_batch(); + flush_tex_batch(); + hiw_flush_queue(); +} + +/* The generated image composed like a real present (no hold-last, no + * interpolation capture, no present bookkeeping), then swapped. */ +static void fg_compose(const FgList *b, GLuint wide_fbo, GLuint wide_tex, int linear, + int force_4_3) { + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); + int lx, ly, lw, lh; + if (b->wide) { + letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); + wide_blit_center(wide_fbo, b->disp[0], b->disp[1], b->disp[3]); + } else if (force_4_3) { + letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); + } else { + letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); + } + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, ww, wh); + if (lx != 0 || ly != 0 || lw != ww || lh != wh) { + glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); + } + if (b->wide) { + present_target_quad(wide_tex, (float)g_wide_w, (float)VRAM_H, 0, b->disp[1], g_wide_w, + b->disp[3], linear, lx, ly, lw, lh, 1, 1, s_out_scale); + } else { + present_bezel(ww, wh, lx, ly, lw, lh); + present_target_quad(s_hr_tex, (float)VRAM_W, (float)VRAM_H, b->disp[0], b->disp[1], + b->disp[2], b->disp[3], linear, lx, ly, lw, lh, 1, 1, s_out_scale); + } +} + +/* Draw and present the frame at phase t between the two lists. Returns 1 + * when a frame was presented. `swap` 0 leaves the composed image in the + * back buffer (tests). */ +static int fg_generate(double t, int swap) { + if (s_fg_pa < 0 || s_fg_pb < 0) return 0; + FgList *a = &s_fg_l[s_fg_pa], *b = &s_fg_l[s_fg_pb]; + if (!a->valid || !b->valid || s_hiw) return 0; + if (b->wide && (g_wide_w <= 0 || g_wide_w != b->wide_w)) return 0; + int iw = -1; + if (b->wide) { + for (int i = 0; i < WIDE_MAX_SURF; i++) + if (s_wide_fbo[i] && s_wide_base[i] == b->disp[0]) { iw = i; break; } + if (iw < 0) return 0; + } + /* Land the real stream's queued work on the real surfaces first. */ + flush_line_batch(); + flush_flat_batch(); + flush_tex_batch(); + flush_cpu_upload(); + hiw_flush_queue(); + pack_flush(); + if (!fg_surfaces_ensure(b->wide)) return 0; + const int S = s_hr_scale; + /* Real bookkeeping the generated draws would touch. */ + FgState real; + fg_state_capture(&real); + const GLuint real_wide_cur = g_wide_cur; + const int real_pc = s_pc_valid, real_pq = s_pq_valid; + DirtyRect pack = s_pack_dirty, sten = s_stencil_stale, cpu = s_cpu_dirty; + const int sten_valid = s_stencil_valid, gpu_dirty = s_gpu_dirty; + uint64_t pres_dirty[PRES_ROWS]; + memcpy(pres_dirty, s_present_dirty, sizeof pres_dirty); + int wst[4] = { 0, 0, 0, 0 }; + if (iw >= 0) { wst[0] = s_wst_x0[iw]; wst[1] = s_wst_y0[iw]; wst[2] = s_wst_x1[iw]; wst[3] = s_wst_y1[iw]; } + /* The displayed buffer as the real frame has it, then swap the surfaces. */ + const int dy = b->disp[1] < 0 ? 0 : b->disp[1]; + const int dh = b->disp[1] + b->disp[3] > VRAM_H ? VRAM_H - dy : b->disp[1] + b->disp[3] - dy; + fg_blit(s_hr_fbo, s_fg_hr_fbo, b->disp[0] * S, dy * S, b->disp[2] * S, dh * S); + if (iw >= 0) fg_blit(s_wide_fbo[iw], s_fg_w_fbo, 0, dy * S, g_wide_w * S, dh * S); + GLuint t0 = s_hr_tex, f0 = s_hr_fbo, r0 = s_hr_rb; + s_hr_tex = s_fg_hr_tex; s_hr_fbo = s_fg_hr_fbo; s_hr_rb = s_fg_hr_rb; + GLuint wt = 0, wf = 0, wr = 0; int was = 0; + if (iw >= 0) { + wt = s_wide_tex[iw]; wf = s_wide_fbo[iw]; wr = s_wide_rb[iw]; was = s_wide_as[iw]; + s_wide_tex[iw] = s_fg_w_tex; s_wide_fbo[iw] = s_fg_w_fbo; s_wide_rb[iw] = s_fg_w_rb; + s_wide_as[iw] = S; + } + s_fg_drawing = 1; + fg_replay(a, b, t, iw >= 0 ? s_fg_w_fbo : 0); + fg_compose(b, iw >= 0 ? s_fg_w_fbo : 0, iw >= 0 ? s_fg_w_tex : 0, b->linear, b->force43); + s_fg_drawing = 0; + if (swap) { + s_fg_presenting = 1; + gl_swap_with_osd(); + s_fg_presenting = 0; + } + /* Back to the real surfaces and state. */ + s_hr_tex = t0; s_hr_fbo = f0; s_hr_rb = r0; + if (iw >= 0) { + s_wide_tex[iw] = wt; s_wide_fbo[iw] = wf; s_wide_rb[iw] = wr; s_wide_as[iw] = was; + s_wst_x0[iw] = wst[0]; s_wst_y0[iw] = wst[1]; s_wst_x1[iw] = wst[2]; s_wst_y1[iw] = wst[3]; + } + fg_state_apply(&real, real_wide_cur); + s_pc_valid = real_pc; s_pq_valid = real_pq; + s_pack_dirty = pack; s_stencil_stale = sten; s_cpu_dirty = cpu; + s_stencil_valid = sten_valid; s_gpu_dirty = gpu_dirty; + memcpy(s_present_dirty, pres_dirty, sizeof pres_dirty); + sw_set_semi_transparency(s_semi_en, s_semi_mode); + sw_set_mask_bits(s_mask_set, s_mask_check); + sw_set_draw_area(s_area_x1, s_area_y1, s_area_x2, s_area_y2); + sw_set_draw_offset(s_off_x, s_off_y); + sw_set_color_modulation(s_mod_r, s_mod_g, s_mod_b, s_mod_raw); + sw_set_texture_window((uint32_t)(s_tw_mask_x | (s_tw_mask_y << 5) | (s_tw_off_x << 10) | + (s_tw_off_y << 15))); + return 1; +} + +/* ---- frame generation: schedule (render thread) ---- */ +static void rth_present_payload(uint16_t op, const uint8_t *p); /* below */ + +static void fg_trip(const char *why) { + if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 3.0, 24.0, 10.0); s_fg_brk_init = 1; } + fg_breaker_trip(&s_fg_brk, fg_now_s(), why); +} + +static void fg_gen_cost_poll(void) { + while (s_fg_qt != s_fg_qh) { + const unsigned i = s_fg_qt % 4u; + GLuint64 avail = 0, ns = 0; + p_glGetQueryObjectui64v(s_fg_q[i], GL_QUERY_RESULT_AVAILABLE, &avail); + if (!avail) break; + p_glGetQueryObjectui64v(s_fg_q[i], GL_QUERY_RESULT, &ns); + double c = (double)(ns > s_fg_q_cpu[i] ? ns : s_fg_q_cpu[i]) * 1e-9; + s_fg_gen_ema = s_fg_gen_ema > 0.0 ? s_fg_gen_ema * 0.8 + c * 0.2 : c; + s_fg_gen_gpu_ms = (double)ns * 1e-6; + s_fg_gen_cpu_ms = (double)s_fg_q_cpu[i] * 1e-6; + s_fg_qt++; + } +} + +/* One generated frame at phase t, timed (GPU query, CPU wall minus swap). */ +static void fg_generate_timed(double t) { + rthf_pause(); + /* (Re)allocation at a new level is not the generated frame's cost. */ + if (s_fg_pb >= 0) (void)fg_surfaces_ensure(s_fg_l[s_fg_pb].wide); + if (s_fg_qok < 0) { + s_fg_qok = (p_glGenQueries && p_glBeginQuery && p_glEndQuery && p_glGetQueryObjectui64v) ? 1 : 0; + if (s_fg_qok) p_glGenQueries(4, s_fg_q); + } + const int q = s_fg_qok > 0 && s_fg_qh - s_fg_qt < 4u; + if (q) { gl_perf_yield(); p_glBeginQuery(GL_TIME_ELAPSED, s_fg_q[s_fg_qh % 4u]); } + const uint64_t c0 = host_now_ns_rthf(), sw0 = s_rthf_swap_ns; + /* The overlay of the real frame these lead up to. */ + if (s_fg_real_p) { + RthOvHdr hd; + memcpy(&hd, s_fg_real_p, sizeof hd); + const uint8_t *qq = s_fg_real_p + sizeof hd; + memset(&s_rth_ov, 0, sizeof s_rth_ov); + s_rth_ov.valid = 1; + s_rth_ov.needs_present = hd.needs_present; + s_rth_ov.slide = hd.slide; + for (int i = 0; i < 4; i++) + if (hd.has & (1 << i)) { + s_rth_ov.px[i] = (const uint32_t *)qq; + s_rth_ov.w[i] = hd.w[i]; s_rth_ov.h[i] = hd.h[i]; + qq += (size_t)hd.w[i] * hd.h[i] * 4u; + } + } + /* Timed without the swap: it waits for the compositor, not for work. */ + const int ok = fg_generate(t, 0); + const uint64_t cpu = host_now_ns_rthf() - c0 - (s_rthf_swap_ns - sw0); + if (q) { + p_glEndQuery(GL_TIME_ELAPSED); + s_fg_q_cpu[s_fg_qh % 4u] = cpu; + s_fg_qh++; + } + if (ok) { + s_fg_presenting = 1; + gl_swap_with_osd(); + s_fg_presenting = 0; + } + s_rth_ov.valid = 0; + if (ok) s_fg_generated++; + rthf_resume(); +} + +/* Keep the composed back buffer (a real frame) for later. 1 when kept. */ +static int fg_keep_real(void) { + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww <= 0 || wh <= 0) return 0; + if (!s_fg_real_fbo || s_fg_real_w != ww || s_fg_real_h != wh) { + if (s_fg_real_fbo) { p_glDeleteFramebuffers(1, &s_fg_real_fbo); glDeleteTextures(1, &s_fg_real_tex); } + s_fg_real_fbo = s_fg_real_tex = 0; + s_fg_real_tex = make_tex(GL_RGBA8, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE); + if (!make_fbo(&s_fg_real_fbo, s_fg_real_tex, 0)) { + glDeleteTextures(1, &s_fg_real_tex); + s_fg_real_tex = s_fg_real_fbo = 0; + return 0; + } + s_fg_real_w = ww; s_fg_real_h = wh; + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_fg_real_fbo); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer(0, 0, ww, wh, 0, 0, ww, wh, GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + s_fg_real_kept = 1; + return 1; +} + +static void fg_present_real(void) { + s_fg_pending = 0; + if (!s_fg_real_kept) return; /* nothing changed: the present was skipped */ + s_fg_real_kept = 0; + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_fg_real_fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + glDisable(GL_SCISSOR_TEST); + if (ww != s_fg_real_w || wh != s_fg_real_h) { + glViewport(0, 0, ww, wh); + glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); + } + p_glBlitFramebuffer(0, 0, s_fg_real_w, s_fg_real_h, 0, 0, s_fg_real_w, s_fg_real_h, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + gl_swap_now(0); + s_fg_real_presents++; +} + +/* Present the waiting real frame now (generated frames still due are + * dropped): a newer present, a sync point, the queue backing up. */ +static void fg_flush(void) { + if (!s_fg_pending) return; + if (s_fg_k <= s_fg_n) s_fg_flushed++; + fg_present_real(); +} + +static uint64_t fg_tick(void *user, uint64_t now) { + (void)user; + if (!s_fg_pending) return 0; + /* Behind: a later frame is complete already, or the guest waited on us. */ + /* A whole frame waiting behind this one: the real frame goes now. Two, + * or the guest waited on the queue: the breaker as well. */ + const uint64_t bp = rt_backpressure_events(); + const int ahead = rt_frames_ahead(); + if (!s_fg_force && (ahead >= 1 || bp != s_fg_bp_seen)) { + if (bp != s_fg_bp_seen) fg_trip("queue backed up"); + else if (ahead >= 2) fg_trip("render thread behind"); + s_fg_bp_seen = bp; + fg_flush(); + return 0; + } + const uint64_t due = s_fg_t0 + (uint64_t)(s_fg_k - 1) * s_fg_step_ns; + if (now < due) return due; + if (s_fg_k <= s_fg_n) { + fg_generate_timed((double)s_fg_k / (double)(s_fg_n + 1)); + s_fg_k++; + return s_fg_t0 + (uint64_t)(s_fg_k - 1) * s_fg_step_ns; + } + fg_present_real(); + return 0; +} + +/* A present record replayed with frame generation on. Returns 1 when it was + * handled (scheduled or dropped), 0 to present it as usual. */ +static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stale) { + RthOvHdr hd; + memcpy(&hd, p, sizeof hd); + const int32_t *a = hd.args; + const int wide = op == RTH_PRESENT_WIDE; + int disp[4]; + if (wide) { + const int nw = g_wide_w - 2 * g_wide_off; + disp[0] = a[0]; disp[1] = a[1]; disp[2] = nw > 0 ? nw : 320; disp[3] = a[2]; + } else { + disp[0] = a[0]; disp[1] = a[1]; disp[2] = a[2]; disp[3] = a[3]; + } + if (s_fg_broken) { /* frames drawn without the render thread in between */ + s_fg_broken = 0; + for (int i = 0; i < 4; i++) if (i != s_fg_cur) s_fg_l[i].valid = 0; + s_fg_have_last = 0; + s_fg_partial = 1; /* the list being captured misses records */ + } + { /* Learn the display buffers. */ + int k = 0; + while (k < s_fg_nbuf && memcmp(s_fg_buf[k], disp, sizeof s_fg_buf[k]) != 0) k++; + if (k == s_fg_nbuf) { + if (s_fg_nbuf == 4) { memmove(s_fg_buf[0], s_fg_buf[1], sizeof s_fg_buf[0] * 3); s_fg_nbuf = 3; s_fg_cur_buf = -1; } + memcpy(s_fg_buf[s_fg_nbuf++], disp, sizeof s_fg_buf[0]); + } + } + const int flip = !s_fg_have_last || disp[0] != s_fg_last_dx || disp[1] != s_fg_last_dy; + s_fg_vblanks++; + if (!flip) { + /* The same game frame again (a 30 Hz game's second VBlank): while + * its in-between frames are being shown, that is what it shows. */ + s_fg_dups++; + return s_fg_pending ? 1 : 0; + } + if (s_fg_pending) fg_flush(); + s_fg_flips++; + s_fg_flip_vb = s_fg_have_last ? s_fg_vblanks : 1; + if (s_fg_flip_vb > 4) s_fg_flip_vb = 4; + s_fg_vblanks = 0; + s_fg_have_last = 1; s_fg_last_dx = disp[0]; s_fg_last_dy = disp[1]; + /* A game that flips right after drawing: the capturing list is this + * buffer's frame, complete. (One that flips later has moved on to the + * other buffer already, which closed it.) */ + if (s_fg_cur_buf >= 0 && s_fg_buf[s_fg_cur_buf][0] == disp[0] && + s_fg_buf[s_fg_cur_buf][1] == disp[1]) { + fg_rotate(); + s_fg_cur_buf = -1; + } + /* The newest closed list is this buffer's frame; the one before it the + * previous game frame. */ + FgList *A = &s_fg_l[s_fg_older], *B = &s_fg_l[s_fg_newer]; + if (B->valid && (B->disp[0] != disp[0] || B->disp[1] != disp[1])) B = A = NULL; + if (B) { B->wide = wide; B->linear = wide ? a[3] : a[4]; B->force43 = wide ? 0 : a[5]; } + const double now = fg_now_s(); + if (atomic_exchange(&s_fg_late, 0)) fg_trip("guest late"); + const char *why = atomic_exchange(&s_fg_hold_reason, NULL); + if (why) s_fg_hold_why = why; + const int held = rt_now_ns() < atomic_load(&s_fg_hold_until); + const uint64_t bp = rt_backpressure_events(); + if (bp != s_fg_bp_seen) { s_fg_bp_seen = bp; fg_trip("queue backed up"); } + if (stale) fg_trip("render thread behind"); + if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 3.0, 24.0, 10.0); s_fg_brk_init = 1; } + if (s_fg_qok > 0) fg_gen_cost_poll(); /* once per game frame: a poll flushes */ + const double ghz = atomic_load(&s_fg_guest_hz), rhz = atomic_load(&s_fg_refresh_hz); + const double flip_s = (double)s_fg_flip_vb / (ghz > 1.0 ? ghz : 59.94); + const int slots = (int)floor(flip_s * rhz + 0.5); + s_fg_last_slots = slots; + int n = 0; + if (A && B && A->valid && B->valid && A->disp[2] == B->disp[2] && + A->disp[3] == B->disp[3] && A->wide_w == B->wide_w && A->scale > 0) { + if (s_fg_force) n = slots > 1 ? slots - 1 : 1; + else if (fg_breaker_open(&s_fg_brk, now) && !stale && !held) + n = fg_plan(flip_s, rhz, s_fg_real_ema * (double)s_fg_flip_vb + s_fg_swap_ema, + s_fg_gen_ema > 0.0 ? s_fg_gen_ema + s_fg_swap_ema : 0.0, 0.85, 7); + if (n == 0) s_fg_skipped_plan++; + } + s_fg_last_n = n; + if (n <= 0) return 0; + /* Match the two frames' triangles. */ + const uint64_t m0 = rt_now_ns(); + if (s_fg_match_cap < B->prims.n) { + int32_t *nm = (int32_t *)realloc(s_fg_match, (size_t)B->prims.n * sizeof *nm); + if (!nm) return 0; + s_fg_match = nm; s_fg_match_cap = B->prims.n; + } + FgMatchParams mp; + fg_match_defaults(&mp); + fg_match(&A->prims, &B->prims, &mp, s_fg_match, &s_fg_mst); + s_fg_last_match_ms = (double)(rt_now_ns() - m0) * 1e-6; + /* The real frame waits for the generated ones before it. */ + if (s_fg_real_cap < bytes) { + uint8_t *nb = (uint8_t *)realloc(s_fg_real_p, bytes); + if (!nb) return 0; + s_fg_real_p = nb; s_fg_real_cap = bytes; + } + memcpy(s_fg_real_p, p, bytes); + s_fg_real_op = op; + /* The real frame, composed and kept now. */ + s_fg_real_kept = 0; + s_fg_capture_real = 1; + rth_present_payload(op, p); + s_fg_capture_real = 0; + s_fg_pa = s_fg_older; s_fg_pb = s_fg_newer; + s_fg_n = n; s_fg_k = 1; + s_fg_t0 = rt_now_ns(); + s_fg_step_ns = (uint64_t)(flip_s / (double)(n + 1) * 1e9); + s_fg_pending = 1; + s_fg_bp_seen = bp; + uint64_t next = fg_tick(NULL, rt_now_ns()); + if (next) rt_tick_at(next); + return 1; +} + +/* ---- frame generation: public ---- */ +void gl_renderer_set_frame_generation(int on) { + GL_RT_SYNC("frame_generation"); + s_fg_on = on ? 1 : 0; + const char *e = getenv("PSX_FRAME_GEN_FORCE"); + s_fg_force = e && e[0] == '1'; + if (!s_fg_on) { + if (s_ctx) fg_surfaces_free(); + fg_invalidate(); + } else { + fg_invalidate(); + } +} +int gl_renderer_frame_generation(void) { return s_fg_on; } + +void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz) { + atomic_store(&s_fg_refresh_hz, refresh_hz); + if (guest_hz > 1.0) atomic_store(&s_fg_guest_hz, guest_hz); +} + +/* Not a breaker trip: generation pauses while the real frames are under + * pressure (dynamic resolution over budget or stepping down) and for a second + * after, without the breaker's escalating holds. */ +void gl_renderer_frame_gen_hold(const char *reason) { + if (!s_fg_on) return; + atomic_store(&s_fg_hold_reason, reason ? reason : "hold"); + atomic_store(&s_fg_hold_until, rt_now_ns() + 1000000000ull); +} + +/* Emulation thread, once per frame boundary: a frame that took much longer + * than the guest's interval trips the breaker. */ +static void fg_note_guest_frame(void) { + if (!s_fg_on) return; + const uint64_t now = rt_now_ns(); + const double ghz = atomic_load(&s_fg_guest_hz); + if (s_fg_emu_last_ns && ghz > 1.0 && + (double)(now - s_fg_emu_last_ns) > 1.5e9 / ghz) + atomic_store(&s_fg_late, 1); + s_fg_emu_last_ns = now; +} + +int gl_renderer_frame_gen_json(char *out, int cap) { + const double now = fg_now_s(); + const int open = s_fg_brk_init ? fg_breaker_open(&s_fg_brk, now) : 1; + return snprintf(out, (size_t)cap, + "\"enabled\":%d,\"active\":%d,\"forced\":%d,\"generated\":%llu,\"real\":%llu," + "\"flips\":%llu,\"dups\":%llu,\"flushed\":%llu,\"not_planned\":%llu," + "\"last_n\":%d,\"slots\":%d,\"flip_vblanks\":%d,\"refresh_hz\":%.1f," + "\"real_ms\":%.3f,\"gen_ms\":%.3f,\"gen_cpu_ms\":%.3f,\"gen_gpu_ms\":%.3f,\"swap_ms\":%.3f," + "\"match_ms\":%.3f," + "\"prims\":%u,\"matched\":%u,\"moved\":%u,\"unmatched\":%u," + "\"breaker_s\":%.2f,\"trips\":%u,\"last_trip\":\"%s\",\"held_s\":%.2f," + "\"last_hold\":\"%s\",\"swaps\":%llu", + s_fg_on, s_fg_on && s_rth_on && open, s_fg_force, + (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, + (unsigned long long)s_fg_flips, (unsigned long long)s_fg_dups, + (unsigned long long)s_fg_flushed, (unsigned long long)s_fg_skipped_plan, + s_fg_last_n, s_fg_last_slots, s_fg_flip_vb, atomic_load(&s_fg_refresh_hz), + s_fg_real_ema * 1e3, s_fg_gen_ema * 1e3, s_fg_gen_cpu_ms, s_fg_gen_gpu_ms, + s_fg_swap_ema * 1e3, + s_fg_last_match_ms, + s_fg_mst.prims, s_fg_mst.matched, s_fg_mst.moved, s_fg_mst.unmatched, + open ? 0.0 : s_fg_brk.until - now, s_fg_brk.trips, + s_fg_brk.reason ? s_fg_brk.reason : "", + (double)(atomic_load(&s_fg_hold_until) > rt_now_ns() + ? atomic_load(&s_fg_hold_until) - rt_now_ns() : 0) * 1e-9, + s_fg_hold_why ? s_fg_hold_why : "", (unsigned long long)s_swaps_total); +} + /* ---- the recording vtable ------------------------------------------------ */ #define RTH_DIRECT_OR(rec) do { if (rth_record_mode()) { rec; return; } } while (0) static void rtb_init(uint16_t *vram) { @@ -9853,7 +10955,9 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { s_rthx_tagged = (c->flags & RTHF_TAGGED) != 0; s_rthx_backdrop = (c->flags >> RTHF_BD_SHIFT) & 3; if (c->op == RTH_FRAME) { rthf_end(); return; } - if (!s_rthf_open && atomic_load_explicit(&s_rthf_want, memory_order_relaxed)) rthf_begin(); + if (!s_rthf_open && (atomic_load_explicit(&s_rthf_want, memory_order_relaxed) || s_fg_on)) + rthf_begin(); + if (s_fg_on) fg_capture(c, payload); switch (c->op) { case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; @@ -9946,6 +11050,13 @@ static void gl_rth_ctx(void *user, int current) { if (current) { SDL_GL_MakeCurrent(s_win, s_ctx); } else { + if (rt_on_render_thread() && s_fg_on) { + /* The emulation thread takes over (and may draw frames the lists + * never see): show the waiting real frame now, and start the + * lists afresh at the next flip after it hands back. */ + fg_flush(); + s_fg_broken = 1; + } glFlush(); SDL_GL_MakeCurrent(s_win, NULL); if (rt_on_render_thread() && s_rthf_open) s_rthf_taint = 1; @@ -9980,6 +11091,8 @@ int gl_renderer_render_thread_start(int max_frames) { cfg.exec = gl_rth_exec; cfg.ctx = gl_rth_ctx; cfg.user = NULL; + cfg.tick = fg_tick; + fg_invalidate(); if (!rt_start(&cfg)) { s_rth_on = 0; s_vram = s_rth_vram_pub; @@ -10010,13 +11123,14 @@ int gl_renderer_render_thread_active(void) { return s_rth_on; } * may not, then close the frame (the in-flight bound applies here). */ void gl_renderer_render_thread_frame_boundary(void) { if (!s_rth_on) return; + fg_note_guest_frame(); if (rt_held()) { if (gl_rth_eligible()) gl_rth_release(); } else if (!gl_rth_eligible()) { gl_rth_acquire("ineligible"); } /* The render thread's per-frame cost (dynamic resolution) ends here. */ - if (!rt_held() && atomic_load_explicit(&s_rthf_want, memory_order_relaxed)) + if (!rt_held() && (atomic_load_explicit(&s_rthf_want, memory_order_relaxed) || s_fg_on)) RTH_REC(RTH_FRAME, 0, 0); rt_frame_end(); } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 25ecfaf3..6ec628cb 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1457,6 +1457,8 @@ static int g_low_latency_input = 1; * GL call are done; s_render_thread_tried keeps it to one attempt. */ static int g_render_thread = 0; static int g_render_thread_frames = 2; +/* [video] frame_generation (docs/FRAME_GENERATION.md), with the render thread. */ +static int g_frame_generation = 0; static int s_render_thread_tried = 0; static int g_video_vsync = 1; static int g_frame_interpolation = 0; @@ -9319,7 +9321,13 @@ static void dynres_tick_rt(double now_s, double wall, double period, int held, g_dynres.last_costs = co; g_dynres.last_bp_ns = bp_ns; DynrtSample smp{ period, wall, frames, cost, bp, held }; + const int prev_level = c.level; const int level = dynrt_sample(&c, now_s, &smp); + /* Frame generation only spends surplus: not while the real frames are + * over budget or the level is stepping down. */ + if (level < prev_level || c.over_streak > 0) + gl_renderer_frame_gen_hold(level < prev_level ? "dynres stepped down" + : "dynres over budget"); if (c.windows != g_dynres.rt_last_windows) { g_dynres.rt_last_windows = c.windows; g_dynres.rt_win_cpu_ms = g_dynres.rt_acc_frames @@ -9580,13 +9588,24 @@ static void render_thread_vblank(void) { gl_renderer_render_thread_start(g_render_thread_frames)) { std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", g_render_thread_frames); + if (g_frame_generation) { + gl_renderer_set_frame_generation(1); + std::fprintf(stdout, "psxrecomp: frame generation on (render thread, " + "from surplus only)\n"); + } } else { std::fprintf(stdout, "psxrecomp: render thread requested but not started " "(needs the OpenGL backend without netplay, frame " "interpolation or a 24-bit display)\n"); + if (g_frame_generation) + std::fprintf(stdout, "psxrecomp: frame generation needs the render thread; off\n"); } std::fflush(stdout); } + if (g_frame_generation) + gl_renderer_frame_gen_configure(g_host_refresh_hz, + g_guest_frame_period_ms > 0.0 + ? 1000.0 / g_guest_frame_period_ms : 0.0); gl_renderer_render_thread_frame_boundary(); #endif } @@ -15697,6 +15716,7 @@ int main(int argc, char** argv) { gl_renderer_set_texture_window_batching( gc.runtime.video_texture_window_batching ? 1 : 0); g_render_thread = gc.runtime.video_render_thread ? 1 : 0; + g_frame_generation = gc.runtime.video_frame_generation ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; @@ -18003,6 +18023,8 @@ session_reboot: * flight (default 2). */ if (const char* e = std::getenv("PSX_RENDER_THREAD")) g_render_thread = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_FRAME_GEN")) + g_frame_generation = (*e && *e != '0') ? 1 : 0; if (const char* e = std::getenv("PSX_RENDER_THREAD_FRAMES")) g_render_thread_frames = std::atoi(e) > 0 ? std::atoi(e) : 2; /* Scanlines: env override wins over config, same as the corrections above, diff --git a/runtime/src/render_thread.c b/runtime/src/render_thread.c index 7ec395f6..0cd14fde 100644 --- a/runtime/src/render_thread.c +++ b/runtime/src/render_thread.c @@ -25,6 +25,10 @@ static void mtx_lock(rt_mutex *m) { AcquireSRWLockExclusive(m); } static void mtx_unlock(rt_mutex *m) { ReleaseSRWLockExclusive(m); } static void cnd_init(rt_cond *c) { InitializeConditionVariable(c); } static void cnd_wait(rt_cond *c, rt_mutex *m) { SleepConditionVariableSRW(c, m, INFINITE, 0); } +static void cnd_wait_ns(rt_cond *c, rt_mutex *m, uint64_t ns) { + DWORD ms = (DWORD)((ns + 999999u) / 1000000u); + SleepConditionVariableSRW(c, m, ms ? ms : 1, 0); +} static void cnd_bcast(rt_cond *c) { WakeAllConditionVariable(c); } static uint64_t now_ns(void) { LARGE_INTEGER f, t; @@ -47,6 +51,19 @@ static void mtx_lock(rt_mutex *m) { pthread_mutex_lock(m); } static void mtx_unlock(rt_mutex *m) { pthread_mutex_unlock(m); } static void cnd_init(rt_cond *c) { pthread_cond_init(c, NULL); } static void cnd_wait(rt_cond *c, rt_mutex *m) { pthread_cond_wait(c, m); } +static void cnd_wait_ns(rt_cond *c, rt_mutex *m, uint64_t ns) { +# if defined(__APPLE__) + struct timespec rel = { (time_t)(ns / 1000000000u), (long)(ns % 1000000000u) }; + pthread_cond_timedwait_relative_np(c, m, &rel); +# else + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + uint64_t t = (uint64_t)ts.tv_nsec + ns % 1000000000u; + ts.tv_sec += (time_t)(ns / 1000000000u + t / 1000000000u); + ts.tv_nsec = (long)(t % 1000000000u); + pthread_cond_timedwait(c, m, &ts); +# endif +} static void cnd_bcast(rt_cond *c) { pthread_cond_broadcast(c); } static uint64_t now_ns(void) { struct timespec ts; @@ -81,6 +98,8 @@ static struct { RtExecFn exec; RtCtxFn ctx; void *user; + RtTickFn tick; + uint64_t tick_at; /* consumer: next tick deadline (0 = none) */ /* shared positions / counters */ _Atomic uint64_t wpos, rpos; @@ -107,6 +126,7 @@ static struct { uint64_t records, bytes, acquires, releases, oversize; uint64_t bp_waits, full_waits, bp_ns, full_ns, acq_ns, high_water; _Atomic uint64_t busy_ns, idle_ns; /* consumer */ + _Atomic uint64_t bp_events; /* producer writes, any thread reads */ RtAcquireEvent acq_ring[ACQ_RING]; uint64_t acq_n; @@ -127,6 +147,23 @@ int rt_running(void) { return atomic_load(&R.running); } int rt_on_render_thread(void) { return t_render; } int rt_held(void) { return R.held; } uint64_t rt_render_idle_ns(void) { return atomic_load(&R.idle_ns); } +uint64_t rt_now_ns(void) { return now_ns(); } +uint64_t rt_backpressure_events(void) { return atomic_load(&R.bp_events); } +void rt_tick_at(uint64_t deadline_ns) { + if (!t_render || !deadline_ns) return; + if (!R.tick_at || deadline_ns < R.tick_at) R.tick_at = deadline_ns; +} + +/* Consumer: run tick when its deadline has passed. */ +static void run_tick(void) { + if (!R.tick || !R.tick_at) return; + uint64_t now = now_ns(); + if (now < R.tick_at) return; + R.tick_at = 0; + uint64_t next = R.tick(R.user, now); + if (next && (!R.tick_at || next < R.tick_at)) R.tick_at = next; + atomic_fetch_add(&R.busy_ns, now_ns() - now); +} static inline uint64_t align_up(uint64_t n) { return (n + (RT_ALIGN - 1)) & ~(uint64_t)(RT_ALIGN - 1); @@ -171,6 +208,7 @@ static void *render_main(void *arg) uint64_t r = atomic_load(&R.rpos); int have_ctx = 1; for (;;) { + run_tick(); uint64_t w = atomic_load_explicit(&R.wpos, memory_order_acquire); if (r == w) { int spun = 0; @@ -183,8 +221,15 @@ static void *render_main(void *arg) uint64_t t0 = now_ns(); mtx_lock(&R.mtx); atomic_store(&R.render_sleeping, 1); - while (atomic_load(&R.wpos) == r && !R.stop && !R.release_req) - cnd_wait(&R.cv_work, &R.mtx); + while (atomic_load(&R.wpos) == r && !R.stop && !R.release_req) { + if (R.tick_at) { /* timed work due: sleep no later than it */ + uint64_t now = now_ns(); + if (now >= R.tick_at) break; + cnd_wait_ns(&R.cv_work, &R.mtx, R.tick_at - now); + } else { + cnd_wait(&R.cv_work, &R.mtx); + } + } atomic_store(&R.render_sleeping, 0); if (R.release_req && atomic_load(&R.wpos) == r) { serve_release_locked(); @@ -264,6 +309,7 @@ void *rt_cmd_begin(uint16_t op, uint16_t flags, uint32_t payload_bytes) { R.full_waits++; emu_wait(pred_space, w + pad + size); R.full_ns += now_ns() - t0; + atomic_fetch_add(&R.bp_events, 1); } if (pad) { RtCmd *p = (RtCmd *)(R.ring + off); @@ -324,6 +370,7 @@ void rt_frame_end(void) { R.bp_waits++; emu_wait(pred_frames, (uint64_t)R.max_frames); R.bp_ns += now_ns() - t0; + atomic_fetch_add(&R.bp_events, 1); } } @@ -386,6 +433,8 @@ int rt_start(const RtConfig *cfg) { R.exec = cfg->exec; R.ctx = cfg->ctx; R.user = cfg->user; + R.tick = cfg->tick; + R.tick_at = 0; mtx_init(&R.mtx); cnd_init(&R.cv_work); cnd_init(&R.cv_emu); diff --git a/runtime/tests/run_gl_frame_gen.py b/runtime/tests/run_gl_frame_gen.py new file mode 100644 index 00000000..128fba32 --- /dev/null +++ b/runtime/tests/run_gl_frame_gen.py @@ -0,0 +1,147 @@ +"""Build and run test_gl_frame_gen.c on a hidden real OpenGL context. + +Frame generation ([video] frame_generation, docs/FRAME_GENERATION.md): per +scale and present path (VRAM, native-wide), the fixture runs with the render +thread on and generation off, then on (forced). The real presented images, in +order, must be identical; the run with generation on must have presented +generated frames and passes its own endpoint checks (phase 1 = the newer real +image, phase 0 = the older one). Build flags and skips as +run_gl_render_thread.py. +""" +import argparse +import json +import os +import pathlib +import platform +import re +import subprocess +import sys +import tempfile + +MAC_FRAMEWORKS = ["Cocoa", "OpenGL", "IOKit", "CoreVideo", "CoreAudio", "AudioToolbox", + "Carbon", "ForceFeedback", "GameController", "Metal", "QuartzCore", + "CoreMedia", "AVFoundation", "Foundation", "CoreHaptics", + "UniformTypeIdentifiers"] +# The static SDL3's Win32 dependencies (as run_gl_texture_filter.py links them). +WIN_LIBS = ["opengl32", "kernel32", "user32", "gdi32", "winmm", "imm32", "ole32", + "oleaut32", "version", "uuid", "advapi32", "setupapi", "shell32", "dinput8"] +SKIP_EXIT = 77 +WINDOWS = os.name == "nt" or platform.system().startswith(("MINGW", "MSYS", "CYGWIN")) +KEYS = ("real",) + + +def parse(stdout): + out = {} + for k in KEYS: + m = re.search(r"^" + k + r"=([0-9a-f]{16})$", stdout, re.M) + out[k] = m[1] if m else None + m = re.search(r"^checks=(\d+) failures=(\d+)$", stdout, re.M) + out["failures"] = int(m[2]) if m else None + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--cc", default="cc") + ap.add_argument("--sdl-include", required=True) + ap.add_argument("--sdl-library", required=True) + ap.add_argument("--output", required=True) + ap.add_argument("--fixture", type=pathlib.Path) + ap.add_argument("--scales", default="1,3") + ap.add_argument("--frames", default="24") + args = ap.parse_args() + sdl_includes = [str(pathlib.Path(d).resolve()) for d in args.sdl_include.split(";") if d] + args.sdl_library = str(pathlib.Path(args.sdl_library).resolve()) + framework = pathlib.Path(__file__).resolve().parents[2] + fixture = args.fixture or framework / "runtime/tests/test_gl_frame_gen.c" + out_root = pathlib.Path(args.output).resolve() + out_root.mkdir(parents=True, exist_ok=True) + dest = pathlib.Path(tempfile.mkdtemp(prefix="fg-", dir=out_root)) + print("Evidence directory:", dest) + receipt = [] + + def run(command): + command = [str(c) for c in command] + r = subprocess.run(command, cwd=dest, capture_output=True, text=True, + encoding="utf-8", errors="replace") + receipt.append({"cmd": command, "exit": r.returncode, + "stdout": r.stdout, "stderr": r.stderr[-4000:]}) + (dest / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8") + return r + + includes = ["-I", framework / "runtime/include", "-I", framework / "runtime/src"] + for d in sdl_includes: + includes += ["-I", d] + src = framework / "runtime/src" + # The fixture #includes gpu_gl_renderer.c and drives it through the real + # facade (gpu_render.c) and render thread (render_thread.c). + sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), + ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), + ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), + ("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")] + sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] + # MinGW's PE linker reports undefined references from sections it later + # collects, so there LTO drops them first (as run_gl_scale_invariance.py). + if WINDOWS: + sections.append("-flto") + objs = [] + for name, path in sources: + o = dest / (name + ".o") + r = run([args.cc, "-std=gnu11", "-O1", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", + "-DGL_SILENCE_DEPRECATION=1", "-w", *sections, *includes, "-c", path, "-o", o]) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + objs.append(o) + probe = dest / ("probe.exe" if WINDOWS else "probe") + link = [args.cc, *objs, args.sdl_library, "-o", probe] + if platform.system() == "Darwin": + for f in MAC_FRAMEWORKS: + link += ["-framework", f] + link += ["-liconv", "-lm", "-lpthread", "-Wl,-dead_strip"] + elif WINDOWS: + # -static: no libwinpthread/libgcc DLLs needed next to probe.exe. + link += ["-flto", "-static", *["-l" + x for x in WIN_LIBS], "-lm", "-Wl,--gc-sections"] + else: + link += ["-lGL", "-lm", "-ldl", "-lpthread", "-Wl,--gc-sections"] + r = run(link) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + + ok = True + first = True + for s in [int(v) for v in args.scales.split(",") if v]: + for path, timing in (("vram", "flip"), ("wide", "flip"), ("vram", "late"), ("wide", "late")): + runs = {} + for fg in (0, 1): + r = run([probe, s, fg, path, args.frames, timing]) + if first and r.returncode == SKIP_EXIT: + print("SKIP:", r.stderr.strip()[-600:]) + return SKIP_EXIT + first = False + p = parse(r.stdout) + tail = r.stdout.strip().splitlines()[-6:] + print(f"scale {s} {path} {timing} frame_generation={fg}: exit={r.returncode}", tail, + r.stderr.strip()[-800:]) + if r.returncode or p["failures"] != 0 or p["real"] is None: + ok = False + m = re.search(r"^presents=(\d+) generated=(\d+)$", r.stdout, re.M) + p["presents"] = int(m[1]) if m else -1 + p["generated"] = int(m[2]) if m else -1 + runs[fg] = p + if runs[0]["real"] != runs[1]["real"] or runs[0]["presents"] != runs[1]["presents"]: + print(f"FAIL scale {s} {path} {timing}: the real frames differ with generation on " + f"({runs[0]['real']}/{runs[0]['presents']} off, " + f"{runs[1]['real']}/{runs[1]['presents']} on)") + ok = False + if runs[0]["generated"] != 0 or runs[1]["generated"] <= 0: + print(f"FAIL scale {s} {path} {timing}: generated frames off={runs[0]['generated']} " + f"on={runs[1]['generated']}") + ok = False + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py index 14810c25..88f8ec05 100644 --- a/runtime/tests/run_gl_readback_region.py +++ b/runtime/tests/run_gl_readback_region.py @@ -73,6 +73,8 @@ def main(): ("xr", framework / "runtime/src/psx_openxr.c")] if (framework / "runtime/src/render_thread.c").exists(): sources.append(("rth", framework / "runtime/src/render_thread.c")) + if (framework / "runtime/src/frame_gen.c").exists(): + sources.append(("fg", framework / "runtime/src/frame_gen.c")) for name, source in sources: if run([compiler / "gcc.exe", "-std=c11", "-O2", "-flto", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", *includes, "-c", source, diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index e338ba7f..b0bf1b2d 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -85,7 +85,7 @@ def main(): sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), - ("facade", src / "gpu_render.c")] + ("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")] sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] # MinGW's PE linker reports undefined references from sections it later # collects, so there LTO drops them first (as run_gl_scale_invariance.py). diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index 33c55215..1a13368d 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -136,6 +136,8 @@ def main(): sources.append(("xr", framework / "runtime/src/psx_openxr.c")) if (framework / "runtime/src/render_thread.c").exists(): sources.append(("rth", framework / "runtime/src/render_thread.c")) + if (framework / "runtime/src/frame_gen.c").exists(): + sources.append(("fg", framework / "runtime/src/frame_gen.c")) # Unused renderer functions reference the rest of the runtime; the linker # drops them (-dead_strip, or per-function sections with --gc-sections). # MinGW's PE linker reports undefined references from sections it later diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py index d4899edd..bd47de47 100644 --- a/runtime/tests/run_gl_texture_filter.py +++ b/runtime/tests/run_gl_texture_filter.py @@ -43,6 +43,8 @@ def main(): sources.append(('xr',fw/'runtime/src/psx_openxr.c')) if (fw/'runtime/src/render_thread.c').exists(): sources.append(('rth',fw/'runtime/src/render_thread.c')) + if (fw/'runtime/src/frame_gen.c').exists(): + sources.append(('fg',fw/'runtime/src/frame_gen.c')) objects=[] for name,source in sources: obj=out/(name+'.o'); run([args.cc,*flags,'-c',source,'-o',obj]); objects.append(obj) diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c new file mode 100644 index 00000000..cdae85da --- /dev/null +++ b/runtime/tests/test_frame_gen.c @@ -0,0 +1,151 @@ +/* Frame generation's renderer-independent half (src/frame_gen.c, + * docs/FRAME_GENERATION.md): matching triangles of two frames, the + * interpolation endpoints, the plan of how many in-between frames fit, and + * the breaker. */ +#include "frame_gen.h" + +#include +#include +#include +#include + +static int checks, failures; +static void check(int ok, const char *what) { + checks++; + if (!ok) { failures++; fprintf(stderr, "FAIL: %s\n", what); } +} + +static FgPrim tri(uint32_t key, float x, float y, float s) { + FgPrim p; + memset(&p, 0, sizeof p); + p.key = key; + p.x[0] = x; p.y[0] = y; + p.x[1] = x + s; p.y[1] = y; + p.x[2] = x; p.y[2] = y + s; + return p; +} + +static void add(FgPrimList *l, FgPrim p) { p.rec = l->n; check(fg_prims_add(l, &p), "add"); } + +static void test_identical_and_moved(void) { + FgPrimList a = { 0 }, b = { 0 }; + for (int i = 0; i < 200; i++) add(&a, tri((uint32_t)(i % 7) + 1, (float)(i % 20) * 16.0f, (float)(i / 20) * 16.0f, 12.0f)); + for (int i = 0; i < 200; i++) add(&b, tri((uint32_t)(i % 7) + 1, (float)(i % 20) * 16.0f + 3.0f, (float)(i / 20) * 16.0f - 2.0f, 12.0f)); + FgMatchParams mp; + fg_match_defaults(&mp); + int32_t *m = (int32_t *)malloc(sizeof(int32_t) * b.n); + FgMatchStats st; + uint32_t got = fg_match(&a, &b, &mp, m, &st); + check(got == 200 && st.matched == 200 && st.unmatched == 0, "a moved scene matches every triangle"); + int pairs_ok = 1; + for (uint32_t i = 0; i < b.n; i++) if (m[i] != (int32_t)i) pairs_ok = 0; + check(pairs_ok, "each triangle pairs with itself (draw order, nearest)"); + /* Same positions: zero moves. */ + got = fg_match(&a, &a, &mp, m, &st); + check(got == 200 && st.moved == 0, "an unchanged scene matches without motion"); + free(m); + fg_prims_free(&a); fg_prims_free(&b); +} + +static void test_keys_limits_and_duplicates(void) { + FgPrimList a = { 0 }, b = { 0 }; + FgMatchParams mp; + fg_match_defaults(&mp); + /* 0: different key never pairs. 1: moved past max_move. 2: deformed. + * 3/4: two copies of one key; the newer ones swapped in draw order but + * each still nearest its own. */ + add(&a, tri(10, 0, 0, 10)); + add(&a, tri(11, 0, 0, 10)); + add(&a, tri(12, 0, 0, 10)); + add(&a, tri(13, 100, 100, 10)); + add(&a, tri(13, 200, 100, 10)); + add(&b, tri(99, 0, 0, 10)); + add(&b, tri(11, mp.max_move + 10.0f, 0, 10)); + FgPrim d = tri(12, 0, 0, 10); + d.x[1] += mp.max_deform + 8.0f; /* one vertex moves alone */ + add(&b, d); + add(&b, tri(13, 204, 101, 10)); + add(&b, tri(13, 103, 99, 10)); + int32_t m[5]; + FgMatchStats st; + fg_match(&a, &b, &mp, m, &st); + check(m[0] == -1, "a different key does not pair"); + check(m[1] == -1, "a move past max_move does not pair (placed, not moved)"); + check(m[2] == -1, "a triangle whose vertices moved apart does not pair"); + check(m[3] == 4 && m[4] == 3, "duplicate keys pair by distance"); + check(st.matched == 2 && st.unmatched == 3, "match stats"); + fg_prims_free(&a); fg_prims_free(&b); +} + +static void test_each_older_pairs_once(void) { + FgPrimList a = { 0 }, b = { 0 }; + add(&a, tri(5, 0, 0, 10)); + add(&b, tri(5, 0, 0, 10)); + add(&b, tri(5, 1, 0, 10)); + FgMatchParams mp; + fg_match_defaults(&mp); + int32_t m[2]; + fg_match(&a, &b, &mp, m, NULL); + check(m[0] == 0 && m[1] == -1, "an older triangle pairs at most once"); + /* Empty older list: nothing pairs. */ + FgPrimList e = { 0 }; + fg_match(&e, &b, &mp, m, NULL); + check(m[0] == -1 && m[1] == -1, "no history: nothing pairs"); + fg_prims_free(&a); fg_prims_free(&b); +} + +static void test_lerp(void) { + FgPrim a = tri(1, 10, 20, 8), b = tri(1, 30, 10, 8); + float x[3], y[3]; + fg_lerp(&a, &b, 0.0, x, y); + check(x[0] == a.x[0] && y[2] == a.y[2], "phase 0 is the older frame exactly"); + fg_lerp(&a, &b, 1.0, x, y); + check(x[1] == b.x[1] && y[1] == b.y[1], "phase 1 is the newer frame exactly"); + fg_lerp(&a, &b, 0.25, x, y); + check(fabsf(x[0] - 15.0f) < 1e-5f && fabsf(y[0] - 17.5f) < 1e-5f, "phase 0.25"); +} + +static void test_plan(void) { + const double f30 = 2.0 / 59.94, f60 = 1.0 / 59.94; + /* 30 Hz game frames on a 120 Hz panel: 4 slots, 3 in-between. */ + check(fg_plan(f30, 120.0, 0.010, 0.002, 0.85, 7) == 3, "30 Hz on 120 Hz: three in-between frames"); + /* 60 Hz game frames on 120 Hz: one. On 60 Hz: none. */ + check(fg_plan(f60, 120.0, 0.004, 0.002, 0.85, 7) == 1, "60 Hz on 120 Hz: one"); + check(fg_plan(f60, 60.0, 0.004, 0.002, 0.85, 7) == 0, "60 Hz on 60 Hz: none"); + check(fg_plan(f30, 60.0, 0.010, 0.002, 0.85, 7) == 1, "30 Hz on 60 Hz: one"); + /* Only what fits: 33.4 ms * 0.85 = 28.4; real 20 ms leaves 8.4 -> two of 4 ms. */ + check(fg_plan(f30, 120.0, 0.020, 0.004, 0.85, 7) == 2, "only what fits"); + check(fg_plan(f30, 120.0, 0.030, 0.001, 0.85, 7) == 0, "no surplus: none"); + /* Unknown generation cost: one, if half the budget is free. */ + check(fg_plan(f30, 120.0, 0.010, 0.0, 0.85, 7) == 1, "unmeasured: one to measure"); + check(fg_plan(f30, 120.0, 0.020, 0.0, 0.85, 7) == 0, "unmeasured without room: none"); + check(fg_plan(f30, 240.0, 0.001, 0.0001, 0.85, 2) == 2, "max_gens caps"); +} + +static void test_breaker(void) { + FgBreaker b; + fg_breaker_init(&b, 3.0, 24.0, 10.0); + check(fg_breaker_open(&b, 0.0), "starts allowing generation"); + fg_breaker_trip(&b, 1.0, "late"); + check(!fg_breaker_open(&b, 3.9) && fg_breaker_open(&b, 4.0), "a trip holds 3 s"); + fg_breaker_trip(&b, 5.0, "late"); /* within 10 s of the last hold's end */ + check(!fg_breaker_open(&b, 10.9) && fg_breaker_open(&b, 11.0), "a repeat doubles the hold"); + fg_breaker_trip(&b, 6.0, "late"); /* already held: the hold stands */ + check(fg_breaker_open(&b, 11.0), "a trip while held does not extend it"); + fg_breaker_trip(&b, 40.0, "late"); /* long after: back to the base hold */ + check(!fg_breaker_open(&b, 42.9) && fg_breaker_open(&b, 43.0), "a later trip starts over"); + check(b.trips == 4 && b.reason && strcmp(b.reason, "late") == 0, "trips counted with a reason"); + for (int i = 0; i < 10; i++) fg_breaker_trip(&b, 43.0 + 30.0 * i + (i ? 0 : 0), "x"); + check(b.hold <= 24.0, "the hold is capped"); +} + +int main(void) { + test_identical_and_moved(); + test_keys_limits_and_duplicates(); + test_each_older_pairs_once(); + test_lerp(); + test_plan(); + test_breaker(); + printf("frame_gen_test: checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c new file mode 100644 index 00000000..fae8b147 --- /dev/null +++ b/runtime/tests/test_gl_frame_gen.c @@ -0,0 +1,278 @@ +/* Frame generation on a real, hidden OpenGL context + * (docs/FRAME_GENERATION.md). Run by run_gl_frame_gen.py with the render + * thread on and frame generation off (argv[2] == 0) or on (1, forced: every + * flip generates, whatever the surplus). A 30 Hz "game" double-buffers at + * x = 0 / x = 512 and presents every VBlank (each game frame twice), paced at + * 60 Hz: a fill, textured / shaded / flat triangles that move a little each + * game frame, a fixed HUD rect, render-to-texture and a VRAM copy outside the + * display, and every fifth frame a triangle that jumps (never matched). + * argv[3] == "wide" presents through a native-wide surface instead of VRAM; + * argv[5] == "late" flips to each frame one VBlank after the next one has + * started drawing (as R4 does). + * Printed: real (digest of every distinct real presented image, in order; a + * present that shows the same image again is not counted: generation replaces + * those with in-between frames), presents, generated, and + * with generation on the endpoint checks: the in-between image composed at + * phase 1 equals the newer real frame's image, at phase 0 the older one's + * (the last two game frames move without a jump, so every triangle pairs). + * Original source-owned scene; no retail payload. */ +static void fixture_present(int generated); +#define GL_PRESENT_TEST_HOOK(gen) fixture_present(gen) +#include "gpu_gl_renderer.c" +#include "mod_texture_banks.c" +uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} +int g_psx_vram_dirty_tracking=0; +uint64_t s_frame_count=0; +void gpu_vram_dirty_mark_row_impl(uint32_t y){(void)y;} +void gpu_vram_dirty_mark_rect(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_vram_dirty_mark_all(void){} +int psx_netplay_active(void){return 0;} +const GpuRenderBackend *vk_backend_get(void){return NULL;} +int gpu_display_is_depth24(void){return 0;} +void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->width=320;out->height=240;} +/* Guest state the backend reads per primitive. It changes with every + * primitive here, so a replay that read it live (instead of the value + * captured when the primitive was recorded) would draw a different image. */ +static int g_prim_id; +static int g_bg_full; +int psx_ws_prim_in_backdrop(void){return (g_prim_id % 3) == 0;} +int psx_ws_prim_is_tagged(void){return (g_prim_id % 5) == 0;} +int gpu_ws_nw_flat_backdrop_enabled(void){return 0;} +int gpu_ws_background_requires_full_composite(void){return g_bg_full;} +int g_ws_tex_edge_pct=0; +void psx_ws_dbg_gate_frame_snapshot(void){} +void gpu_depth24_upload_span_reset(void){} +int gpu_ws_netplay_local_viewport_width(void){return 0;} +int render_pass_netplay_enabled(void){return 0;} +uint64_t psx_cycle_count=0; +uint32_t g_psx_vblank_cycles=564480u; +/* Frontend hooks of the present path (host overlays, pacing, rings). */ +int host_osd_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int host_osd_volume_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int host_osd_needs_present(void){return 0;} +void host_osd_present_done(void){} +int psx_rewind_overlay_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +float psx_rewind_slide(void){return 0.0f;} +int psx_savestate_menu_overlay_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int present_shot_take(char *out,int n){(void)out;(void)n;return 0;} +void present_shot_done(int ok){(void)ok;} +void psx_host_sleep_ms(uint32_t ms){(void)ms;} +/* Called on every present. On the render thread it also stands in for a slow + * swap, so replay runs well behind recording and anything it read live from + * the "guest" (instead of what was recorded) would be a later value. */ +int psx_present_vsync_owns_cadence(void){return 0;} +void latency_ring_mark(LatencyStage s){(void)s;} +void gpu_timeline_note(uint8_t k,uint32_t a,uint32_t b){(void)k;(void)a;(void)b;} + +static uint16_t vram[1024*512]; +static int checks, failures; +static void check(int ok, const char *label) { + checks++; + if (!ok) { fprintf(stderr, "FAIL %s\n", label); failures++; } +} +static uint64_t fnv(const void *pp, size_t n, uint64_t h) { + const uint8_t *p = (const uint8_t *)pp; + for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } + return h; +} + +/* Presented images: every swap (render thread or not) passes here first. */ +static uint64_t real_seq = 0xcbf29ce484222325ull; +static uint64_t n_real = 0, n_gen = 0; +static uint32_t *img_last[2]; /* the last two real images */ +static int img_w, img_h; +static uint64_t read_back(uint32_t *dst) { + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + img_w = ww; img_h = wh; + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glReadPixels(0, 0, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE, dst); + return fnv(dst, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); +} +static void fixture_present(int generated) { + if (generated) { n_gen++; return; } + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + for (int i = 0; i < 2; i++) + if (!img_last[i]) img_last[i] = (uint32_t *)malloc((size_t)ww * wh * 4); + uint32_t *t = img_last[0]; img_last[0] = img_last[1]; img_last[1] = t; + static uint64_t last_d = 0; + uint64_t d = read_back(img_last[1]); + if (d == last_d) { /* the same image shown again: not a new real frame */ + uint32_t *u = img_last[0]; img_last[0] = img_last[1]; img_last[1] = u; + return; + } + last_d = d; + if (getenv("FG_DUMP")) { + printf("R %llu %016llx\n", (unsigned long long)n_real, (unsigned long long)d); + char fn[64]; snprintf(fn, sizeof fn, "real%02llu.rgba", (unsigned long long)n_real); + FILE *f = fopen(fn, "wb"); fwrite(img_last[1], 4, (size_t)ww * wh, f); fclose(f); + } + real_seq = fnv(&d, sizeof d, real_seq); + n_real++; +} + +#define FRAME_W 320 +#define FRAME_H 240 + +static void textures(void) { + static uint16_t page[64*64], clut[16]; + for (int i = 0; i < 64*64; i++) page[i] = (uint16_t)((i * 0x1357u) ^ (i >> 3)); + for (int i = 0; i < 16; i++) clut[i] = (uint16_t)(i ? (0x0421u * (uint16_t)i) | ((i & 1) << 15) : 0); + /* Outside both buffers (x 0..319, 512..831; y 0..239). */ + for (int i = 0; i < 64*64; i++) vram[(i / 64) * 1024 + 896 + i % 64] = page[i]; + gr_vram_transfer_in(896, 0, 64, 64, page); + gr_vram_transfer_in(512, 300, 16, 1, clut); +} + +static int wide_mode; +/* One game frame drawn into buffer bx. Positions move by whole pixels with + * g; every fifth frame triangle 0 jumps across the screen. */ +static void game_frame_part(int g, int bx, int part) { + g_prim_id = -1; + gr_set_draw_area(bx, 0, bx + FRAME_W - 1, FRAME_H - 1); + gr_set_draw_offset(bx, 0); + if (wide_mode) gr_wide_set_target(bx); + if (part == 0) { + gr_fill_rect(bx, 0, FRAME_W, FRAME_H, 0x1084); + if (wide_mode) gr_wide_clear(bx, 0, FRAME_H, 0x1084); + } + gr_set_color_modulation(128, 128, 128, 0); + gr_set_semi_transparency(0, 0); + for (int i = part ? 20 : 0; i < (part ? 40 : 20); i++) { + int x = bx + 10 + (i % 8) * 36 + ((i * 3 + g * (1 + i % 3)) % 9) - 4; + int y = 12 + (i / 8) * 42 + (g * (i % 2 ? 1 : -1)) % 5; + if (i == 0 && g % 5 == 0) { x = bx + 250 - (g * 37) % 200; y = 180; } + switch (i % 4) { + case 0: gr_draw_shaded_textured_triangle(x, y, 0, 0, 0x808080, x + 30, y + 2, 63, 4, 0x6090b0, + x + 3, y + 32, 8, 63, 0xb09060, 512, 300, 0x000e, 0); break; + case 1: gr_draw_textured_triangle(x, y, 4, 4, x + 28, y, 60, 4, x, y + 28, 4, 60, 512, 300, 0x000e); break; + case 2: gr_draw_gouraud_triangle(x, y, 0x7c00, x + 26, y + 4, 0x03e0, x + 6, y + 30, 0x001f); break; + default: gr_draw_flat_triangle(x, y, x + 24, y + 20, x - 4, y + 26, (uint16_t)(0x2108 * (i & 3) + 0x0421)); break; + } + } + if (part == 0) return; + gr_draw_flat_rect(bx + 8, 220, 80, 12, 0x7fff); /* HUD */ + gr_draw_flat_rect(900, 300, 32, 32, (uint16_t)(0x03e0 + g)); /* render to texture */ + gr_copy_rect(bx + 10, 10, 900, 400, 16, 16); /* copy out of the display */ +} + +static void present(int bx) { + if (wide_mode) (void)gl_renderer_present_wide_fbo(bx, 0, FRAME_H, 0); + else gl_renderer_present_vram(bx, 0, FRAME_W, FRAME_H, 0, 0); + gl_renderer_render_thread_frame_boundary(); +} + +int main(int argc, char **argv) { + int scale = argc > 1 ? atoi(argv[1]) : 2; + int fg = argc > 2 && argv[2][0] == '1'; + wide_mode = argc > 3 && !strcmp(argv[3], "wide"); + int frames = argc > 4 ? atoi(argv[4]) : 24; + /* "late": the game flips to a frame one VBlank after it starts drawing + * the next one (as R4 does), so a flip is not a list boundary. */ + int late = argc > 5 && !strcmp(argv[5], "late"); + if (SDL_Init(SDL_INIT_VIDEO) != 0) { + fprintf(stderr, "SKIP no video (%s)\n", SDL_GetError()); + return 77; + } + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#if defined(__APPLE__) + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window *win = SDL_CreateWindow("Frame generation hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) { fprintf(stderr, "SKIP no window (%s)\n", SDL_GetError()); return 77; } + { + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + SDL_GLContext probe = SDL_GL_CreateContext(win); + if (!probe) { + fprintf(stderr, "SKIP no GL 3.3 core context (%s)\n", SDL_GetError()); + return 77; + } + SDL_GL_MakeCurrent(win, NULL); + SDL_GL_DeleteContext(probe); + } + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(scale); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } + textures(); + if (wide_mode) gr_wide_configure(426, 53); + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + if (fg) { + setenv("PSX_FRAME_GEN_FORCE", "1", 1); + gl_renderer_set_frame_generation(1); + check(gl_renderer_frame_generation() == 1, "frame generation on"); + } + gl_renderer_frame_gen_configure(120.0, 59.94); + for (int g = 0; g < frames; g++) { + const int bx = (g & 1) ? 512 : 0, prev = bx ^ 512; + if (late) { + game_frame_part(g, bx, 0); + present(prev); /* flips to the previous frame */ + SDL_Delay(16); + game_frame_part(g, bx, 1); + present(prev); + SDL_Delay(16); + continue; + } + game_frame_part(g, bx, 0); + game_frame_part(g, bx, 1); + present(bx); /* the game frame's first VBlank: a flip */ + SDL_Delay(16); + present(bx); /* its second: the same buffer again */ + SDL_Delay(16); + } + SDL_Delay(60); + gl_renderer_render_thread_sync("test"); /* the render thread parks; we hold the context */ + if (fg) { + char js[1024]; + gl_renderer_frame_gen_json(js, sizeof js); + printf("fg {%s}\n", js); + /* Endpoints: compose (no swap) at phase 1 and 0 between the last two + * game frames and compare with their real images. */ + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + uint32_t *img = (uint32_t *)malloc((size_t)ww * wh * 4); + check(img_last[0] && img_last[1], "two real images kept"); + check(fg_generate(1.0, 0) == 1, "compose at phase 1"); + uint64_t d1 = read_back(img); + uint64_t r1 = fnv(img_last[1], (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + if (getenv("FG_DUMP")) { + FILE *f = fopen("p1.rgba", "wb"); fwrite(img, 4, (size_t)ww * wh, f); fclose(f); + f = fopen("r1.rgba", "wb"); fwrite(img_last[1], 4, (size_t)ww * wh, f); fclose(f); + f = fopen("r0.rgba", "wb"); fwrite(img_last[0], 4, (size_t)ww * wh, f); fclose(f); + printf("dump %dx%d\n", ww, wh); + } + check(d1 == r1, "phase 1 equals the newer real frame"); + check(fg_generate(0.0, 0) == 1, "compose at phase 0"); + uint64_t d0 = read_back(img); + uint64_t r0 = fnv(img_last[0], (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + long diff = 0; + for (int i = 0; i < ww * wh; i++) if (img[i] != img_last[0][i]) diff++; + printf("phase0_diff_px=%ld matched=%u/%u\n", diff, s_fg_mst.matched, s_fg_mst.prims); + check(d0 == r0, "phase 0 equals the older real frame"); + check(fg_generate(0.5, 0) == 1, "compose at phase 0.5"); + uint64_t dm = read_back(img); + if (getenv("FG_DUMP")) { + FILE *f = fopen("pm.rgba", "wb"); fwrite(img, 4, (size_t)ww * wh, f); fclose(f); + } + check(dm != r0 && dm != r1, "phase 0.5 is neither endpoint"); + free(img); + check(n_gen > 0, "generated frames were presented"); + } + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("real=%016llx\n", (unsigned long long)real_seq); + printf("presents=%llu generated=%llu\n", (unsigned long long)n_real, (unsigned long long)n_gen); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); + SDL_DestroyWindow(win); + SDL_Quit(); + return failures ? 1 : 0; +} diff --git a/runtime/tests/test_render_thread.c b/runtime/tests/test_render_thread.c index 2353be2b..e7554d77 100644 --- a/runtime/tests/test_render_thread.c +++ b/runtime/tests/test_render_thread.c @@ -234,12 +234,59 @@ static void test_stop_while_held(void) { rt_stop(); } +/* 6. Timed work (frame generation's hook): an exec asks for a tick 5 ms out + * while the ring then goes idle; the idle render thread wakes for it (not + * only when records arrive), holds the context, and each tick can ask for + * the next. Ticks never run before their deadline. */ +static _Atomic int tick_count, tick_early, tick_no_ctx, tick_off_thread; +static uint64_t tick_due; +static uint64_t fake_tick(void *user, uint64_t now) { + (void)user; + if (now < tick_due) atomic_fetch_add(&tick_early, 1); + if (atomic_load(&ctx_owner) != 2) atomic_fetch_add(&tick_no_ctx, 1); + if (!rt_on_render_thread()) atomic_fetch_add(&tick_off_thread, 1); + int n = atomic_fetch_add(&tick_count, 1) + 1; + if (n >= 4) return 0; + tick_due = now + 2000000u; + return tick_due; +} +static void tick_exec(void *user, const RtCmd *c, const void *payload) { + fake_exec(user, c, payload); + if (c->op == OP_PRESENT) { + tick_due = rt_now_ns() + 5000000u; + rt_tick_at(tick_due); + } +} +static void test_tick(void) { + reset_state(); + atomic_store(&tick_count, 0); + g_max_frames = 2; + RtConfig cfg = { 1u << 20, 2, tick_exec, fake_ctx, NULL, fake_tick }; + CHECK(rt_start(&cfg), "start with tick"); + void *p = rt_cmd_begin(OP_PRESENT, 0, 16); + CHECK(p != NULL, "present record"); + rt_cmd_commit(); + rt_frame_end(); + const uint64_t t0 = rt_now_ns(); + while (atomic_load(&tick_count) < 4 && rt_now_ns() - t0 < 2000000000ull) sleep_us(500); + const uint64_t el = rt_now_ns() - t0; + CHECK(atomic_load(&tick_count) == 4, "four ticks while idle (%d)", atomic_load(&tick_count)); + CHECK(el >= 10000000u, "ticks waited for their deadlines (%.1f ms)", (double)el * 1e-6); + CHECK(atomic_load(&tick_early) == 0, "no tick before its deadline"); + CHECK(atomic_load(&tick_no_ctx) == 0 && atomic_load(&tick_off_thread) == 0, + "ticks on the render thread, holding the context"); + sleep_us(20000); + CHECK(atomic_load(&tick_count) == 4, "no tick after 0 was returned"); + rt_stop(); +} + int main(void) { test_order_and_wrap(); test_oversize(); test_backpressure(); test_acquire_release(); test_stop_while_held(); + test_tick(); printf("checks=%d failures=%d\n", checks, failures); return failures ? 1 : 0; } From cb10c49834a7a63865699d1123fe6849d3f743c0 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:53:17 -0700 Subject: [PATCH 39/57] Regenerate the OpenBIOS emitter stamp Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index e6c3e858..95abf5dd 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -1dfc076d921b1165f6390d2926bad58b3c0528cafd25594528d603e1fcdd55bd +69c42630d0ae700cdb02eea8481bed9a538593caa99f5a979b26ab8e8ad71f17 From 49c2f31120943daab3e171c827cdc51dc8785f4b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:56:48 -0700 Subject: [PATCH 40/57] Test: frame generation GL fixture stubs the HD texture pack hooks; TCP index master's HD texture packs (#557 base) added gpu_hd_textures_* calls to the GL renderer and the gpu_render.c facade; the fixture runs without a pack. The command index is regenerated for frame_gen. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 5 +++-- runtime/tests/test_gl_frame_gen.c | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 6a643202..c9028a2f 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -479,9 +479,9 @@ between Play and TCP availability. ## Complete command index (generated) -**358 commands registered** — 345 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**359 commands registered** — 346 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -72 of 358 have prose above; **286 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +72 of 359 have prose above; **287 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -602,6 +602,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `fntrace_unfiltered` | | ✓ | | | `frame` | ✓ | | ✓ | | `frame_fingerprint` | ✓ | | ✓ | +| `frame_gen` | ✓ | | | | `frame_perf` | ✓ | | | | `frame_range` | ✓ | ✓ | ✓ | | `frame_timeseries` | ✓ | ✓ | ✓ | diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index fae8b147..d15184be 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -19,7 +19,17 @@ static void fixture_present(int generated); #define GL_PRESENT_TEST_HOOK(gen) fixture_present(gen) #include "gpu_gl_renderer.c" +#include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" +/* gpu_render.c (the facade) observes draws for HD texture packs: no pack here. */ +int gpu_hd_textures_active(void){return 0;} +int gpu_hd_textures_dump_enabled(void){return 0;} +void gpu_hd_textures_observe_draw(uint16_t tp,uint16_t cx,uint16_t cy,const int l[4], + uint32_t w,int semi){(void)tp;(void)cx;(void)cy;(void)l;(void)w;(void)semi;} +void gpu_hd_textures_set_vram(const uint16_t* v){(void)v;} +void gpu_hd_textures_track_upload(int x,int y,int w,int h,const uint16_t* words){ + (void)x;(void)y;(void)w;(void)h;(void)words;} +void gpu_hd_textures_invalidate(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; From d18c3162f2144781a29abb9f84781618c6bd836c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:32:35 -0700 Subject: [PATCH 41/57] Test: frame generation GL fixture stubs the HD upload/copy hooks master's facade now calls gpu_hd_textures_begin_upload/begin_copy/end_copy; the fixture runs without a pack, so it stubs them like the other hooks. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_gl_frame_gen.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index d15184be..f9283900 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -30,6 +30,10 @@ void gpu_hd_textures_set_vram(const uint16_t* v){(void)v;} void gpu_hd_textures_track_upload(int x,int y,int w,int h,const uint16_t* words){ (void)x;(void)y;(void)w;(void)h;(void)words;} void gpu_hd_textures_invalidate(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_hd_textures_begin_upload(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_hd_textures_begin_copy(int sx,int sy,int dx,int dy,int w,int h){ + (void)sx;(void)sy;(void)dx;(void)dy;(void)w;(void)h;} +void gpu_hd_textures_end_copy(void){} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; From e3d8994099707003bd5ccf18c479f3bfb7da234f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:59:00 -0700 Subject: [PATCH 42/57] Present thread: swap off the render thread ([video] present_thread) Opt-in under the render thread. Composed frames go to a ring of offscreen slots at the window's size (binds of framebuffer 0 on the composing context land on the current slot); a present thread with a second, shared context on the same window waits on the slot's fence, copies it 1:1 and swaps. The compositor's wait moves off rendering; the composing side waits only while every slot is queued or on screen. Falls back to direct swaps when the shared context or sync objects are unavailable. Co-Authored-By: Claude Opus 5.5 --- recompiler/src/config_loader.cpp | 3 + recompiler/src/config_loader.h | 8 + .../tests/video_enhancement_settings_test.cpp | 16 ++ runtime/CMakeLists.txt | 7 + runtime/include/gpu_gl_renderer.h | 8 + runtime/include/present_thread.h | 80 ++++++ runtime/runtime.cmake | 1 + runtime/src/gpu_gl_renderer.c | 263 +++++++++++++++++- runtime/src/main.cpp | 74 ++--- runtime/src/present_thread.c | 238 ++++++++++++++++ runtime/tests/run_gl_frame_gen.py | 50 +++- runtime/tests/run_gl_readback_region.py | 2 + runtime/tests/run_gl_render_thread.py | 2 +- runtime/tests/run_gl_scale_invariance.py | 2 + runtime/tests/run_gl_texture_filter.py | 2 + runtime/tests/test_gl_frame_gen.c | 46 ++- runtime/tests/test_present_thread.c | 128 +++++++++ 17 files changed, 891 insertions(+), 39 deletions(-) create mode 100644 runtime/include/present_thread.h create mode 100644 runtime/src/present_thread.c create mode 100644 runtime/tests/test_present_thread.c diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 6ef03792..f089e553 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -802,6 +802,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("render_thread")) { rt.video_render_thread = toml::find(video, "render_thread"); } + if (video.contains("present_thread")) { + rt.video_present_thread = toml::find(video, "present_thread"); + } if (video.contains("frame_generation")) { rt.video_frame_generation = toml::find(video, "frame_generation"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index ebaa58a8..60895332 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -583,6 +583,14 @@ struct RuntimeConfig { // PSX_FRAME_GEN=0/1 overrides. bool video_frame_generation = false; + // present_thread: with render_thread, composed frames go to offscreen + // slots and a present thread (second, shared GL context on the window) + // does the copy and the swap, so the window compositor's wait does not + // block rendering (docs/RENDER_THREAD.md "Present thread"). Same pixels. + // Off by default; PSX_PRESENT_THREAD=0/1 overrides, + // PSX_PRESENT_THREAD_SLOTS=2..4 (default 3). + bool video_present_thread = false; + // low_latency_input: re-sample the pad after the wall-clock pacer (just // before present) so the next CPU frame reads near-fresh input instead of // input ~one frame stale. Default on. vsync: present/swap mode — diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index a775acfd..39cb4264 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -335,6 +335,21 @@ static void test_render_thread() { fs::remove(q); } +/* [video] present_thread (docs/RENDER_THREAD.md): opt-in, off by default. */ +static void test_present_thread() { + fs::path p = write_game_toml("psxrecomp_pt_default.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(!gc.runtime.video_present_thread, "present_thread defaults OFF"); + fs::remove(p); + fs::path q = write_game_toml("psxrecomp_pt_on.toml", + "[video]\n" + "render_thread = true\n" + "present_thread = true\n"); + auto gq = PSXRecompV4::load_game_config(q); + check(gq.runtime.video_present_thread, "[video] present_thread = true is honoured"); + fs::remove(q); +} + /* [video] frame_generation (docs/FRAME_GENERATION.md): opt-in, off by default. */ static void test_frame_generation() { fs::path p = write_game_toml("psxrecomp_fg_default.toml", ""); @@ -416,6 +431,7 @@ int main() { test_texture_window_batching(); test_render_thread(); test_frame_generation(); + test_present_thread(); test_pgxp_title_keys(); if (failures) { diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c8de9c7b..0c13be5d 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1159,6 +1159,13 @@ if(BUILD_TESTING) find_package(Threads REQUIRED) target_link_libraries(render_thread_test PRIVATE Threads::Threads) add_test(NAME render_thread_test COMMAND render_thread_test) + # Present thread core ([video] present_thread): submission order is + # presentation order, slots are never composed while queued or on + # screen, done tokens reach the next composer, stop drains. + add_executable(present_thread_test tests/test_present_thread.c src/present_thread.c) + target_include_directories(present_thread_test PRIVATE include) + target_link_libraries(present_thread_test PRIVATE Threads::Threads) + add_test(NAME present_thread_test COMMAND present_thread_test) endif() # Same family of bug as frame_pacing: the starvation watchdog's staleness diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 6efa688f..94ae9c57 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -503,6 +503,14 @@ int gl_renderer_render_thread_active(void); void gl_renderer_render_thread_frame_boundary(void); void gl_renderer_render_thread_sync(const char *reason); int gl_renderer_render_thread_json(char *out, size_t cap); +/* [video] present_thread (docs/RENDER_THREAD.md "Present thread"): set before + * gl_renderer_render_thread_start; with the render thread, composed frames go + * to `slots` (2..4) offscreen slots and a present thread with a shared context + * does the copy + swap. Falls back to direct swaps when it cannot start. + * json: the "present_thread" member for the render_thread debug command. */ +void gl_renderer_set_present_thread(int on, int slots); +int gl_renderer_present_thread_active(void); +int gl_renderer_present_thread_json(char *out, size_t cap); int gl_renderer_fbo_peek_deferred(int x, int y, int w, int h, uint16_t *out); void gl_renderer_ring_capture(uint32_t frame, int wide, int base_x, int disp_y, int disp_h, int cx, int cy, int cw, int ch); diff --git a/runtime/include/present_thread.h b/runtime/include/present_thread.h new file mode 100644 index 00000000..9a47d8f0 --- /dev/null +++ b/runtime/include/present_thread.h @@ -0,0 +1,80 @@ +/* present_thread.h — opt-in present thread under the render thread + * ([video] present_thread, docs/RENDER_THREAD.md "Present thread"). + * + * The thread that composes frames (the render thread, or the emulation thread + * while it holds the context at a sync point) draws each presented frame into + * an offscreen slot instead of the window, then hands the slot over; the + * present thread owns a second graphics context on the same window and does + * the final copy and the swap. The swap's wait on the window compositor then + * lands on the present thread, not on rendering. + * + * This core is backend-neutral: a ring of `slots` numbered slots, each FREE, + * COMPOSING (the producer's current slot, exactly one), QUEUED or PRESENTING. + * Queued slots are presented in submission order. The producer blocks in + * pt_submit() only while every other slot is queued or presenting (the + * display is the bottleneck; that wait is the swap's wait, moved). + * + * Fences are opaque: `ready` travels with a submitted slot to present(); + * present() returns a `done` token (e.g. a fence after its copy) that the + * producer receives when it next takes that slot, before drawing into it. + * + * Producer calls (pt_current / pt_submit) may come from different threads + * over time, but never concurrently (the context hand-off orders them). */ +#ifndef PSX_PRESENT_THREAD_H +#define PSX_PRESENT_THREAD_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PT_MAX_SLOTS 4 + +/* Present thread: make the present context current (current != 0, return 0 on + * failure: pt_start then fails) or release it (current == 0). */ +typedef int (*PtCtxFn)(void *user, int current); +/* Present thread: show `slot` (wait for `ready`, copy, swap). Returns the + * slot's done token (may be NULL). */ +typedef void *(*PtPresentFn)(void *user, int slot, void *ready); +/* Present thread, after the last present at stop or for a token nobody will + * collect: dispose of a done token. Optional. */ +typedef void (*PtDisposeFn)(void *user, void *token); + +typedef struct PtConfig { + int slots; /* 2..PT_MAX_SLOTS */ + PtCtxFn ctx; + PtPresentFn present; + PtDisposeFn dispose; /* optional */ + void *user; +} PtConfig; + +/* Start the thread; returns once its ctx(1) has run. 1 on success (the + * producer's current slot is 0), 0 on failure (nothing running). */ +int pt_start(const PtConfig *cfg); +/* Present everything queued, release the context on the present thread, + * join. Done tokens still held are passed to dispose on the present thread + * before it releases its context. Safe when not running. */ +void pt_stop(void); +int pt_running(void); + +/* Producer: the slot being composed. */ +int pt_current(void); +/* Producer: queue the current slot with `ready`, then take the next free slot + * (blocking while none is). *next_done receives that slot's done token (NULL + * if none); *wait_ns the time spent blocked. Returns the new current slot, or + * -1 when not running. */ +int pt_submit(void *ready, void **next_done, uint64_t *wait_ns); +/* Producer: wait until nothing is queued or presenting. */ +void pt_drain(void); + +typedef struct PtStats { + uint64_t submits, presents, waits, wait_ns, present_ns, present_max_ns; + int slots, queued_high; +} PtStats; +void pt_get_stats(PtStats *out); + +#ifdef __cplusplus +} +#endif +#endif /* PSX_PRESENT_THREAD_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 25e86950..f25a845a 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -371,6 +371,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/gpu_vram_dirty.c ${PSXRECOMP_ROOT}/runtime/src/gpu_render.c ${PSXRECOMP_ROOT}/runtime/src/render_thread.c + ${PSXRECOMP_ROOT}/runtime/src/present_thread.c ${PSXRECOMP_ROOT}/runtime/src/frame_gen.c ${PSXRECOMP_ROOT}/runtime/src/gpu_gl_renderer.c ${PSXRECOMP_ROOT}/runtime/src/gpu_hd_textures.cpp diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 186d71d9..39e3d17b 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -95,6 +95,7 @@ #include "psx_rewind.h" #include "psx_openxr.h" #include "openxr_color.h" +#include "present_thread.h" #include "psx_sdl.h" #if defined(PSX_SDL3) @@ -293,6 +294,25 @@ static PFN_glBindRenderbuffer p_glBindRenderbuffer; static PFN_glRenderbufferStorage p_glRenderbufferStorage; static PFN_glFramebufferRenderbuffer p_glFramebufferRenderbuffer; +/* Present thread (docs/RENDER_THREAD.md "Present thread"): while it runs, + * the default framebuffer of the composing context is an offscreen slot. + * Every bind of framebuffer 0 on that context lands on the current slot; the + * present thread's own context binds the real one (p_glBindFramebuffer_raw). */ +typedef void *(APIENTRY *PFN_glFenceSync)(GLenum, GLbitfield); +typedef void (APIENTRY *PFN_glWaitSync)(void *, GLbitfield, uint64_t); +typedef void (APIENTRY *PFN_glDeleteSync)(void *); +static PFN_glFenceSync p_glFenceSync; +static PFN_glWaitSync p_glWaitSync; +static PFN_glDeleteSync p_glDeleteSync; +static PFN_glBindFramebuffer p_glBindFramebuffer_raw; +static int s_pt_on = 0; /* redirect active (present thread running) */ +static volatile int s_pt_interval_gen = 0; /* bumped per swap-interval change */ +static GLuint pt_target_fbo(void); +static void APIENTRY gl_bind_fb_redirect(GLenum target, GLuint fb) { + if (fb == 0 && s_pt_on) fb = pt_target_fbo(); + p_glBindFramebuffer_raw(target, fb); +} + static int load_modern_gl(void) { int ok = 1; #define LOAD(p, n) do { p = (void *)SDL_GL_GetProcAddress(n); if (!p) ok = 0; } while (0) @@ -340,6 +360,14 @@ static int load_modern_gl(void) { /* Optional: only the debug presented-image ring maps pack buffers. */ p_glMapBuffer = (void *)SDL_GL_GetProcAddress("glMapBuffer"); p_glUnmapBuffer = (void *)SDL_GL_GetProcAddress("glUnmapBuffer"); + /* Optional: the present thread needs sync objects (GL 3.2). */ + p_glFenceSync = (void *)SDL_GL_GetProcAddress("glFenceSync"); + p_glWaitSync = (void *)SDL_GL_GetProcAddress("glWaitSync"); + p_glDeleteSync = (void *)SDL_GL_GetProcAddress("glDeleteSync"); + if (p_glBindFramebuffer && p_glBindFramebuffer != gl_bind_fb_redirect) { + p_glBindFramebuffer_raw = p_glBindFramebuffer; + p_glBindFramebuffer = gl_bind_fb_redirect; + } #undef LOAD return ok; } @@ -1296,7 +1324,7 @@ static void pres_record(int path, int dx, int dy, int w, int h, * we pass in are already bottom-origin GL window coords). */ uint8_t px[3] = { 0, 0, 0 }; if (probe_pixels && lw > 0 && lh > 0) { - glReadBuffer(GL_BACK); + glReadBuffer(s_pt_on ? PSXGL_COLOR_ATTACHMENT0 : GL_BACK); glPixelStorei(GL_PACK_ALIGNMENT, 1); glReadPixels(lx + lw / 2, ly + lh / 2, 1, 1, GL_RGB, GL_UNSIGNED_BYTE, px); glPixelStorei(GL_PACK_ALIGNMENT, 4); @@ -5434,6 +5462,7 @@ int gl_renderer_init_context(SDL_Window *win) { void gl_renderer_set_swap_interval(int interval) { GL_RT_SYNC("set_swap_interval"); s_swap_interval = interval; + s_pt_interval_gen++; /* the present thread's context applies it */ if (s_ctx) { if (SDL_GL_SetSwapInterval(interval) != 0 && interval < 0) { SDL_GL_SetSwapInterval(1); @@ -5444,6 +5473,7 @@ void gl_renderer_set_swap_interval(int interval) { int gl_renderer_get_swap_interval(void) { GL_RT_SYNC("get_swap_interval"); if(!s_ctx)return -2; + if(s_pt_on)return s_swap_interval; #if defined(PSX_SDL3) int interval=0; return SDL_GL_GetSwapInterval(&interval)?interval:-2; @@ -7758,7 +7788,7 @@ static int openxr_copy_native(uint32_t eye,uint32_t texture,int w,int h) { int ok=p_glCheckFramebufferStatus(PSXGL_DRAW_FRAMEBUFFER)==PSXGL_FRAMEBUFFER_COMPLETE; if(ok) { int *r=s_native_surface_rect; - glDisable(GL_SCISSOR_TEST);p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,0);glReadBuffer(GL_BACK); + glDisable(GL_SCISSOR_TEST);p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,0);glReadBuffer(s_pt_on?PSXGL_COLOR_ATTACHMENT0:GL_BACK); glDisable(0x8DB9); /* Default framebuffer is already upright: unlike native VRAM/eye * textures, its bottom GL row is the displayed bottom row. */ @@ -8192,6 +8222,227 @@ static int ov_needs_present(void) { } static uint64_t s_swaps_total = 0; /* every swap, real or generated (diagnostic) */ + +/* ---- Present thread ([video] present_thread) ------------------------------- + * Opt-in under the render thread. Each composed frame (everything that used + * to land in the window's back buffer: the display quad, OSD, captures) goes + * to an offscreen slot of the window's size; the swap becomes "fence, queue + * the slot, take the next free one". The present thread owns a second context + * on the same window, sharing objects with s_ctx, and does the 1:1 copy and + * the real swap, so the compositor's wait no longer blocks rendering. The + * composing side waits only while every slot is queued or on screen. */ +#define PT_SYNC_GPU_COMMANDS_COMPLETE 0x9117 +#define PT_TIMEOUT_IGNORED 0xFFFFFFFFFFFFFFFFull +static int s_pt_want = 0, s_pt_slots = 3; +static GLenum s_pt_format = GL_RGBA8; /* the window's: no alpha -> GL_RGB8 */ +static SDL_GLContext s_pt_ctx = NULL; +static GLuint s_pts_tex[PT_MAX_SLOTS], s_pts_rb[PT_MAX_SLOTS], s_pts_fbo[PT_MAX_SLOTS]; +static int s_pts_w[PT_MAX_SLOTS], s_pts_h[PT_MAX_SLOTS]; +static unsigned s_pts_gen[PT_MAX_SLOTS]; +/* present thread's own objects (FBOs are per-context) */ +static GLuint s_ptp_fbo[PT_MAX_SLOTS]; +static unsigned s_ptp_gen[PT_MAX_SLOTS]; +static int s_ptp_interval_gen = -1; + +void gl_renderer_set_present_thread(int on, int slots) { + s_pt_want = on ? 1 : 0; + s_pt_slots = slots < 2 ? 2 : slots > PT_MAX_SLOTS ? PT_MAX_SLOTS : slots; +} +int gl_renderer_present_thread_active(void) { return s_pt_on; } + +/* Composing context: the current slot, (re)allocated at the window's size. */ +static GLuint pt_target_fbo(void) { + const int i = pt_current(); + if (i < 0) return 0; + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww < 1) ww = 1; + if (wh < 1) wh = 1; + if (s_pts_fbo[i] && s_pts_w[i] == ww && s_pts_h[i] == wh) return s_pts_fbo[i]; + GLint tex_prev = 0, rb_prev = 0; + glGetIntegerv(GL_TEXTURE_BINDING_2D, &tex_prev); + glGetIntegerv(0x8CA7 /* GL_RENDERBUFFER_BINDING */, &rb_prev); + if (s_pts_fbo[i]) { + p_glDeleteFramebuffers(1, &s_pts_fbo[i]); + p_glDeleteRenderbuffers(1, &s_pts_rb[i]); + glDeleteTextures(1, &s_pts_tex[i]); + s_pts_fbo[i] = s_pts_rb[i] = s_pts_tex[i] = 0; + } + s_pts_tex[i] = make_tex(s_pt_format, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE); + p_glGenRenderbuffers(1, &s_pts_rb[i]); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_pts_rb[i]); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, ww, wh); + p_glGenFramebuffers(1, &s_pts_fbo[i]); + p_glBindFramebuffer_raw(PSXGL_FRAMEBUFFER, s_pts_fbo[i]); + p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, s_pts_tex[i], 0); + p_glFramebufferRenderbuffer(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, + PSXGL_RENDERBUFFER, s_pts_rb[i]); + if (p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER) != PSXGL_FRAMEBUFFER_COMPLETE) + fprintf(stdout, "psxrecomp: present thread: slot FBO incomplete\n"); + glBindTexture(GL_TEXTURE_2D, (GLuint)tex_prev); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, (GLuint)rb_prev); + s_pts_w[i] = ww; s_pts_h[i] = wh; + s_pts_gen[i]++; + return s_pts_fbo[i]; +} + +static int pt_cb_ctx(void *user, int current) { + (void)user; + if (current) { + if (SDL_GL_MakeCurrent(s_win, s_pt_ctx) != 0) return 0; + s_ptp_interval_gen = -1; + return 1; + } + for (int i = 0; i < PT_MAX_SLOTS; i++) { + if (s_ptp_fbo[i]) p_glDeleteFramebuffers(1, &s_ptp_fbo[i]); + s_ptp_fbo[i] = 0; + s_ptp_gen[i] = 0; + } + glFinish(); + SDL_GL_MakeCurrent(s_win, NULL); + return 1; +} + +static void *pt_cb_present(void *user, int slot, void *ready) { + (void)user; + if (s_ptp_interval_gen != s_pt_interval_gen) { + s_ptp_interval_gen = s_pt_interval_gen; + if (SDL_GL_SetSwapInterval(s_swap_interval) != 0 && s_swap_interval < 0) + SDL_GL_SetSwapInterval(1); + } + if (ready) { p_glWaitSync(ready, 0, PT_TIMEOUT_IGNORED); p_glDeleteSync(ready); } + if (!s_ptp_fbo[slot] || s_ptp_gen[slot] != s_pts_gen[slot]) { + if (!s_ptp_fbo[slot]) p_glGenFramebuffers(1, &s_ptp_fbo[slot]); + p_glBindFramebuffer_raw(PSXGL_READ_FRAMEBUFFER, s_ptp_fbo[slot]); + p_glFramebufferTexture2D(PSXGL_READ_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + s_pts_tex[slot], 0); + s_ptp_gen[slot] = s_pts_gen[slot]; + } + const int sw = s_pts_w[slot], sh = s_pts_h[slot]; + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + p_glBindFramebuffer_raw(PSXGL_READ_FRAMEBUFFER, s_ptp_fbo[slot]); + p_glBindFramebuffer_raw(PSXGL_DRAW_FRAMEBUFFER, 0); + glDisable(GL_SCISSOR_TEST); + if (ww == sw && wh == sh) { + p_glBlitFramebuffer(0, 0, sw, sh, 0, 0, sw, sh, GL_COLOR_BUFFER_BIT, GL_NEAREST); + } else { + /* The window changed after this frame was composed: fit it. */ + glViewport(0, 0, ww, wh); + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + p_glBlitFramebuffer(0, 0, sw, sh, 0, 0, ww, wh, GL_COLOR_BUFFER_BIT, GL_LINEAR); + } +#ifdef GL_PRESENT_THREAD_TEST_HOOK + GL_PRESENT_THREAD_TEST_HOOK(slot); +#endif + void *done = p_glFenceSync(PT_SYNC_GPU_COMMANDS_COMPLETE, 0); + SDL_GL_SwapWindow(s_win); + return done; +} + +static void pt_cb_dispose(void *user, void *token) { + (void)user; + if (token) p_glDeleteSync(token); +} + +/* Composing context current; 1 when the present thread runs. */ +static int pt_begin(void) { + if (!s_pt_want || s_pt_on || !s_win || !s_ctx) return 0; + if (!p_glFenceSync || !p_glWaitSync || !p_glDeleteSync) { + fprintf(stdout, "psxrecomp: present thread: no sync objects; swaps stay on the render thread\n"); + return 0; + } + /* The slots stand in for the window's back buffer: same channels, so + * destination alpha and readbacks behave as they did there. */ + { + GLint a = 8; + typedef void (APIENTRY *PFN_gfap)(GLenum, GLenum, GLenum, GLint *); + PFN_gfap gfap = (PFN_gfap)SDL_GL_GetProcAddress("glGetFramebufferAttachmentParameteriv"); + p_glBindFramebuffer_raw(PSXGL_FRAMEBUFFER, 0); + while (glGetError() != GL_NO_ERROR) {} + if (gfap) gfap(PSXGL_FRAMEBUFFER, GL_BACK_LEFT, 0x8215 /* ALPHA_SIZE */, &a); + if (glGetError() != GL_NO_ERROR) a = 8; + s_pt_format = a > 0 ? GL_RGBA8 : GL_RGB8; + } + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); + s_pt_ctx = SDL_GL_CreateContext(s_win); + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); + if (!s_pt_ctx) { + fprintf(stdout, "psxrecomp: present thread: shared context failed (%s); swaps stay on " + "the render thread\n", SDL_GetError()); + SDL_GL_MakeCurrent(s_win, s_ctx); + return 0; + } + SDL_GL_MakeCurrent(s_win, NULL); /* creation made it current here */ + SDL_GL_MakeCurrent(s_win, s_ctx); + memset(s_pts_fbo, 0, sizeof s_pts_fbo); + memset(s_ptp_fbo, 0, sizeof s_ptp_fbo); + PtConfig c = { s_pt_slots, pt_cb_ctx, pt_cb_present, pt_cb_dispose, NULL }; + if (!pt_start(&c)) { + fprintf(stdout, "psxrecomp: present thread: start failed; swaps stay on the render thread\n"); + SDL_GL_DeleteContext(s_pt_ctx); + s_pt_ctx = NULL; + SDL_GL_MakeCurrent(s_win, s_ctx); + return 0; + } + s_pt_on = 1; + fprintf(stdout, "psxrecomp: present thread on (%d slots)\n", s_pt_slots); + return 1; +} + +/* Composing context current, render thread stopped. */ +static void pt_end(void) { + if (!s_pt_on) return; + glFlush(); + pt_stop(); + s_pt_on = 0; + for (int i = 0; i < PT_MAX_SLOTS; i++) { + if (s_pts_fbo[i]) { + p_glDeleteFramebuffers(1, &s_pts_fbo[i]); + p_glDeleteRenderbuffers(1, &s_pts_rb[i]); + glDeleteTextures(1, &s_pts_tex[i]); + } + s_pts_fbo[i] = s_pts_rb[i] = s_pts_tex[i] = 0; + s_pts_w[i] = s_pts_h[i] = 0; + } + SDL_GL_DeleteContext(s_pt_ctx); + s_pt_ctx = NULL; + SDL_GL_MakeCurrent(s_win, s_ctx); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); +} + +/* The swap: direct, or queued to the present thread. */ +static void gl_present_swap(void) { + if (!s_pt_on) { SDL_GL_SwapWindow(s_win); return; } + (void)pt_target_fbo(); /* a frame that never bound 0 still has its slot */ + void *ready = p_glFenceSync(PT_SYNC_GPU_COMMANDS_COMPLETE, 0); + glFlush(); + void *done = NULL; + (void)pt_submit(ready, &done, NULL); + if (done) { p_glWaitSync(done, 0, PT_TIMEOUT_IGNORED); p_glDeleteSync(done); } + /* Whatever is bound as the window now means the new slot. */ + GLint d = 0, r = 0; + glGetIntegerv(0x8CA6 /* DRAW_FRAMEBUFFER_BINDING */, &d); + glGetIntegerv(0x8CAA /* READ_FRAMEBUFFER_BINDING */, &r); + for (int i = 0; i < PT_MAX_SLOTS; i++) { + if (s_pts_fbo[i] && (GLuint)d == s_pts_fbo[i]) p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + if (s_pts_fbo[i] && (GLuint)r == s_pts_fbo[i]) p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + } +} + +int gl_renderer_present_thread_json(char *out, size_t cap) { + PtStats st; + pt_get_stats(&st); + return snprintf(out, cap, + "\"present_thread\":{\"active\":%d,\"slots\":%d,\"submits\":%llu,\"presents\":%llu," + "\"waits\":%llu,\"wait_ms\":%.3f,\"swap_ms_avg\":%.3f,\"swap_ms_max\":%.3f," + "\"queued_high\":%d}", + s_pt_on, st.slots, (unsigned long long)st.submits, (unsigned long long)st.presents, + (unsigned long long)st.waits, st.wait_ns / 1e6, + st.presents ? (double)st.present_ns / (double)st.presents / 1e6 : 0.0, + st.present_max_ns / 1e6, st.queued_high); +} static void gl_swap_now(int generated); static int fg_keep_real(void); /* frame generation */ static void fg_note_swap(uint64_t ns); @@ -8290,7 +8541,7 @@ static void gl_swap_now(int generated) { s_swaps_total++; uint64_t t0 = s_dyn_on ? SDL_GetPerformanceCounter() : 0; const uint64_t rthf_t0 = rth_replaying() ? host_now_ns_rthf() : 0; - SDL_GL_SwapWindow(s_win); + gl_present_swap(); if (rthf_t0) { const uint64_t sw = host_now_ns_rthf() - rthf_t0; s_rthf_swap_ns += sw; @@ -11093,7 +11344,9 @@ int gl_renderer_render_thread_start(int max_frames) { cfg.user = NULL; cfg.tick = fg_tick; fg_invalidate(); + (void)pt_begin(); /* [video] present_thread; falls back to direct swaps */ if (!rt_start(&cfg)) { + pt_end(); s_rth_on = 0; s_vram = s_rth_vram_pub; sw_renderer_rebind_vram(s_rth_vram_pub); @@ -11108,6 +11361,7 @@ int gl_renderer_render_thread_start(int max_frames) { void gl_renderer_render_thread_stop(void) { if (!s_rth_on) return; rt_stop(); /* drains; the context is current here again */ + pt_end(); s_rth_on = 0; s_vram = s_rth_vram_pub; sw_renderer_rebind_vram(s_rth_vram_pub); @@ -11164,7 +11418,8 @@ int gl_renderer_render_thread_json(char *out, size_t cap) { k += snprintf(out + k, cap - (size_t)k, "%s{\"frame\":%llu,\"wait_us\":%.1f,\"reason\":\"%s\"}", i ? "," : "", (unsigned long long)ev[i].frame, ev[i].wait_ns / 1e3, ev[i].reason); - if (k > 0 && (size_t)k < cap) k += snprintf(out + k, cap - (size_t)k, "]"); + if (k > 0 && (size_t)k < cap) k += snprintf(out + k, cap - (size_t)k, "],"); + if (k > 0 && (size_t)k < cap) k += gl_renderer_present_thread_json(out + k, cap - (size_t)k); return k; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 6ec628cb..8781026b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1459,6 +1459,9 @@ static int g_render_thread = 0; static int g_render_thread_frames = 2; /* [video] frame_generation (docs/FRAME_GENERATION.md), with the render thread. */ static int g_frame_generation = 0; +/* [video] present_thread (docs/RENDER_THREAD.md), with the render thread. */ +static int g_present_thread = 0; +static int g_present_thread_slots = 3; static int s_render_thread_tried = 0; static int g_video_vsync = 1; static int g_frame_interpolation = 0; @@ -9212,6 +9215,40 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { return ep; } +/* Render thread: start once, then close every frame (after its present, + * before pacing, so the render thread draws while the guest waits). */ +static void render_thread_vblank(void) { +#ifndef PSX_SDL_NO_RENDER + if (!g_render_thread) return; + if (!s_render_thread_tried) { + s_render_thread_tried = 1; + gl_renderer_set_present_thread(g_present_thread, g_present_thread_slots); + if (g_gl_active && gr_backend() == GR_BACKEND_OPENGL && + gl_renderer_render_thread_start(g_render_thread_frames)) { + std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", + g_render_thread_frames); + if (g_frame_generation) { + gl_renderer_set_frame_generation(1); + std::fprintf(stdout, "psxrecomp: frame generation on (render thread, " + "from surplus only)\n"); + } + } else { + std::fprintf(stdout, "psxrecomp: render thread requested but not started " + "(needs the OpenGL backend without netplay, frame " + "interpolation or a 24-bit display)\n"); + if (g_frame_generation) + std::fprintf(stdout, "psxrecomp: frame generation needs the render thread; off\n"); + } + std::fflush(stdout); + } + if (g_frame_generation) + gl_renderer_frame_gen_configure(g_host_refresh_hz, + g_guest_frame_period_ms > 0.0 + ? 1000.0 / g_guest_frame_period_ms : 0.0); + gl_renderer_render_thread_frame_boundary(); +#endif +} + /* ---- Dynamic internal resolution: the host side --------------------------- * [video] dynamic_resolution (docs/ENHANCEMENTS.md, IR3). The controller * (dynamic_resolution.c) decides; this feeds it one sample per guest VBlank, @@ -9577,38 +9614,6 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { c.down_block_until > now_s ? c.down_block_until - now_s : 0.0, blocked); } -/* Render thread: start once, then close every frame (after its present, - * before pacing, so the render thread draws while the guest waits). */ -static void render_thread_vblank(void) { -#ifndef PSX_SDL_NO_RENDER - if (!g_render_thread) return; - if (!s_render_thread_tried) { - s_render_thread_tried = 1; - if (g_gl_active && gr_backend() == GR_BACKEND_OPENGL && - gl_renderer_render_thread_start(g_render_thread_frames)) { - std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", - g_render_thread_frames); - if (g_frame_generation) { - gl_renderer_set_frame_generation(1); - std::fprintf(stdout, "psxrecomp: frame generation on (render thread, " - "from surplus only)\n"); - } - } else { - std::fprintf(stdout, "psxrecomp: render thread requested but not started " - "(needs the OpenGL backend without netplay, frame " - "interpolation or a 24-bit display)\n"); - if (g_frame_generation) - std::fprintf(stdout, "psxrecomp: frame generation needs the render thread; off\n"); - } - std::fflush(stdout); - } - if (g_frame_generation) - gl_renderer_frame_gen_configure(g_host_refresh_hz, - g_guest_frame_period_ms > 0.0 - ? 1000.0 / g_guest_frame_period_ms : 0.0); - gl_renderer_render_thread_frame_boundary(); -#endif -} static void sdl_vblank_present(void) { sync_guest_cadence_to_video_standard(); @@ -15717,6 +15722,7 @@ int main(int argc, char** argv) { gc.runtime.video_texture_window_batching ? 1 : 0); g_render_thread = gc.runtime.video_render_thread ? 1 : 0; g_frame_generation = gc.runtime.video_frame_generation ? 1 : 0; + g_present_thread = gc.runtime.video_present_thread ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; @@ -18023,6 +18029,10 @@ session_reboot: * flight (default 2). */ if (const char* e = std::getenv("PSX_RENDER_THREAD")) g_render_thread = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_PRESENT_THREAD")) + g_present_thread = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_PRESENT_THREAD_SLOTS")) + g_present_thread_slots = std::atoi(e) > 0 ? std::atoi(e) : 3; if (const char* e = std::getenv("PSX_FRAME_GEN")) g_frame_generation = (*e && *e != '0') ? 1 : 0; if (const char* e = std::getenv("PSX_RENDER_THREAD_FRAMES")) diff --git a/runtime/src/present_thread.c b/runtime/src/present_thread.c new file mode 100644 index 00000000..3a6a6552 --- /dev/null +++ b/runtime/src/present_thread.c @@ -0,0 +1,238 @@ +/* present_thread.c — present thread core. See present_thread.h and + * docs/RENDER_THREAD.md ("Present thread"). + * + * Presents run at display rate (tens to a few hundred per second), so one + * mutex guards all slot state; nothing is held while present() runs. */ + +#include "present_thread.h" + +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +typedef SRWLOCK pt_mutex; +typedef CONDITION_VARIABLE pt_cond; +static void mtx_init(pt_mutex *m) { InitializeSRWLock(m); } +static void mtx_lock(pt_mutex *m) { AcquireSRWLockExclusive(m); } +static void mtx_unlock(pt_mutex *m) { ReleaseSRWLockExclusive(m); } +static void cnd_init(pt_cond *c) { InitializeConditionVariable(c); } +static void cnd_wait(pt_cond *c, pt_mutex *m) { SleepConditionVariableSRW(c, m, INFINITE, 0); } +static void cnd_bcast(pt_cond *c) { WakeAllConditionVariable(c); } +static uint64_t now_ns(void) { + LARGE_INTEGER f, t; + QueryPerformanceFrequency(&f); + QueryPerformanceCounter(&t); + return (uint64_t)((double)t.QuadPart * 1e9 / (double)f.QuadPart); +} +typedef HANDLE pt_thread; +#else +# include +# if defined(__APPLE__) +# include +# endif +# include +typedef pthread_mutex_t pt_mutex; +typedef pthread_cond_t pt_cond; +static void mtx_init(pt_mutex *m) { pthread_mutex_init(m, NULL); } +static void mtx_lock(pt_mutex *m) { pthread_mutex_lock(m); } +static void mtx_unlock(pt_mutex *m) { pthread_mutex_unlock(m); } +static void cnd_init(pt_cond *c) { pthread_cond_init(c, NULL); } +static void cnd_wait(pt_cond *c, pt_mutex *m) { pthread_cond_wait(c, m); } +static void cnd_bcast(pt_cond *c) { pthread_cond_broadcast(c); } +static uint64_t now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} +typedef pthread_t pt_thread; +#endif + +enum { S_FREE = 0, S_COMPOSING, S_QUEUED, S_PRESENTING }; + +static struct { + PtConfig cfg; + pt_mutex mtx; + pt_cond cv; /* any state change; both sides wait on it */ + pt_thread thread; + int running, stop, init_done, init_ok; + int state[PT_MAX_SLOTS]; + void *ready[PT_MAX_SLOTS]; + void *done[PT_MAX_SLOTS]; + int queue[PT_MAX_SLOTS]; + int q_head, q_len; + int current; + PtStats st; +} P; +static int s_pt_inited = 0; + +static void *present_loop(void) { + int ok = P.cfg.ctx(P.cfg.user, 1); + mtx_lock(&P.mtx); + P.init_done = 1; + P.init_ok = ok; + cnd_bcast(&P.cv); + if (!ok) { mtx_unlock(&P.mtx); return NULL; } + for (;;) { + while (!P.q_len && !P.stop) cnd_wait(&P.cv, &P.mtx); + if (!P.q_len) break; /* stop with nothing queued */ + const int slot = P.queue[P.q_head]; + P.q_head = (P.q_head + 1) % PT_MAX_SLOTS; + P.q_len--; + P.state[slot] = S_PRESENTING; + void *ready = P.ready[slot]; + P.ready[slot] = NULL; + mtx_unlock(&P.mtx); + const uint64_t t0 = now_ns(); + void *done = P.cfg.present(P.cfg.user, slot, ready); + const uint64_t dt = now_ns() - t0; + mtx_lock(&P.mtx); + P.st.presents++; + P.st.present_ns += dt; + if (dt > P.st.present_max_ns) P.st.present_max_ns = dt; + P.done[slot] = done; + P.state[slot] = S_FREE; + cnd_bcast(&P.cv); + } + /* Tokens nobody will collect any more. */ + for (int i = 0; i < P.cfg.slots; i++) { + if (P.done[i] && P.cfg.dispose) P.cfg.dispose(P.cfg.user, P.done[i]); + P.done[i] = NULL; + } + mtx_unlock(&P.mtx); + P.cfg.ctx(P.cfg.user, 0); + return NULL; +} + +#ifdef _WIN32 +static DWORD WINAPI present_main(LPVOID a) { (void)a; present_loop(); return 0; } +#else +static void *present_main(void *a) { (void)a; return present_loop(); } +#endif + +int pt_start(const PtConfig *cfg) { + if (P.running || !cfg || !cfg->ctx || !cfg->present) return 0; + if (cfg->slots < 2 || cfg->slots > PT_MAX_SLOTS) return 0; + if (!s_pt_inited) { mtx_init(&P.mtx); cnd_init(&P.cv); s_pt_inited = 1; } + mtx_lock(&P.mtx); + P.cfg = *cfg; + P.stop = P.init_done = P.init_ok = 0; + P.q_head = P.q_len = 0; + for (int i = 0; i < PT_MAX_SLOTS; i++) { + P.state[i] = S_FREE; + P.ready[i] = P.done[i] = NULL; + } + P.current = 0; + P.state[0] = S_COMPOSING; + memset(&P.st, 0, sizeof P.st); + P.st.slots = cfg->slots; + mtx_unlock(&P.mtx); + int ok; +#ifdef _WIN32 + P.thread = CreateThread(NULL, 0, present_main, NULL, 0, NULL); + ok = P.thread != NULL; + if (ok) SetThreadPriority(P.thread, THREAD_PRIORITY_ABOVE_NORMAL); +#else + pthread_attr_t attr; + pthread_attr_init(&attr); +# if defined(__APPLE__) + pthread_attr_set_qos_class_np(&attr, QOS_CLASS_USER_INTERACTIVE, 0); +# endif + ok = pthread_create(&P.thread, &attr, present_main, NULL) == 0; + pthread_attr_destroy(&attr); +#endif + if (!ok) return 0; + mtx_lock(&P.mtx); + while (!P.init_done) cnd_wait(&P.cv, &P.mtx); + ok = P.init_ok; + mtx_unlock(&P.mtx); + if (!ok) { +#ifdef _WIN32 + WaitForSingleObject(P.thread, INFINITE); + CloseHandle(P.thread); +#else + pthread_join(P.thread, NULL); +#endif + return 0; + } + P.running = 1; + return 1; +} + +void pt_stop(void) { + if (!P.running) return; + mtx_lock(&P.mtx); + P.stop = 1; + cnd_bcast(&P.cv); + mtx_unlock(&P.mtx); +#ifdef _WIN32 + WaitForSingleObject(P.thread, INFINITE); + CloseHandle(P.thread); +#else + pthread_join(P.thread, NULL); +#endif + P.running = 0; +} + +int pt_running(void) { return P.running; } +int pt_current(void) { return P.running ? P.current : -1; } + +int pt_submit(void *ready, void **next_done, uint64_t *wait_ns) { + if (next_done) *next_done = NULL; + if (wait_ns) *wait_ns = 0; + if (!P.running) return -1; + mtx_lock(&P.mtx); + const int cur = P.current; + P.ready[cur] = ready; + P.state[cur] = S_QUEUED; + P.queue[(P.q_head + P.q_len) % PT_MAX_SLOTS] = cur; + P.q_len++; + P.st.submits++; + if (P.q_len > P.st.queued_high) P.st.queued_high = P.q_len; + cnd_bcast(&P.cv); + int next = -1; + uint64_t t0 = 0; + for (;;) { + /* Prefer the slot after the current one: the oldest free. */ + for (int k = 1; k <= P.cfg.slots; k++) { + const int s = (cur + k) % P.cfg.slots; + if (P.state[s] == S_FREE) { next = s; break; } + } + if (next >= 0) break; + if (!t0) { t0 = now_ns(); P.st.waits++; } + cnd_wait(&P.cv, &P.mtx); + } + if (t0) { + const uint64_t w = now_ns() - t0; + P.st.wait_ns += w; + if (wait_ns) *wait_ns = w; + } + P.state[next] = S_COMPOSING; + P.current = next; + if (next_done) *next_done = P.done[next]; + else if (P.done[next] && P.cfg.dispose) P.cfg.dispose(P.cfg.user, P.done[next]); + P.done[next] = NULL; + mtx_unlock(&P.mtx); + return next; +} + +void pt_drain(void) { + if (!P.running) return; + mtx_lock(&P.mtx); + for (;;) { + int busy = P.q_len > 0; + for (int i = 0; i < P.cfg.slots && !busy; i++) + busy = P.state[i] == S_PRESENTING; + if (!busy) break; + cnd_wait(&P.cv, &P.mtx); + } + mtx_unlock(&P.mtx); +} + +void pt_get_stats(PtStats *out) { + if (!out) return; + if (!s_pt_inited) { memset(out, 0, sizeof *out); return; } + mtx_lock(&P.mtx); + *out = P.st; + mtx_unlock(&P.mtx); +} diff --git a/runtime/tests/run_gl_frame_gen.py b/runtime/tests/run_gl_frame_gen.py index 128fba32..0a2d696d 100644 --- a/runtime/tests/run_gl_frame_gen.py +++ b/runtime/tests/run_gl_frame_gen.py @@ -77,7 +77,7 @@ def main(): # facade (gpu_render.c) and render thread (render_thread.c). sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), - ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), + ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), ("pth", src / "present_thread.c"), ("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")] sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] # MinGW's PE linker reports undefined references from sections it later @@ -139,6 +139,54 @@ def main(): print(f"FAIL scale {s} {path} {timing}: generated frames off={runs[0]['generated']} " f"on={runs[1]['generated']}") ok = False + # Present thread ([video] present_thread): the fixture checks the + # window shows every composed image bit for bit, in order. The real + # images match the direct-swap run: bit for bit, except that + # rasterizing into a texture instead of the window's drawable may + # round a filtered present pixel differently (seen on Apple's + # GL-on-Metal: 1 LSB, a pixel or two per frame); allowed up to 1 LSB + # on at most 0.05 % of the pixels, and reported. + for fg in (0, 1): + dirs = {} + for mode in ("direct", "pt"): + sub = dest / f"pt-{s}-{path}-{timing}-{fg}-{mode}" + sub.mkdir(exist_ok=True) + cmd = [str(c) for c in (probe, s, fg, path, args.frames, timing)] + if mode == "pt": + cmd.append("pt") + env = dict(os.environ, FG_DUMP="1") + r = subprocess.run(cmd, cwd=sub, capture_output=True, text=True, + encoding="utf-8", errors="replace", env=env) + p = parse(r.stdout) + if r.returncode or p["failures"] != 0: + print(f"FAIL scale {s} {path} {timing} fg={fg} {mode}: exit={r.returncode}", + r.stdout.strip().splitlines()[-4:], r.stderr.strip()[-800:]) + ok = False + dirs[mode] = sub + files = sorted(f.name for f in dirs["direct"].glob("real*.rgba")) + other = sorted(f.name for f in dirs["pt"].glob("real*.rgba")) + if files != other or not files: + print(f"FAIL scale {s} {path} {timing} fg={fg}: present thread real frames " + f"{len(other)} vs {len(files)}") + ok = False + continue + exact, worst, worst_px = 0, 0, 0.0 + for name in files: + a = (dirs["direct"] / name).read_bytes() + b = (dirs["pt"] / name).read_bytes() + if a == b: + exact += 1 + continue + d = [abs(x - y) for x, y in zip(a, b) if x != y] + px = len({i // 4 for i, (x, y) in enumerate(zip(a, b)) if x != y}) + worst = max(worst, max(d) if d else 0) + worst_px = max(worst_px, px / (len(a) / 4)) + print(f"scale {s} {path} {timing} frame_generation={fg} present_thread=1: " + f"{exact}/{len(files)} real frames bit-identical, max diff {worst} LSB " + f"on {worst_px * 100:.3f} % of pixels") + if worst > 1 or worst_px > 0.0005: + print(f"FAIL scale {s} {path} {timing} fg={fg}: present thread changed real frames") + ok = False print("PASS" if ok else "FAIL") return 0 if ok else 1 diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py index 88f8ec05..fbc0f768 100644 --- a/runtime/tests/run_gl_readback_region.py +++ b/runtime/tests/run_gl_readback_region.py @@ -73,6 +73,8 @@ def main(): ("xr", framework / "runtime/src/psx_openxr.c")] if (framework / "runtime/src/render_thread.c").exists(): sources.append(("rth", framework / "runtime/src/render_thread.c")) + if (framework / "runtime/src/present_thread.c").exists(): + sources.append(("pth", framework / "runtime/src/present_thread.c")) if (framework / "runtime/src/frame_gen.c").exists(): sources.append(("fg", framework / "runtime/src/frame_gen.c")) for name, source in sources: diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index b0bf1b2d..d1dafbb1 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -84,7 +84,7 @@ def main(): # facade (gpu_render.c) and render thread (render_thread.c). sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), - ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), + ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), ("pth", src / "present_thread.c"), ("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")] sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] # MinGW's PE linker reports undefined references from sections it later diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index 1a13368d..b1d09cab 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -136,6 +136,8 @@ def main(): sources.append(("xr", framework / "runtime/src/psx_openxr.c")) if (framework / "runtime/src/render_thread.c").exists(): sources.append(("rth", framework / "runtime/src/render_thread.c")) + if (framework / "runtime/src/present_thread.c").exists(): + sources.append(("pth", framework / "runtime/src/present_thread.c")) if (framework / "runtime/src/frame_gen.c").exists(): sources.append(("fg", framework / "runtime/src/frame_gen.c")) # Unused renderer functions reference the rest of the runtime; the linker diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py index bd47de47..94bbf4c2 100644 --- a/runtime/tests/run_gl_texture_filter.py +++ b/runtime/tests/run_gl_texture_filter.py @@ -43,6 +43,8 @@ def main(): sources.append(('xr',fw/'runtime/src/psx_openxr.c')) if (fw/'runtime/src/render_thread.c').exists(): sources.append(('rth',fw/'runtime/src/render_thread.c')) + if (fw/'runtime/src/present_thread.c').exists(): + sources.append(('pth',fw/'runtime/src/present_thread.c')) if (fw/'runtime/src/frame_gen.c').exists(): sources.append(('fg',fw/'runtime/src/frame_gen.c')) objects=[] diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index f9283900..3efabb14 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -18,6 +18,11 @@ * Original source-owned scene; no retail payload. */ static void fixture_present(int generated); #define GL_PRESENT_TEST_HOOK(gen) fixture_present(gen) +/* argv[6] == "pt": the present thread is on; every image it puts in the + * window (read back after its copy, before its swap) must be the image the + * composing side presented, in the same order. */ +static void fixture_window(int slot); +#define GL_PRESENT_THREAD_TEST_HOOK(slot) fixture_window(slot) #include "gpu_gl_renderer.c" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" @@ -105,7 +110,37 @@ static uint64_t read_back(uint32_t *dst) { glReadPixels(0, 0, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE, dst); return fnv(dst, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); } +#define MAX_SHOWN 4096 +static int pt_mode; +static uint64_t composed_d[MAX_SHOWN], window_d[MAX_SHOWN]; +static int n_composed, n_window; +static void fixture_window(int slot) { + (void)slot; + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + uint32_t *px = (uint32_t *)malloc((size_t)ww * wh * 4); + p_glBindFramebuffer_raw(PSXGL_READ_FRAMEBUFFER, 0); + glReadBuffer(GL_BACK); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glReadPixels(0, 0, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE, px); + if (n_window < MAX_SHOWN) + window_d[n_window] = fnv(px, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + n_window++; + free(px); +} static void fixture_present(int generated) { + if (pt_mode) { + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + uint32_t *px = (uint32_t *)malloc((size_t)ww * wh * 4); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glReadPixels(0, 0, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE, px); + if (n_composed < MAX_SHOWN) + composed_d[n_composed] = fnv(px, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + n_composed++; + free(px); + } if (generated) { n_gen++; return; } int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); @@ -187,6 +222,7 @@ int main(int argc, char **argv) { /* "late": the game flips to a frame one VBlank after it starts drawing * the next one (as R4 does), so a flip is not a list boundary. */ int late = argc > 5 && !strcmp(argv[5], "late"); + pt_mode = argc > 6 && !strcmp(argv[6], "pt"); if (SDL_Init(SDL_INIT_VIDEO) != 0) { fprintf(stderr, "SKIP no video (%s)\n", SDL_GetError()); return 77; @@ -218,7 +254,9 @@ int main(int argc, char **argv) { if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } textures(); if (wide_mode) gr_wide_configure(426, 53); + if (pt_mode) gl_renderer_set_present_thread(1, 3); check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + if (pt_mode) check(gl_renderer_present_thread_active(), "present thread started"); if (fg) { setenv("PSX_FRAME_GEN_FORCE", "1", 1); gl_renderer_set_frame_generation(1); @@ -284,8 +322,14 @@ int main(int argc, char **argv) { check(glGetError() == GL_NO_ERROR, "GL error"); printf("real=%016llx\n", (unsigned long long)real_seq); printf("presents=%llu generated=%llu\n", (unsigned long long)n_real, (unsigned long long)n_gen); + gl_renderer_shutdown(); /* stops the render and present threads: all shown */ + if (pt_mode) { + int same = n_composed == n_window && n_composed <= MAX_SHOWN && n_composed > 0; + for (int i = 0; same && i < n_composed; i++) same = composed_d[i] == window_d[i]; + printf("pt composed=%d shown=%d\n", n_composed, n_window); + check(same, "the window shows every composed image, in order"); + } printf("checks=%d failures=%d\n", checks, failures); - gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); return failures ? 1 : 0; diff --git a/runtime/tests/test_present_thread.c b/runtime/tests/test_present_thread.c new file mode 100644 index 00000000..c1a8aa19 --- /dev/null +++ b/runtime/tests/test_present_thread.c @@ -0,0 +1,128 @@ +/* present_thread_test — the present thread core (present_thread.c) against a + * fake presenter: submission order is presentation order, a slot is never + * composed while queued or presenting, each done token reaches the producer + * that next takes its slot, a slow display blocks the producer (and a fast one + * still keeps every invariant), stop presents everything queued and disposes leftover tokens, a + * failed context start reports failure. */ +#include "present_thread.h" + +#include +#include +#include +#include +#include +#include + +static int checks, failures; +static void check(int ok, const char *label) { + checks++; + if (!ok) { fprintf(stderr, "FAIL %s\n", label); failures++; } +} +static void sleep_us(long us) { + struct timespec ts = { us / 1000000, (us % 1000000) * 1000 }; + nanosleep(&ts, NULL); +} + +#define N_FRAMES 600 +static _Atomic int slot_busy[PT_MAX_SLOTS]; /* 1 while queued or presenting */ +static uintptr_t presented[N_FRAMES + 8]; +static _Atomic int n_presented; +static int ctx_ok = 1, ctx_on, ctx_off; +static long present_us = 0; +static _Atomic int disposed; +static uintptr_t last_done[PT_MAX_SLOTS]; +static _Atomic int order_bad; + +static int fake_ctx(void *u, int cur) { (void)u; if (cur) { ctx_on++; return ctx_ok; } ctx_off++; return 1; } +static void *fake_present(void *u, int slot, void *ready) { + (void)u; + if (!atomic_load(&slot_busy[slot])) atomic_store(&order_bad, 1); + int i = atomic_fetch_add(&n_presented, 1); + if (i < N_FRAMES + 8) presented[i] = (uintptr_t)ready; + if (present_us) sleep_us(present_us + (rand() % 3) * present_us / 2); + atomic_store(&slot_busy[slot], 0); + /* done token: slot in the low byte, frame above */ + return (void *)(((uintptr_t)ready << 8) | (uintptr_t)slot | 0x80u); +} +static void fake_dispose(void *u, void *t) { (void)u; (void)t; atomic_fetch_add(&disposed, 1); } + +static PtConfig cfg(int slots) { + PtConfig c = { slots, fake_ctx, fake_present, fake_dispose, NULL }; + return c; +} +static void reset(void) { + for (int i = 0; i < PT_MAX_SLOTS; i++) { atomic_store(&slot_busy[i], 0); last_done[i] = 0; } + atomic_store(&n_presented, 0); + atomic_store(&disposed, 0); + atomic_store(&order_bad, 0); + ctx_on = ctx_off = 0; +} + +static void run(int slots, long us, int expect_waits) { + reset(); + present_us = us; + PtConfig c = cfg(slots); + check(pt_start(&c), "start"); + check(pt_current() == 0, "first slot is 0"); + int bad_reuse = 0, bad_token = 0; + for (int f = 1; f <= N_FRAMES; f++) { + const int cur = pt_current(); + if (atomic_load(&slot_busy[cur])) bad_reuse++; + /* producer "composes" for a bit */ + if (f % 7 == 0) sleep_us(200); + atomic_store(&slot_busy[cur], 1); + void *done = NULL; + uint64_t w = 0; + const int next = pt_submit((void *)(uintptr_t)f, &done, &w); + if (next < 0 || next >= slots || next == cur || atomic_load(&slot_busy[next])) bad_reuse++; + if ((uintptr_t)done != last_done[next]) bad_token++; + /* this slot's next token is unknown until presented again */ + last_done[next] = 0; + /* remember what present will hand back for cur */ + last_done[cur] = (((uintptr_t)f) << 8) | (uintptr_t)cur | 0x80u; + } + pt_drain(); + check(atomic_load(&n_presented) == N_FRAMES, "every submitted slot presented"); + int ordered = 1; + for (int i = 0; i < N_FRAMES; i++) if (presented[i] != (uintptr_t)(i + 1)) ordered = 0; + check(ordered, "presented in submission order"); + check(!bad_reuse, "a slot is never composed while queued or presenting"); + check(!bad_token, "done token reaches the producer taking the slot"); + check(!atomic_load(&order_bad), "present sees only queued slots"); + PtStats st; + pt_get_stats(&st); + check(st.submits == N_FRAMES && st.presents == N_FRAMES, "stats count"); + check(st.queued_high <= slots, "bounded queue"); + if (expect_waits > 0) check(st.waits > 0 && st.wait_ns > 0, "slow display blocks the producer"); + /* stop with frames queued: they are presented, leftover tokens disposed */ + present_us = 2000; + for (int f = 0; f < slots - 1; f++) { + void *d = NULL; + atomic_store(&slot_busy[pt_current()], 1); + (void)pt_submit((void *)(uintptr_t)(N_FRAMES + 1 + f), &d, NULL); + } + pt_stop(); + check(atomic_load(&n_presented) == N_FRAMES + slots - 1, "stop presents the queue"); + check(atomic_load(&disposed) > 0, "stop disposes uncollected tokens"); + check(ctx_on == 1 && ctx_off == 1, "context taken and released once"); + check(!pt_running() && pt_submit(NULL, NULL, NULL) == -1, "stopped"); +} + +int main(void) { + srand(7); + run(3, 0, 0); + run(3, 1500, 1); + run(2, 800, 1); + run(4, 300, -1); /* may or may not wait: invariants only */ + /* context failure */ + reset(); + ctx_ok = 0; + PtConfig c = cfg(3); + check(!pt_start(&c), "start fails when the context cannot be made current"); + check(!pt_running(), "not running after failed start"); + ctx_ok = 1; + PtConfig bad = cfg(1); + check(!pt_start(&bad), "one slot refused"); + printf("present_thread_test: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} From 10961741844d3d48fb4d5c4939d76ad5ec15929b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:59:01 -0700 Subject: [PATCH 43/57] Dynamic resolution: fast first descent on the render thread Armed at init (session start, render-thread start, a new resolution) and on a savestate load, game entry or window resize. While armed, an overrun jumps straight to the highest level whose pure-area prediction is at or below 0.78, then is judged like any step; a fit after a step, an up step, a failed judgement or 6 s disarm it and single steps fine-tune. A fixed cost only makes the target dearer than predicted, so a jump never lands below the level that fits. Co-Authored-By: Claude Opus 5.5 --- runtime/include/dynamic_resolution.h | 29 ++++- runtime/src/dynamic_resolution.c | 53 +++++++- runtime/src/main.cpp | 13 +- runtime/tests/test_dynamic_resolution_rt.c | 139 ++++++++++++++++++++- 4 files changed, 221 insertions(+), 13 deletions(-) diff --git a/runtime/include/dynamic_resolution.h b/runtime/include/dynamic_resolution.h index fdc90f42..2bc3f446 100644 --- a/runtime/include/dynamic_resolution.h +++ b/runtime/include/dynamic_resolution.h @@ -169,6 +169,22 @@ double dynres_up_blocked_s(const DynresController *c, int level, double now_s); * and the level not blocked. The prediction is load * ((1-f) + f*(S'/S)^2) * with f, the share of the cost that scales with pixels, learned from each * judged step. down_load > up_load is the hysteresis. + * - fast descent: armed at init (session start, render-thread start, a + * resolution change) and by dynrt_arm_descent (savestate load, game + * entry, window resize); it runs for descent_s from the first window + * judged after arming. While armed, a down step taken on a window whose + * load is at or over strong_load, or after down_windows consecutive + * over-budget windows, goes straight to the highest level the + * pure-area prediction (load * (S'/S)^2) puts at or below descent_load, + * several levels at once. A fixed part of the cost only makes the target + * dearer than predicted, so a jump never lands below the level that fits; + * it is judged like any step, and a judged window still over budget jumps + * again from the new measurement. + * A window that fits after a down step, an up step or a failed judgement + * disarms it (light windows before the heavy part do not), after + * which single steps fine-tune. descent_load < budget, and the level + * above the target is predicted over descent_load > up_load, so the jump + * is not followed by an up step back (no oscillation). * - relapse: a level reached by an up step and left by a down step within * relapse_s is blocked for up steps for relapse_block_s, doubling up to * relapse_block_max_s; holding it relapse_forget_s resets that. @@ -197,6 +213,8 @@ typedef struct DynrtParams { double learn_rate; double gap_factor, gap_hold_s; double min_coverage; + double descent_s; /* fast descent lasts this long once judging */ + double descent_load; /* fast descent target (predicted load) */ } DynrtParams; void dynrt_default_params(DynrtParams *p); @@ -232,9 +250,12 @@ typedef struct DynrtController { double post_load_before, post_pred, post_bp_before; int verify_strikes, strike_from; double strike_t; + /* fast descent: armed; deadline (0 = starts at the next judged window) */ + int descent_armed, descent_stepped; + double descent_until; /* telemetry */ unsigned long long downs, ups, undos, relapses, windows, held_windows, - guest_bound_windows, thin_windows; + guest_bound_windows, thin_windows, fast_downs; const char *last_reason; double last_decision_t; } DynrtController; @@ -245,6 +266,12 @@ void dynrt_hold(DynrtController *c, double now_s, double tail_s); /* One guest interval ending at now_s; returns the level to render at. */ int dynrt_sample(DynrtController *c, double now_s, const DynrtSample *s); int dynrt_force(DynrtController *c, int level); +/* Arm fast descent (see RULES): a savestate load, game entry, a resize. */ +void dynrt_arm_descent(DynrtController *c); +/* Fast-descent target from `level` at `load`: the highest level in + * [floor, level - 1] whose pure-area prediction is at or below + * descent_load (floor if none). */ +int dynrt_descent_target(const DynrtController *c, double load, int level); double dynrt_predict(const DynrtController *c, double load, int from, int to); double dynrt_up_blocked_s(const DynrtController *c, int level, double now_s); diff --git a/runtime/src/dynamic_resolution.c b/runtime/src/dynamic_resolution.c index 9a7c4978..56801f57 100644 --- a/runtime/src/dynamic_resolution.c +++ b/runtime/src/dynamic_resolution.c @@ -329,6 +329,8 @@ void dynrt_default_params(DynrtParams *p) { p->gap_factor = 4.0; p->gap_hold_s = 1.0; p->min_coverage = 0.5; + p->descent_s = 6.0; + p->descent_load = 0.78; } static int rt_clamp(const DynrtController *c, int l) { @@ -356,9 +358,27 @@ void dynrt_init(DynrtController *c, const DynrtParams *p, int floor_level, c->up_reached_t[i] = -1e9; c->relapse_dur[i] = c->p.relapse_block_s; } + c->descent_armed = c->p.descent_s > 0.0; c->last_reason = "start"; } +void dynrt_arm_descent(DynrtController *c) { + if (c->p.descent_s <= 0.0) return; + c->descent_armed = 1; + c->descent_stepped = 0; + c->descent_until = 0.0; +} + +/* Pure area (f = 1): the cost's fixed part only makes a level dearer than + * this predicts, so a jump never lands below the level that fits; a jump + * that lands too high is still over budget and jumps again from the new + * measurement. */ +int dynrt_descent_target(const DynrtController *c, double load, int level) { + for (int l = level - 1; l > c->floor; l--) + if (load * ((double)l * l) / ((double)level * level) <= c->p.descent_load) return l; + return c->floor < level ? c->floor : level; +} + double dynrt_predict(const DynrtController *c, double load, int from, int to) { if (from < 1 || to < 1) return load; double q = ((double)to * (double)to) / ((double)from * (double)from); @@ -471,6 +491,10 @@ static int rt_close_window(DynrtController *c, double now) { c->last_bp_share = bp_share; c->last_hz = wall > 0.0 ? (double)n / wall : 0.0; c->last_valid = 1; + if (c->descent_armed) { + if (c->descent_until <= 0.0) c->descent_until = now + c->p.descent_s; + else if (now > c->descent_until) c->descent_armed = 0; + } const int strong_bp = bp_share >= c->p.bp_strong; const int no_bp = bp_share < c->p.bp_weak; /* The guest is the limit: slow, never held by the queue, and the render @@ -488,6 +512,7 @@ static int rt_close_window(DynrtController *c, double now) { const int bp_cleared = c->post_bp_before >= c->p.bp_strong && no_bp; const int failed = c->post_down && want > 0.0 && fell < c->p.verify_fraction * want && !bp_cleared; + if (failed) c->descent_armed = 0; /* the model is off: single steps */ if (failed && !c->verify_strikes) { /* One strike: the window before the step may have mixed lighter * frames in (a scene getting heavier, the cost's lag), so it @@ -540,16 +565,32 @@ static int rt_close_window(DynrtController *c, double now) { c->over_streak++; const int strong = strong_bp || load >= c->p.strong_load; if (c->level > c->floor && now >= c->down_block_until && - (strong || c->over_streak >= c->p.down_windows)) - rt_step(c, now, c->level - 1, 1, load, bp_share, - strong_bp ? "queue full (render thread behind)" + (strong || c->over_streak >= c->p.down_windows)) { + int to = c->level - 1; + const char *why = strong_bp ? "queue full (render thread behind)" : strong ? "render cost over the interval" - : "render cost over budget"); + : "render cost over budget"; + /* Armed, and over by the meter (a full interval, or budget for + * down_windows): jump to the predicted level. Backpressure alone + * (a meter that under-reads) predicts no jump: single steps. */ + if (c->descent_armed && + (load >= c->p.strong_load || c->over_streak >= c->p.down_windows)) { + const int t = dynrt_descent_target(c, load, c->level); + if (t < to) { + to = t; + why = "fast descent (predicted from area)"; + c->fast_downs++; + } + } + if (c->descent_armed) c->descent_stepped = 1; + rt_step(c, now, to, 1, load, bp_share, why); + } else if (c->level <= c->floor) c->last_reason = "over budget at the floor"; return c->level; } c->over_streak = 0; + if (c->descent_stepped) c->descent_armed = 0; /* it fits: fine-tune from here */ if (guest_bound) c->last_reason = "guest-bound (no step)"; if (c->level >= c->ceiling) { c->up_streak_s = 0.0; return c->level; } const int next = c->level + 1; @@ -559,8 +600,10 @@ static int rt_close_window(DynrtController *c, double now) { c->up_streak_s = 0.0; if (c->up_streak_s >= c->p.up_after_s - 1e-9 && now - c->last_step_t >= c->p.up_cooldown_s && - now >= c->up_block_until[next]) + now >= c->up_block_until[next]) { + c->descent_armed = 0; rt_step(c, now, next, 0, load, bp_share, "headroom"); + } return c->level; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 8781026b..a94a43b4 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9500,6 +9500,13 @@ static void dynres_tick(void) { * so a scene that is too heavy right after a load is not left * running slow for seconds. */ if (soft_tail && tail > 0.5) tail = 0.5; + /* A new scene: let the first sustained overrun jump several levels + * (fast descent, dynamic_resolution.h). Render-thread start and a + * new resolution re-initialize, which arms too. */ + if (why && (std::strcmp(why, "savestate load") == 0 || + std::strcmp(why, "game entry") == 0 || + std::strcmp(why, "window resize") == 0)) + dynrt_arm_descent(&g_dynres.rt); dynres_tick_rt(now_s, wall, period, held, tail, why); return; } @@ -9567,7 +9574,8 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { "\"held_windows\":%llu,\"guest_bound_windows\":%llu,\"thin_windows\":%llu," "\"steps\":%llu,\"last_from\":%d,\"last_to\":%d,\"last_ms\":%.3f," "\"frames_measured\":%llu,\"gpu_frames\":%llu,\"frames_dropped\":%llu," - "\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", + "\"down_blocked_s\":%.1f,\"up_blocked\":{%s},\"fast_downs\":%llu," + "\"descent_armed\":%d", g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, g_dynres.active ? r.floor : 0, st.level, st.level * g_video_ref_lines, r.forced, r.last_load, 1.0 - r.p.margin, r.last_bp_share, r.last_hz, g_dynres.rt_win_cpu_ms, @@ -9578,7 +9586,8 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { (unsigned long long)st.steps, st.last_from, st.last_to, st.last_ms, (unsigned long long)co.frames, (unsigned long long)co.gpu_frames, (unsigned long long)co.dropped, - r.down_block_until > now_s ? r.down_block_until - now_s : 0.0, blocked); + r.down_block_until > now_s ? r.down_block_until - now_s : 0.0, blocked, + r.fast_downs, r.descent_armed); } const DynresController &c = g_dynres.ctl; const double now_s = (double)SDL_GetPerformanceCounter() / diff --git a/runtime/tests/test_dynamic_resolution_rt.c b/runtime/tests/test_dynamic_resolution_rt.c index 9d425bfc..e87e8c06 100644 --- a/runtime/tests/test_dynamic_resolution_rt.c +++ b/runtime/tests/test_dynamic_resolution_rt.c @@ -8,7 +8,10 @@ * Checks: overrun -> down one level at a time, headroom -> up only after the * window, hysteresis (no oscillation), guest-bound -> no step, a down step * that does not lower the cost -> undone and backed off, queue full -> - * fast down, floor and ceiling, holds. + * fast down, floor and ceiling, holds; fast descent (armed at start and by + * dynrt_arm_descent): a sustained overrun jumps to the predicted level in + * one or two steps, then fine-tunes without oscillating, and a cost that + * does not scale still ends in the undo. * Build/run: ctest -R dynamic_resolution_rt_test */ #include #include @@ -96,6 +99,13 @@ static void ctl(DynrtController *c, int floor_l, int ceil_l, int level) { dynrt_default_params(&p); dynrt_init(c, &p, floor_l, ceil_l, level); } +/* Fast descent off: the one-level rules alone. */ +static void ctl_single(DynrtController *c, int floor_l, int ceil_l, int level) { + DynrtParams p; + dynrt_default_params(&p); + p.descent_s = 0.0; + dynrt_init(c, &p, floor_l, ceil_l, level); +} /* Highest level whose cost fits the 15 % margin. */ static int fit_level(const Scene *sc, int floor_l, int ceil_l) { @@ -105,12 +115,12 @@ static int fit_level(const Scene *sc, int floor_l, int ceil_l) { return best; } -/* 1. Overrun at the ceiling: down one level at a time to the highest level - * that fits, fast, then no oscillation. */ +/* 1. Overrun at the ceiling, fast descent off: down one level at a time to + * the highest level that fits, then no oscillation. */ static void test_overrun_steps_down_and_settles(void) { DynrtController c; Run r; Scene sc = { .a = 0.003, .b = 0.00030 }; /* 9x: 27.3 ms, 6x: 13.8 ms, 7x: 17.7 ms */ - ctl(&c, 3, 9, 9); + ctl_single(&c, 3, 9, 9); run_init(&r, 9); drive(&c, &r, &sc, 20.0, 0.0); int want = fit_level(&sc, 3, 9); @@ -181,7 +191,7 @@ static void test_guest_bound_no_step(void) { static void test_undo_and_backoff(void) { DynrtController c; Run r; Scene sc = { .flat = 0.0175 }; /* 1.05 of the period at every level */ - ctl(&c, 3, 9, 9); + ctl_single(&c, 3, 9, 9); run_init(&r, 9); drive(&c, &r, &sc, 3.0, 0.0); CHECK(c.undos == 1 && r.level == 9, "undo: undos %llu, level %d after 3 s", c.undos, r.level); @@ -285,7 +295,126 @@ static void test_force(void) { CHECK(r.level == fit_level(&sc, 3, 9), "force: released to %d", r.level); } +/* 12. Fast descent from a 10x ceiling (R4 2P VS shape: a fixed part plus a + * part that scales with area; 5x fits): the first step jumps several + * levels, the fit level is reached far sooner than one level per judged + * window, never more than one level below it, and nothing moves after. */ +static void test_fast_descent_from_ceiling(void) { + Scene sc = { .a = 0.0025, .b = 0.000420 }; /* 10x: 44.5 ms, 5x: 13.0 ms, 6x: 17.6 ms */ + const int want = fit_level(&sc, 2, 10); + DynrtController s; Run rs; + ctl_single(&s, 2, 10, 10); + run_init(&rs, 10); + double t_single = -1.0; + while (rs.t < 20.0 && t_single < 0.0) { + drive(&s, &rs, &sc, 0.05, 0.0); + if (rs.level == want) t_single = rs.t; + } + DynrtController c; Run r; + ctl(&c, 2, 10, 10); + run_init(&r, 10); + double t_fast = -1.0; + int first_jump = 0; + while (r.t < 20.0) { + const int before = r.level; + drive(&c, &r, &sc, 0.05, 0.0); + if (!first_jump && r.level < before) first_jump = before - r.level; + if (t_fast < 0.0 && r.level <= want + 0 && r.level >= want - 1) t_fast = r.t; + } + printf("fast descent: 10x -> %dx in %.2f s, %d down step(s), first %d level(s); " + "one level at a time: %.2f s\n", want, t_fast, r.downs, first_jump, t_single); + CHECK(want == 5, "fast descent: scene fits at %d, want 5", want); + CHECK(first_jump >= 3, "fast descent: first step %d level(s)", first_jump); + CHECK(t_fast > 0.0 && t_fast < 1.6, "fast descent: near the fit level at %.2f s", t_fast); + CHECK(t_single > 2.5 && t_fast < t_single / 2.0, + "fast descent: %.2f s vs %.2f s one level at a time", t_fast, t_single); + CHECK(r.level == want, "fast descent: settled at %d, want %d", r.level, want); + CHECK(r.min_level >= want - 1, "fast descent: overshot to %d", r.min_level); + CHECK(r.downs <= 3, "fast descent: %d down steps", r.downs); + CHECK(c.fast_downs >= 1 && !c.descent_armed, "fast descent: %llu jumps, armed %d", + c.fast_downs, c.descent_armed); + const int steps = r.steps; + drive(&c, &r, &sc, 120.0, 0.0); + CHECK(r.steps == steps, "fast descent: %d steps after settling (oscillation)", + r.steps - steps); +} + +/* 13. Re-armed (a savestate load into a heavier scene): jumps again. Not + * armed (the window expired, nothing re-armed): one level at a time. */ +static void test_fast_descent_rearm(void) { + Scene light = { .a = 0.002, .b = 0.00012 }; /* 9x fits */ + Scene heavy = { .a = 0.003, .b = 0.00030 }; /* 6x fits */ + for (int arm = 0; arm < 2; arm++) { + DynrtController c; Run r; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &light, 10.0, 0.0); + CHECK(r.steps == 0 && !c.descent_armed, "rearm: light start steps %d armed %d", + r.steps, c.descent_armed); + /* the load: a hold, then the heavy scene */ + dynrt_hold(&c, r.t, 0.5); + if (arm) dynrt_arm_descent(&c); + const double t0 = r.t; + drive(&c, &r, &heavy, 20.0, 0.0); + const int want = fit_level(&heavy, 3, 9); + CHECK(r.level == want, "rearm %d: settled at %d, want %d", arm, r.level, want); + if (arm) + CHECK(c.fast_downs >= 1 && r.downs <= 2, + "rearm: %llu jumps, %d downs", c.fast_downs, r.downs); + else + CHECK(c.fast_downs == 0 && r.downs == 9 - want, + "not armed: %llu jumps, %d downs", c.fast_downs, r.downs); + (void)t0; + } +} + +/* 14. Light windows first (game entry: menus, loading) do not use up the + * arming; the heavy part then jumps. */ +static void test_fast_descent_light_first(void) { + Scene sc = { .a = 0.003, .b = 0.00030, .heavy_from = 0.0, .heavy_to = 0.0 }; + Scene light = { .a = 0.002 }; + DynrtController c; Run r; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &light, 2.0, 0.0); + CHECK(c.descent_armed, "light first: disarmed by light windows"); + drive(&c, &r, &sc, 10.0, 0.0); + CHECK(c.fast_downs >= 1 && r.level == fit_level(&sc, 3, 9), + "light first: %llu jumps, level %d", c.fast_downs, r.level); +} + +/* 15. A cost that does not scale, fast descent on: the jump is judged, the + * model is dropped (disarmed) and the two-strike undo still happens. */ +static void test_fast_descent_flat_cost(void) { + DynrtController c; Run r; + Scene sc = { .flat = 0.0175 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 4.0, 0.0); + CHECK(c.undos == 1 && r.level == 9 && !c.descent_armed, + "flat: undos %llu level %d armed %d", c.undos, r.level, c.descent_armed); + drive(&c, &r, &sc, 116.0, 0.0); + CHECK(r.time_at[9] > 105.0, "flat: %.1f s at the ceiling", r.time_at[9]); +} + +/* 16. dynrt_descent_target: the highest level whose pure-area prediction is + * at or below descent_load; the floor when none is. */ +static void test_descent_target(void) { + DynrtController c; + ctl(&c, 2, 10, 10); + c.f = 0.3; /* the learned share does not matter: pure area */ + /* load 2.0 at 10x: L^2/100 * 2 <= 0.78 -> L <= 6.24 */ + CHECK(dynrt_descent_target(&c, 2.0, 10) == 6, "target: %d", dynrt_descent_target(&c, 2.0, 10)); + CHECK(dynrt_descent_target(&c, 50.0, 10) == 2, "target floor"); + CHECK(dynrt_descent_target(&c, 0.80, 10) == 9, "target one level"); +} + int main(void) { + test_fast_descent_from_ceiling(); + test_fast_descent_rearm(); + test_fast_descent_light_first(); + test_fast_descent_flat_cost(); + test_descent_target(); test_overrun_steps_down_and_settles(); test_headroom_steps_up_after_window(); test_hysteresis(); From 1ed99213f2f49d3884ca2c5cd155a92f97c87500 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:03:23 -0700 Subject: [PATCH 44/57] Frame generation: PSX_PRESENT_SHOT_GENERATED captures a generated frame Diagnostic: a staged present_shot waits for the next generated frame, so the in-between images can be inspected in game. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 39e3d17b..482febdb 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -8496,7 +8496,15 @@ static void gl_swap_with_osd(void) { extern int present_shot_take(char *out, int n); extern void present_shot_done(int ok); char shot_path[512]; - if (present_shot_take(shot_path, (int)sizeof(shot_path))) { + /* PSX_PRESENT_SHOT_GENERATED=1 (diagnostic): a staged present_shot + * waits for the next generated frame (frame generation). */ + static int gen_only = -1; + if (gen_only < 0) { + const char *e = getenv("PSX_PRESENT_SHOT_GENERATED"); + gen_only = (e && *e && *e != '0') ? 1 : 0; + } + if ((!gen_only || s_fg_presenting) && + present_shot_take(shot_path, (int)sizeof(shot_path))) { int ww = 0, wh = 0; int wrote = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); From 673c1d0a1bac654433268c3baeb78e42fff9ef1c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:06:07 -0700 Subject: [PATCH 45/57] Present thread: one owned swap call; frame-gen fixture builds on MinGW Both swap paths go through gl_window_swap. The temporal-blending ownership guard allows the present thread's context (pt_begin only). The frame generation fixture used setenv, which MinGW lacks. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 8 ++++++-- .../tests/test_frame_interpolation_context_ownership.py | 8 +++++++- runtime/tests/test_gl_frame_gen.c | 4 ++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 482febdb..df0fe1c2 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -8303,6 +8303,10 @@ static int pt_cb_ctx(void *user, int current) { return 1; } +/* The one owned swap call: the composing thread's (direct) or the present + * thread's. */ +static void gl_window_swap(void) { SDL_GL_SwapWindow(s_win); } + static void *pt_cb_present(void *user, int slot, void *ready) { (void)user; if (s_ptp_interval_gen != s_pt_interval_gen) { @@ -8337,7 +8341,7 @@ static void *pt_cb_present(void *user, int slot, void *ready) { GL_PRESENT_THREAD_TEST_HOOK(slot); #endif void *done = p_glFenceSync(PT_SYNC_GPU_COMMANDS_COMPLETE, 0); - SDL_GL_SwapWindow(s_win); + gl_window_swap(); return done; } @@ -8414,7 +8418,7 @@ static void pt_end(void) { /* The swap: direct, or queued to the present thread. */ static void gl_present_swap(void) { - if (!s_pt_on) { SDL_GL_SwapWindow(s_win); return; } + if (!s_pt_on) { gl_window_swap(); return; } (void)pt_target_fbo(); /* a frame that never bound 0 still has its slot */ void *ready = p_glFenceSync(PT_SYNC_GPU_COMMANDS_COMPLETE, 0); glFlush(); diff --git a/runtime/tests/test_frame_interpolation_context_ownership.py b/runtime/tests/test_frame_interpolation_context_ownership.py index ca649af6..d1257630 100644 --- a/runtime/tests/test_frame_interpolation_context_ownership.py +++ b/runtime/tests/test_frame_interpolation_context_ownership.py @@ -9,9 +9,15 @@ RENDERER = (ROOT / "runtime" / "src" / "gpu_gl_renderer.c").read_text( encoding="utf-8" ) -assert RENDERER.count("SDL_GL_CreateContext(") == 1, ( +# The only second context is the present thread's ([video] present_thread, +# docs/RENDER_THREAD.md), created in pt_begin under the render thread, which +# frame interpolation makes ineligible. +assert RENDERER.count("SDL_GL_CreateContext(") == 2, ( "temporal blending must not create a second GL context" ) +_pt = RENDERER[RENDERER.index("static int pt_begin(void)"):] +_pt = _pt[:_pt.index("\n}\n")] +assert "SDL_GL_CreateContext(" in _pt, "the second GL context belongs to the present thread" assert "SDL_CreateThread(" not in RENDERER, ( "temporal blending must not create a presentation thread" ) diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index 3efabb14..2075794f 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -258,7 +258,11 @@ int main(int argc, char **argv) { check(gl_renderer_render_thread_start(2) == 1, "render thread started"); if (pt_mode) check(gl_renderer_present_thread_active(), "present thread started"); if (fg) { +#ifdef _WIN32 + _putenv("PSX_FRAME_GEN_FORCE=1"); +#else setenv("PSX_FRAME_GEN_FORCE", "1", 1); +#endif gl_renderer_set_frame_generation(1); check(gl_renderer_frame_generation() == 1, "frame generation on"); } From 06b05041e99a08952e84296240fec33333eafeb9 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:22:26 -0700 Subject: [PATCH 46/57] Docs: present thread and fast first descent Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_GENERATION.md | 9 +++- docs/RENDER_THREAD.md | 93 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/docs/FRAME_GENERATION.md b/docs/FRAME_GENERATION.md index aa1d1f11..898dd6a0 100644 --- a/docs/FRAME_GENERATION.md +++ b/docs/FRAME_GENERATION.md @@ -59,7 +59,10 @@ has measured time to spare. real frame's `TIME_ELAPSED` span reads close to the whole interval (it counts the GPU waiting for records), so it is not used here; GPU overload shows as backpressure. The measured frame cost dynamic resolution uses - excludes generation (its query is paused, segments summed). + excludes generation (its query is paused, segments summed). With the + present thread (`[video] present_thread`, docs/RENDER_THREAD.md) a swap + here is the hand-off to it, and its cost is the time spent waiting for a + free slot, not the compositor's round trip. - **Breaker.** Off for 3 s (doubling to 24 s on repeats within 10 s) after the queue backed up (the guest waited), the render thread fell two frames behind, or a guest frame took over 1.5 intervals. While dynamic resolution @@ -82,6 +85,10 @@ has measured time to spare. dups, plan (`last_n`, `slots`), costs (`real_ms`, `gen_ms`, `swap_ms`), last match counts, breaker, last hold, total swaps. +`PSX_PRESENT_SHOT_GENERATED=1` (diagnostic): a staged `present_shot` is +taken at the next generated frame instead of the next present, to inspect +in-between images in game. + ## Tests - `frame_gen_test`: matching (moved scenes, keys, move and deformation diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 6a81fbd8..376fa606 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -174,6 +174,61 @@ resolution (below), not of this layer. | headless | `--headless` (software) never starts it; `--headless-opengl` runs it on the hidden context (no present; the frame boundary still closes every frame), which is what `fp_identity` exercises | | Vulkan / software | never started (log line says why) | +## Present thread (`[video] present_thread`) + +Opt-in, under the render thread, off by default (`present_thread = true`; +`PSX_PRESENT_THREAD=0/1`, `PSX_PRESENT_THREAD_SLOTS=2..4`, default 3). On +macOS a swap on the render thread cost 6-9 ms of waiting on the window +compositor (`[NSOpenGLContext flushBuffer]` on GL-on-Metal) in every frame, +real or generated; with this it costs a fence and a hand-off. + +- **Slots.** While it runs, framebuffer 0 of the composing context (the + render thread's, or the emulation thread's while it holds the context at a + sync point) is an offscreen slot: every `glBindFramebuffer(..., 0)` goes + through `gl_bind_fb_redirect` and lands on the current slot, an RGBA8 (RGB8 + if the window has no alpha) texture plus depth-stencil at the window's + drawable size, reallocated when the window changes. Everything that used + to be drawn into the back buffer (display quad, OSD, hold-last captures, + `present_shot`, frame generation's kept real frame) is drawn into it + unchanged; the two `glReadBuffer(GL_BACK)` reads read its attachment. +- **Swap.** `gl_present_swap`: a fence (`glFenceSync`), `glFlush`, queue the + slot to the present thread, take the next free slot (waiting only while + every other slot is queued or on screen: the display is the bottleneck; + that wait is what the dynamic-resolution and frame-generation ledgers count + as the swap), and `glWaitSync` on the fence the present thread left after + its last copy out of that slot. +- **Present thread** (`present_thread.c`, backend-neutral core; GL callbacks + in `gpu_gl_renderer.c`): owns a second context created on the same window + with `SDL_GL_SHARE_WITH_CURRENT_CONTEXT` (textures and syncs are shared; + FBOs are per context, so it keeps its own read FBO per slot). Per slot, in + submission order: `glWaitSync` on the ready fence, a 1:1 `GL_NEAREST` + blit to the real framebuffer 0 (scaled only if the window changed since + the frame was composed), a done fence, the swap. The swap interval is + applied on its context. `QOS_CLASS_USER_INTERACTIVE` on macOS, above-normal + priority on Windows. +- **Fallback.** No sync objects, no shared context or a failed start: a log + line and direct swaps, as without it. It starts and stops with the render + thread (stop presents what is queued first). +- **Pixels.** The window shows exactly the composed image (gl_frame_gen_test + reads the window back after each copy). Compared with composing straight + into the window, the composed image is bit-identical at 1x and for every + native-wide present; on Apple GL-on-Metal a VRAM present resolved down + into a smaller window (area resolve) rounded 1 pixel by 1 LSB in 2 of 24 + frames: rasterizing into a texture instead of the drawable. The test + allows at most 1 LSB on 0.05 % of the pixels and reports it. +- **Latency.** Up to `slots - 1` composed frames can wait for the display. + Frame generation already paces presents to the refresh, so the queue + normally holds at most one. +- **R4** (Match display 10x ceiling, dynres, Fit widescreen, 120 Hz panel, + host at load average 22-26): the render thread's swap went from 4.5-7.8 ms + to 0.02-0.08 ms; the present thread's swap takes 8-10 ms. 2P VS guest + 56.6-57.6 → 57.2-58.6 Hz; with generation forced (test knob) 1P guest + 25 → 41-44 Hz and presented 50 → 78-85 Hz. + +`{"cmd":"render_thread"}` carries `present_thread`: active, slots, submits, +presents, waits and wait time (composing side blocked), swap time (avg/max, +on the present thread), queue high water. + ## Dynamic resolution (`[video] dynamic_resolution`) Opt-in as before (`dynamic_resolution = true`, `dynamic_resolution_min`; @@ -227,6 +282,19 @@ full rules and `dynrt_default_params` the numbers): 30 % of the predicted cost (and did not end the backpressure) is a strike; a second in a row means the cost is not the pixels: both steps are undone and down steps are blocked for 20 s, doubling to 320 s. +- Fast first descent: armed when the controller starts (session start, + render-thread start, a new resolution) and on a savestate load, game entry + or window resize, for 6 s from the first judged window. While armed, a + window at or over the whole interval, or two over budget, jumps straight + to the highest level whose pure-area prediction (load × (S′/S)²) is at or + below 0.78; the jump is judged like any step and, if still over, jumps + again from the new measurement. A fixed part of the cost only makes the + target dearer than predicted, so a jump never lands below the level that + fits, and the level above it is predicted over the 0.70 up threshold, so no + up step follows. A fit after a step, an up step or a failed judgement + disarm it; single steps fine-tune. R4 2P VS from a 10x ceiling (busy + host): 10→5 or 10→6, then 4-3; guest at 59 Hz or more from 1.2-2.3 s + after the load (one level per judged window: 3.3 s). - Up, one level, after 3 s of windows with no backpressure and the next level predicted at 0.70 or less, 2 s after the last step, never above the ceiling. The prediction (load × ((1−f) + f·(S′/S)²)) learns f from each @@ -279,7 +347,21 @@ means), `guest_bound`, the step counters and `up_blocked`. are poisoned after each call. It fails for each of these deliberate mistakes: replay reading the tags live, staging uploads from the guest array, dropping the stream state. -- `video_enhancement_settings_test`: the key defaults off and parses. +- `video_enhancement_settings_test`: the keys (`render_thread`, + `present_thread`) default off and parse. +- `present_thread_test` (`test_present_thread.c`): the present core against + a fake presenter: presentation order is submission order over 600 frames + with 2-4 slots, a slot is never composed while queued or on screen, each + done token reaches the composer that next takes its slot, a slow display + blocks the composer, stop presents the queue and disposes leftover tokens, + a context failure fails the start. Clean under ThreadSanitizer. +- `gl_frame_gen_test` present-thread runs: generation off and on, VRAM and + native-wide, flip and late timing: the window shows every composed image, + in order, bit for bit; real frames match the direct-swap run (see Pixels). +- Windows (MinGW): the runtime builds with the Win32 path of + `render_thread.c` (SRW lock, condition variable, `CreateThread`), and + `render_thread_test` and `gl_render_thread_test` run there (the GL test + exits 77, a CTest skip, on a host with no GL 3.3 context). - `dynamic_resolution_rt_test` (`test_dynamic_resolution_rt.c`): the render-thread controller against a synthetic two-stage pipeline (guest E, render a + b·S², two frames in flight, costs four frames late): overrun -> @@ -288,14 +370,13 @@ means), `guest_bound`, the step counters and `up_blocked`. no step; a cost that does not scale -> two strikes, undo, back-off; queue full with an under-reading meter -> down; floor and ceiling; a heavy stretch whose first window is mixed -> one strike, not an undo; holds; - thin windows; pins. + thin windows; pins; fast descent (10x→5x in one jump in 0.68 s vs 3.88 s + one level at a time, no overshoot, no step in the next 120 s; re-armed by + `dynrt_arm_descent`, single steps when not armed; light windows first do + not use it up; a flat cost still ends in the undo; the target function). - `gl_render_thread_test` dynres mode: the same level steps between frames with the thread off (applied at once) and on (recorded, and directly in held frames) give identical readbacks, VRAM, frame and wide surface. -- Windows (MinGW): the runtime builds with the Win32 path of - `render_thread.c` (SRW lock, condition variable, `CreateThread`), and - `render_thread_test` and `gl_render_thread_test` run there (the GL test - exits 77, a CTest skip, on a host with no GL 3.3 context). - In game (R4, see the PR): `tools/fp_identity.py` guest identity render thread off vs on under `--headless-opengl`, and A/B frame rates from a race savestate at Native, 4K and Match display, 4:3 and widescreen. From ceac56b2de432bfa9a998303fb3a16ad370840f1 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:53:49 -0700 Subject: [PATCH 47/57] Regenerate the OpenBIOS emitter stamp Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 95abf5dd..e9b2f22a 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -69c42630d0ae700cdb02eea8481bed9a538593caa99f5a979b26ab8e8ad71f17 +7452d9baed94969d54e5ea4f59d742e4f51fe519d783a83ba0fb92565d1ccc34 From 1f102027eceef13d892df7665c70ef0d7c446480 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:05:24 -0700 Subject: [PATCH 48/57] Frame generation: warm cost estimate, re-probe, guest-paced breaker --- runtime/include/frame_gen.h | 35 +++++++++ runtime/include/gpu_gl_renderer.h | 6 +- runtime/src/frame_gen.c | 63 +++++++++++++++++ runtime/src/gpu_gl_renderer.c | 113 +++++++++++++++++++++++------- runtime/src/main.cpp | 10 +-- runtime/tests/test_frame_gen.c | 75 +++++++++++++++++++- 6 files changed, 267 insertions(+), 35 deletions(-) diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h index 5c83a03a..418c9b72 100644 --- a/runtime/include/frame_gen.h +++ b/runtime/include/frame_gen.h @@ -89,6 +89,41 @@ void fg_breaker_init(FgBreaker *b, double base_hold, double max_hold, double rep void fg_breaker_trip(FgBreaker *b, double now, const char *reason); int fg_breaker_open(const FgBreaker *b, double now); /* 1 = generation allowed */ +/* The cost of one generated frame, as the plan sees it. + * - Cold samples are discarded: the first frames after the surfaces were + * (re)allocated or generation was enabled pay first-touch costs (driver + * allocation, page faults) that steady state never sees; fg_cost_cold() + * marks the next `n` samples as such. + * - A stale estimate is re-probed: when the estimate has kept the plan at + * zero for `probe_s`, fg_cost_estimate() reports 0 (unknown) once so one + * frame is generated and measured, and that probe's sample replaces the + * estimate instead of blending into it. A probe that still does not fit + * doubles the wait before the next one (up to max_probe_s); one that fits + * resets it. */ +typedef struct FgCost { + double ema; /* seconds, 0 = unknown */ + uint32_t samples, discarded, probes; + int cold; /* samples still to discard */ + int probing; /* the next sample is a probe's */ + double blocked_since;/* when the estimate started keeping the plan at 0 */ + double probe_at; /* when the probe was granted */ + double probe_s, base_probe_s, max_probe_s; +} FgCost; +void fg_cost_init(FgCost *c, double probe_s, double max_probe_s); +void fg_cost_cold(FgCost *c, int n); +/* One measured frame; `fit_s` is what one generated frame may cost to fit. */ +void fg_cost_add(FgCost *c, double cost_s, double fit_s); +/* The estimate to plan with at `now`: 0 when unknown or due for a probe. + * `fit_s` as above: an estimate above it is what blocks the plan. */ +double fg_cost_estimate(FgCost *c, double now, double fit_s); + +/* The guest's own pacing: frame boundaries against a schedule that advances + * one interval per frame. Late only when the guest slipped more than `slack_s` + * behind its schedule (jitter, a long frame followed by a short one, does not + * count); the schedule then restarts from now. Early boundaries pull it in. */ +typedef struct FgPace { double next; int primed; } FgPace; +int fg_pace_note(FgPace *p, double now, double period_s, double slack_s); + #ifdef __cplusplus } #endif diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 94ae9c57..ebc6edb2 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -330,13 +330,13 @@ void gl_renderer_dynres_stats(GlDynresStats *out); * in-between frames drawn by the render thread from the last two game * frames' draw lists when it has time to spare. Off by default; with it off * nothing changes. set: a sync point. configure: the display's refresh and - * the guest's frame rate (any thread). hold: trip the breaker (generation - * stays off for a few seconds), e.g. while dynamic resolution is over budget. + * the guest's frame rate (any thread). hold: pause generation for `secs` + * (not a breaker trip), e.g. while dynamic resolution is over budget. * json: the debug server's {"cmd":"frame_gen"} fields (no sync point). */ void gl_renderer_set_frame_generation(int on); int gl_renderer_frame_generation(void); void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz); -void gl_renderer_frame_gen_hold(const char *reason); +void gl_renderer_frame_gen_hold(const char *reason, double secs); int gl_renderer_frame_gen_json(char *out, int cap); /* Host time the renderer spent, as running totals in performance-counter * ticks (only kept while dynamic resolution is on): waits for the frame diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index 867defb4..d2135b51 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -190,3 +190,66 @@ void fg_breaker_trip(FgBreaker *b, double now, const char *reason) { } int fg_breaker_open(const FgBreaker *b, double now) { return now >= b->until; } + +void fg_cost_init(FgCost *c, double probe_s, double max_probe_s) { + memset(c, 0, sizeof *c); + c->base_probe_s = c->probe_s = probe_s; + c->max_probe_s = max_probe_s; + c->blocked_since = -1.0; +} + +void fg_cost_cold(FgCost *c, int n) { if (n > c->cold) c->cold = n; } + +void fg_cost_add(FgCost *c, double cost_s, double fit_s) { + if (cost_s <= 0.0) return; + if (c->cold > 0) { c->cold--; c->discarded++; return; } + c->samples++; + if (c->probing || c->ema <= 0.0) { + c->ema = cost_s; + if (c->probing) { + c->probing = 0; + if (fit_s > 0.0 && cost_s > fit_s) { + c->probe_s *= 2.0; + if (c->probe_s > c->max_probe_s) c->probe_s = c->max_probe_s; + } else { + c->probe_s = c->base_probe_s; + } + } + } else { + c->ema = c->ema * 0.8 + cost_s * 0.2; + } + if (fit_s <= 0.0 || c->ema <= fit_s) c->blocked_since = -1.0; +} + +double fg_cost_estimate(FgCost *c, double now, double fit_s) { + if (c->ema <= 0.0) return 0.0; + if (c->probing) { + /* A probe in flight; one never drawn (no room) is given up. */ + if (now - c->probe_at < c->probe_s) return c->ema; + c->probing = 0; + c->blocked_since = now; + } + if (fit_s > 0.0 && c->ema > fit_s) { + if (c->blocked_since < 0.0) c->blocked_since = now; + else if (now - c->blocked_since >= c->probe_s) { + c->blocked_since = now; + c->probing = 1; + c->probe_at = now; + c->probes++; + return 0.0; + } + } else { + c->blocked_since = -1.0; + } + return c->ema; +} + +int fg_pace_note(FgPace *p, double now, double period_s, double slack_s) { + if (period_s <= 0.0) return 0; + if (!p->primed) { p->primed = 1; p->next = now + period_s; return 0; } + int late = 0; + if (now > p->next + slack_s) { late = 1; p->next = now; } + else if (now < p->next - period_s) p->next = now; /* early: pull in */ + p->next += period_s; + return late; +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index df0fe1c2..89093d0f 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -10025,7 +10025,6 @@ static _Atomic double s_fg_refresh_hz = 0.0, s_fg_guest_hz = 59.94; static _Atomic int s_fg_late = 0; /* emulation thread: a late frame */ static const char *_Atomic s_fg_hold_reason = NULL; static _Atomic uint64_t s_fg_hold_until = 0; /* rt_now_ns: no generation before */ -static uint64_t s_fg_emu_last_ns = 0; /* emulation thread */ static FgList s_fg_l[4]; /* older, newer, capturing, spare */ static int s_fg_older = 0, s_fg_newer = 1, s_fg_cur = 2; static int s_fg_pa = -1, s_fg_pb = -1; /* the pair a schedule draws from */ @@ -10043,7 +10042,15 @@ static FgMatchStats s_fg_mst; static FgBreaker s_fg_brk; static int s_fg_brk_init = 0; static uint64_t s_fg_bp_seen = 0; -static double s_fg_real_ema = 0.0, s_fg_gen_ema = 0.0; /* seconds */ +static double s_fg_real_ema = 0.0; /* seconds */ +/* A generated frame's cost: cold samples discarded, a blocking estimate + * re-probed (frame_gen.h FgCost). s_fg_fit_s: what one may cost to fit. */ +static FgCost s_fg_cost; +static int s_fg_cost_init = 0; +static double s_fg_fit_s = 0.0; +static FgPace s_fg_pace; /* emulation thread */ +static _Atomic uint64_t s_fg_last_gen_ns = 0; /* the last generated frame (rt_now_ns) */ +static uint64_t s_fg_ignored_late = 0, s_fg_ignored_bp = 0; static const char *s_fg_hold_why = NULL; static double s_fg_gen_gpu_ms = 0.0, s_fg_gen_cpu_ms = 0.0; /* the last one measured */ /* Schedule after a flip: n generated frames, then the real one. */ @@ -10072,6 +10079,18 @@ static double s_fg_last_match_ms = 0.0; static double fg_now_s(void) { return (double)rt_now_ns() * 1e-9; } +static FgCost *fg_cost(void) { + if (!s_fg_cost_init) { fg_cost_init(&s_fg_cost, 2.0, 16.0); s_fg_cost_init = 1; } + return &s_fg_cost; +} + +/* Breaker trips only count while generation is what could have caused + * them: a generated frame in the last half second. */ +static int fg_recently_generated(void) { + const uint64_t g = atomic_load(&s_fg_last_gen_ns); + return g && rt_now_ns() - g < 500000000ull; +} + /* Render thread: every swap's wall time. A swap waits for the compositor * (and on a busy WindowServer for its round trip); each generated frame adds * one, so the plan counts it. */ @@ -10354,6 +10373,22 @@ static void fg_invalidate(void) { fg_list_open(); } +/* A new surface's first touch (the driver backing its storage) happens here, + * when it is allocated, not inside the first generated frame; that frame's + * measured cost is discarded as well (cold). */ +static void fg_surface_warm(GLuint fbo) { + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, fbo); + glDisable(GL_SCISSOR_TEST); + glClearColor(0.f, 0.f, 0.f, 0.f); + glClearStencil(0); + glClearDepth(1.0); + glStencilMask(0xFF); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + glFlush(); + fg_cost_cold(fg_cost(), 2); +} + /* Surfaces for the generated image, at the current level. */ static int fg_surfaces_ensure(int wide) { const int S = s_hr_scale; @@ -10366,6 +10401,7 @@ static int fg_surfaces_ensure(int wide) { s_fg_hr_S = 0; if (!hr_alloc_surface(S, &s_fg_hr_tex, &s_fg_hr_rb, &s_fg_hr_fbo)) return 0; s_fg_hr_S = S; + fg_surface_warm(s_fg_hr_fbo); } if (wide && (s_fg_w_S != S || s_fg_w_w != g_wide_w)) { if (s_fg_w_fbo) { @@ -10376,6 +10412,7 @@ static int fg_surfaces_ensure(int wide) { s_fg_w_S = 0; if (!wide_alloc_surface(S, &s_fg_w_tex, &s_fg_w_rb, &s_fg_w_fbo)) return 0; s_fg_w_S = S; s_fg_w_w = g_wide_w; + fg_surface_warm(s_fg_w_fbo); } return 1; } @@ -10664,8 +10701,11 @@ static int fg_generate(double t, int swap) { /* ---- frame generation: schedule (render thread) ---- */ static void rth_present_payload(uint16_t op, const uint8_t *p); /* below */ +/* Short first hold, doubling on repeats within 2 s of the last hold's end, + * up to 8 s: one hiccup costs a fraction of a second of generation. */ +#define FG_BRK_INIT() do { if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 0.5, 8.0, 2.0); s_fg_brk_init = 1; } } while (0) static void fg_trip(const char *why) { - if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 3.0, 24.0, 10.0); s_fg_brk_init = 1; } + FG_BRK_INIT(); fg_breaker_trip(&s_fg_brk, fg_now_s(), why); } @@ -10677,7 +10717,7 @@ static void fg_gen_cost_poll(void) { if (!avail) break; p_glGetQueryObjectui64v(s_fg_q[i], GL_QUERY_RESULT, &ns); double c = (double)(ns > s_fg_q_cpu[i] ? ns : s_fg_q_cpu[i]) * 1e-9; - s_fg_gen_ema = s_fg_gen_ema > 0.0 ? s_fg_gen_ema * 0.8 + c * 0.2 : c; + fg_cost_add(fg_cost(), c, s_fg_fit_s); s_fg_gen_gpu_ms = (double)ns * 1e-6; s_fg_gen_cpu_ms = (double)s_fg_q_cpu[i] * 1e-6; s_fg_qt++; @@ -10726,7 +10766,7 @@ static void fg_generate_timed(double t) { s_fg_presenting = 0; } s_rth_ov.valid = 0; - if (ok) s_fg_generated++; + if (ok) { s_fg_generated++; atomic_store(&s_fg_last_gen_ns, rt_now_ns()); } rthf_resume(); } @@ -10793,8 +10833,9 @@ static uint64_t fg_tick(void *user, uint64_t now) { const uint64_t bp = rt_backpressure_events(); const int ahead = rt_frames_ahead(); if (!s_fg_force && (ahead >= 1 || bp != s_fg_bp_seen)) { - if (bp != s_fg_bp_seen) fg_trip("queue backed up"); - else if (ahead >= 2) fg_trip("render thread behind"); + if (bp != s_fg_bp_seen) { + if (fg_recently_generated()) fg_trip("queue backed up"); else s_fg_ignored_bp++; + } else if (ahead >= 2) fg_trip("render thread behind"); s_fg_bp_seen = bp; fg_flush(); return 0; @@ -10866,26 +10907,40 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal if (B->valid && (B->disp[0] != disp[0] || B->disp[1] != disp[1])) B = A = NULL; if (B) { B->wide = wide; B->linear = wide ? a[3] : a[4]; B->force43 = wide ? 0 : a[5]; } const double now = fg_now_s(); - if (atomic_exchange(&s_fg_late, 0)) fg_trip("guest late"); + /* Guest lateness and backpressure trip the breaker only while generation + * runs: otherwise they are not generation's doing. */ + const int gen_recent = fg_recently_generated(); + if (atomic_exchange(&s_fg_late, 0)) { + if (gen_recent) fg_trip("guest late"); else s_fg_ignored_late++; + } const char *why = atomic_exchange(&s_fg_hold_reason, NULL); if (why) s_fg_hold_why = why; const int held = rt_now_ns() < atomic_load(&s_fg_hold_until); const uint64_t bp = rt_backpressure_events(); - if (bp != s_fg_bp_seen) { s_fg_bp_seen = bp; fg_trip("queue backed up"); } + if (bp != s_fg_bp_seen) { + s_fg_bp_seen = bp; + if (gen_recent) fg_trip("queue backed up"); else s_fg_ignored_bp++; + } if (stale) fg_trip("render thread behind"); - if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 3.0, 24.0, 10.0); s_fg_brk_init = 1; } - if (s_fg_qok > 0) fg_gen_cost_poll(); /* once per game frame: a poll flushes */ + FG_BRK_INIT(); const double ghz = atomic_load(&s_fg_guest_hz), rhz = atomic_load(&s_fg_refresh_hz); const double flip_s = (double)s_fg_flip_vb / (ghz > 1.0 ? ghz : 59.94); const int slots = (int)floor(flip_s * rhz + 0.5); s_fg_last_slots = slots; + /* What one generated frame may cost (its swap included) to fit. */ + const double real_s = s_fg_real_ema * (double)s_fg_flip_vb + s_fg_swap_ema; + s_fg_fit_s = 0.85 * flip_s - real_s - s_fg_swap_ema; + if (s_fg_qok > 0) fg_gen_cost_poll(); /* once per game frame: a poll flushes */ int n = 0; if (A && B && A->valid && B->valid && A->disp[2] == B->disp[2] && A->disp[3] == B->disp[3] && A->wide_w == B->wide_w && A->scale > 0) { + /* The surfaces exist (and are warm) before a frame is planned on them. */ + if (!s_hiw) (void)fg_surfaces_ensure(B->wide); if (s_fg_force) n = slots > 1 ? slots - 1 : 1; - else if (fg_breaker_open(&s_fg_brk, now) && !stale && !held) - n = fg_plan(flip_s, rhz, s_fg_real_ema * (double)s_fg_flip_vb + s_fg_swap_ema, - s_fg_gen_ema > 0.0 ? s_fg_gen_ema + s_fg_swap_ema : 0.0, 0.85, 7); + else if (fg_breaker_open(&s_fg_brk, now) && !stale && !held) { + const double est = fg_cost_estimate(fg_cost(), now, s_fg_fit_s); + n = fg_plan(flip_s, rhz, real_s, est > 0.0 ? est + s_fg_swap_ema : 0.0, 0.85, 7); + } if (n == 0) s_fg_skipped_plan++; } s_fg_last_n = n; @@ -10946,24 +11001,25 @@ void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz) { } /* Not a breaker trip: generation pauses while the real frames are under - * pressure (dynamic resolution over budget or stepping down) and for a second - * after, without the breaker's escalating holds. */ -void gl_renderer_frame_gen_hold(const char *reason) { + * pressure (dynamic resolution over budget or stepping down) for the caller's + * short tail, without the breaker's escalating holds. */ +void gl_renderer_frame_gen_hold(const char *reason, double secs) { if (!s_fg_on) return; + if (secs <= 0.0) secs = 0.1; atomic_store(&s_fg_hold_reason, reason ? reason : "hold"); - atomic_store(&s_fg_hold_until, rt_now_ns() + 1000000000ull); + const uint64_t until = rt_now_ns() + (uint64_t)(secs * 1e9); + if (until > atomic_load(&s_fg_hold_until)) atomic_store(&s_fg_hold_until, until); } -/* Emulation thread, once per frame boundary: a frame that took much longer - * than the guest's interval trips the breaker. */ +/* Emulation thread, once per frame boundary: the guest is late when it has + * slipped two intervals behind its own VBlank schedule. One long frame made + * up by short ones (scheduling jitter, a burst after a wait) is not. */ static void fg_note_guest_frame(void) { if (!s_fg_on) return; - const uint64_t now = rt_now_ns(); const double ghz = atomic_load(&s_fg_guest_hz); - if (s_fg_emu_last_ns && ghz > 1.0 && - (double)(now - s_fg_emu_last_ns) > 1.5e9 / ghz) + if (ghz <= 1.0) return; + if (fg_pace_note(&s_fg_pace, (double)rt_now_ns() * 1e-9, 1.0 / ghz, 2.0 / ghz)) atomic_store(&s_fg_late, 1); - s_fg_emu_last_ns = now; } int gl_renderer_frame_gen_json(char *out, int cap) { @@ -10977,13 +11033,14 @@ int gl_renderer_frame_gen_json(char *out, int cap) { "\"match_ms\":%.3f," "\"prims\":%u,\"matched\":%u,\"moved\":%u,\"unmatched\":%u," "\"breaker_s\":%.2f,\"trips\":%u,\"last_trip\":\"%s\",\"held_s\":%.2f," - "\"last_hold\":\"%s\",\"swaps\":%llu", + "\"last_hold\":\"%s\",\"swaps\":%llu,\"gen_samples\":%u,\"gen_cold\":%u," + "\"gen_probes\":%u,\"ignored_late\":%llu,\"ignored_bp\":%llu", s_fg_on, s_fg_on && s_rth_on && open, s_fg_force, (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, (unsigned long long)s_fg_flips, (unsigned long long)s_fg_dups, (unsigned long long)s_fg_flushed, (unsigned long long)s_fg_skipped_plan, s_fg_last_n, s_fg_last_slots, s_fg_flip_vb, atomic_load(&s_fg_refresh_hz), - s_fg_real_ema * 1e3, s_fg_gen_ema * 1e3, s_fg_gen_cpu_ms, s_fg_gen_gpu_ms, + s_fg_real_ema * 1e3, fg_cost()->ema * 1e3, s_fg_gen_cpu_ms, s_fg_gen_gpu_ms, s_fg_swap_ema * 1e3, s_fg_last_match_ms, s_fg_mst.prims, s_fg_mst.matched, s_fg_mst.moved, s_fg_mst.unmatched, @@ -10991,7 +11048,9 @@ int gl_renderer_frame_gen_json(char *out, int cap) { s_fg_brk.reason ? s_fg_brk.reason : "", (double)(atomic_load(&s_fg_hold_until) > rt_now_ns() ? atomic_load(&s_fg_hold_until) - rt_now_ns() : 0) * 1e-9, - s_fg_hold_why ? s_fg_hold_why : "", (unsigned long long)s_swaps_total); + s_fg_hold_why ? s_fg_hold_why : "", (unsigned long long)s_swaps_total, + fg_cost()->samples, fg_cost()->discarded, fg_cost()->probes, + (unsigned long long)s_fg_ignored_late, (unsigned long long)s_fg_ignored_bp); } /* ---- the recording vtable ------------------------------------------------ */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a94a43b4..97ec2d28 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9361,10 +9361,12 @@ static void dynres_tick_rt(double now_s, double wall, double period, int held, const int prev_level = c.level; const int level = dynrt_sample(&c, now_s, &smp); /* Frame generation only spends surplus: not while the real frames are - * over budget or the level is stepping down. */ - if (level < prev_level || c.over_streak > 0) - gl_renderer_frame_gen_hold(level < prev_level ? "dynres stepped down" - : "dynres over budget"); + * over budget (renewed every over-budget sample) and briefly after a + * step down, while the new level's first frames settle. */ + if (level < prev_level) + gl_renderer_frame_gen_hold("dynres stepped down", 0.25); + else if (c.over_streak > 0) + gl_renderer_frame_gen_hold("dynres over budget", 0.1); if (c.windows != g_dynres.rt_last_windows) { g_dynres.rt_last_windows = c.windows; g_dynres.rt_win_cpu_ms = g_dynres.rt_acc_frames diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c index cdae85da..b89b7b16 100644 --- a/runtime/tests/test_frame_gen.c +++ b/runtime/tests/test_frame_gen.c @@ -1,7 +1,7 @@ /* Frame generation's renderer-independent half (src/frame_gen.c, * docs/FRAME_GENERATION.md): matching triangles of two frames, the * interpolation endpoints, the plan of how many in-between frames fit, and - * the breaker. */ + * the breaker, the generated-frame cost estimate and the guest pace. */ #include "frame_gen.h" #include @@ -139,7 +139,80 @@ static void test_breaker(void) { check(b.hold <= 24.0, "the hold is capped"); } +static void test_cost(void) { + FgCost c; + fg_cost_init(&c, 2.0, 16.0); + const double fit = 0.020; + check(fg_cost_estimate(&c, 0.0, fit) == 0.0, "unknown before any sample"); + /* Cold: the first frames after allocation are discarded. */ + fg_cost_cold(&c, 2); + fg_cost_add(&c, 0.070, fit); + fg_cost_add(&c, 0.040, fit); + check(c.ema == 0.0 && c.discarded == 2, "cold samples discarded"); + fg_cost_add(&c, 0.006, fit); + check(fabs(fg_cost_estimate(&c, 0.1, fit) - 0.006) < 1e-12, "first warm sample is the estimate"); + fg_cost_add(&c, 0.016, fit); + check(fabs(c.ema - 0.008) < 1e-12, "warm samples blend"); + /* A spike that pushes the estimate past the fit blocks the plan... */ + for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit); + check(fg_cost_estimate(&c, 10.0, fit) > fit, "a high estimate blocks"); + check(fg_cost_estimate(&c, 11.9, fit) > fit, "... until the probe interval"); + /* ...for at most probe_s: then one probe, whose sample replaces it. */ + check(fg_cost_estimate(&c, 12.0, fit) == 0.0 && c.probes == 1, "stale estimate re-probed"); + check(fg_cost_estimate(&c, 12.03, fit) > fit, "one probe at a time"); + fg_cost_add(&c, 0.005, fit); + check(fabs(fg_cost_estimate(&c, 12.1, fit) - 0.005) < 1e-12, "the probe replaces the estimate"); + /* A probe that still does not fit backs off. */ + for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit); + (void)fg_cost_estimate(&c, 20.0, fit); + check(fg_cost_estimate(&c, 22.0, fit) == 0.0, "second probe"); + fg_cost_add(&c, 0.050, fit); + check(c.probe_s == 4.0, "a probe that does not fit doubles the wait"); + check(fg_cost_estimate(&c, 25.9, fit) > fit && fg_cost_estimate(&c, 26.0, fit) == 0.0, + "next probe after the doubled wait"); + fg_cost_add(&c, 0.004, fit); + check(c.probe_s == 2.0, "a probe that fits resets the wait"); + /* A granted probe that was never drawn is given up, then re-granted. */ + for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit); + (void)fg_cost_estimate(&c, 40.0, fit); + check(fg_cost_estimate(&c, 42.0, fit) == 0.0, "probe granted"); + check(fg_cost_estimate(&c, 44.0, fit) > fit, "an undrawn probe expires"); + check(fg_cost_estimate(&c, 46.0, fit) == 0.0, "and is granted again"); + /* Re-allocation makes the next samples cold again. */ + fg_cost_cold(&c, 2); + fg_cost_add(&c, 0.5, fit); fg_cost_add(&c, 0.5, fit); + check(c.cold == 0 && c.ema < 0.5, "cold after re-allocation"); +} + +static void test_pace(void) { + FgPace p; + memset(&p, 0, sizeof p); + const double T = 1.0 / 59.94, slack = 2.0 * T; + double t = 0.0; + int late = 0; + for (int i = 0; i < 600; i++) { /* jitter: +-6 ms around the schedule */ + late += fg_pace_note(&p, t + ((i % 3) - 1) * 0.006, T, slack); + t += T; + } + check(late == 0, "jitter is not late"); + late = 0; /* one 40 ms frame made up by a burst */ + late += fg_pace_note(&p, t + 0.024, T, slack); t += T; + late += fg_pace_note(&p, t + 0.004, T, slack); t += T; + late += fg_pace_note(&p, t, T, slack); t += T; + check(late == 0, "a long frame made up is not late"); + /* A real slip: 60 ms gap. */ + check(fg_pace_note(&p, t + 0.045, T, slack) == 1, "a slip beyond two intervals is late"); + t += 0.045 + T; + check(fg_pace_note(&p, t, T, slack) == 0, "the schedule restarts after a slip"); + /* Slow but steady (a 50 Hz guest told 59.94): one trip, then resync each time it slips. */ + int trips = 0; + for (int i = 0; i < 60; i++) { t += 0.020; trips += fg_pace_note(&p, t, T, slack); } + check(trips > 0 && trips < 20, "a steadily slow guest is late now and then"); +} + int main(void) { + test_cost(); + test_pace(); test_identical_and_moved(); test_keys_limits_and_duplicates(); test_each_older_pairs_once(); From bf674462712fa0cc4971194490dd3d6fc3f9137c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:25:02 -0700 Subject: [PATCH 49/57] Frame generation: plan ceiling for render-thread overload, stalls judged by wait A render thread a frame behind lowers the plan's ceiling by one (recovering one per 2 s) instead of tripping the breaker; the guest waiting on the queue trips it only when the wait was a stall (a quarter of the game frame). The real-frame cost plans with CPU time; the GPU span is reported only. The generated-frame blit copies colour only and marks the stencil stale. Co-Authored-By: Claude Opus 5.5 --- runtime/include/frame_gen.h | 8 +++ runtime/src/frame_gen.c | 21 ++++++++ runtime/src/gpu_gl_renderer.c | 98 ++++++++++++++++++++++++++-------- runtime/tests/test_frame_gen.c | 18 +++++++ 4 files changed, 123 insertions(+), 22 deletions(-) diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h index 418c9b72..a24a18c6 100644 --- a/runtime/include/frame_gen.h +++ b/runtime/include/frame_gen.h @@ -117,6 +117,14 @@ void fg_cost_add(FgCost *c, double cost_s, double fit_s); * `fit_s` as above: an estimate above it is what blocks the plan. */ double fg_cost_estimate(FgCost *c, double now, double fit_s); +/* A ceiling on the plan for what the cost estimate cannot see (the real + * frames' GPU work): an overload trip while n frames were planned lowers + * it to n - 1; each `recover_s` without one raises it by one, up to max. */ +typedef struct FgCeiling { int cap, max; double last; double recover_s; } FgCeiling; +void fg_ceiling_init(FgCeiling *c, int max, double recover_s); +void fg_ceiling_trip(FgCeiling *c, int n_planned, double now); +int fg_ceiling_get(FgCeiling *c, double now); + /* The guest's own pacing: frame boundaries against a schedule that advances * one interval per frame. Late only when the guest slipped more than `slack_s` * behind its schedule (jitter, a long frame followed by a short one, does not diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index d2135b51..05d4d8b0 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -253,3 +253,24 @@ int fg_pace_note(FgPace *p, double now, double period_s, double slack_s) { p->next += period_s; return late; } + +void fg_ceiling_init(FgCeiling *c, int max, double recover_s) { + c->cap = c->max = max; + c->last = -1e30; + c->recover_s = recover_s; +} + +void fg_ceiling_trip(FgCeiling *c, int n_planned, double now) { + if (n_planned <= 0) return; + const int to = n_planned - 1 > 1 ? n_planned - 1 : 1; + if (to < c->cap) c->cap = to; + c->last = now; +} + +int fg_ceiling_get(FgCeiling *c, double now) { + while (c->cap < c->max && now - c->last >= c->recover_s) { + c->cap++; + c->last = c->last < -1e29 ? now : c->last + c->recover_s; + } + return c->cap; +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 89093d0f..8420e5b3 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -9843,7 +9843,7 @@ static int s_rthf_nseg[RTHF_Q]; static uint64_t s_rthf_slot_cpu[RTHF_Q]; static int s_rthf_paused = 0; /* a pause ended this frame's query */ static uint64_t s_rthf_pause_t0 = 0, s_rthf_pause_swap = 0, s_rthf_excl = 0; -static void fg_note_real_cost(uint64_t cost_ns); /* frame generation */ +static void fg_note_real_cost(uint64_t cpu_ns, uint64_t gpu_ns, int has_gpu); /* frame generation */ static unsigned s_rthf_head = 0, s_rthf_tail = 0; static int s_rthf_open = 0, s_rthf_taint = 0, s_rthf_has_q = 0; static uint64_t s_rthf_t0 = 0, s_rthf_idle0 = 0, s_rthf_swap0 = 0; @@ -9864,11 +9864,9 @@ static void rthf_publish(uint64_t cpu, uint64_t gpu, int has_gpu) { } atomic_fetch_add(&s_rthf_cost_ns, cost); atomic_fetch_add(&s_rthf_frames, 1); /* last: the totals above are in */ - /* Frame generation budgets with the CPU time: on macOS a TIME_ELAPSED - * span also counts the GPU waiting for a frame's records to arrive, so - * it reads close to the whole interval whatever the work. GPU overload - * shows up as a slow swap and a queue that backs up (the breaker). */ - fg_note_real_cost(cpu); + /* Frame generation budgets with the CPU time (see + * fg_note_real_cost for why only the CPU time plans). */ + fg_note_real_cost(cpu, gpu, has_gpu); } /* Read every finished query pair, oldest first, without waiting. */ @@ -10041,7 +10039,7 @@ static int32_t *s_fg_match = NULL; static uint32_t s_fg_match_cap = 0; static FgMatchStats s_fg_mst; static FgBreaker s_fg_brk; static int s_fg_brk_init = 0; -static uint64_t s_fg_bp_seen = 0; +static uint64_t s_fg_bp_seen = 0, s_fg_bp_ns_seen = 0; static double s_fg_real_ema = 0.0; /* seconds */ /* A generated frame's cost: cold samples discarded, a blocking estimate * re-probed (frame_gen.h FgCost). s_fg_fit_s: what one may cost to fit. */ @@ -10100,9 +10098,20 @@ static void fg_note_swap(uint64_t ns) { s_fg_swap_ema = s_fg_swap_ema > 0.0 ? s_fg_swap_ema * 0.9 + c * 0.1 : c; } -static void fg_note_real_cost(uint64_t cost_ns) { - const double c = (double)cost_ns * 1e-9; +/* A real VBlank frame's render-thread cost, for the plan: its CPU time. Its + * GPU span is kept for the debug output only: on macOS it also counts the + * GPU waiting for records, so with generation off it reads most of the + * VBlank (R4 1P: 10 ms for ~3 ms of work) and would leave no room. GPU + * overload shows as a queue that backs up instead: the breaker, and the + * ceiling that lowers the plan by one per such trip (FgCeiling). */ +static double s_fg_real_gpu_ema = 0.0; +static void fg_note_real_cost(uint64_t cpu_ns, uint64_t gpu_ns, int has_gpu) { + const double c = (double)cpu_ns * 1e-9; s_fg_real_ema = s_fg_real_ema > 0.0 ? s_fg_real_ema * 0.9 + c * 0.1 : c; + if (has_gpu) { + const double g = (double)gpu_ns * 1e-9; + s_fg_real_gpu_ema = s_fg_real_gpu_ema > 0.0 ? s_fg_real_gpu_ema * 0.9 + g * 0.1 : g; + } } static void fg_list_clear(FgList *l) { @@ -10428,13 +10437,16 @@ static void fg_surfaces_free(void) { s_fg_real_fbo = s_fg_real_tex = 0; s_fg_real_w = s_fg_real_h = 0; s_fg_real_kept = 0; } +/* Colour only: a depth-stencil blit costs the CPU milliseconds on macOS + * (most of a generated frame at high levels). The stencil is the mask-bit + * mirror of alpha; the caller marks it stale and it is rebuilt from the + * copied alpha only if a generated draw checks the mask. */ static void fg_blit(GLuint src, GLuint dst, int x, int y, int w, int h) { p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, src); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, dst); glDisable(GL_SCISSOR_TEST); - glStencilMask(0xFF); p_glBlitFramebuffer(x, y, x + w, y + h, x, y, x + w, y + h, - GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT, GL_NEAREST); + GL_COLOR_BUFFER_BIT, GL_NEAREST); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); } @@ -10668,6 +10680,10 @@ static int fg_generate(double t, int swap) { s_wide_tex[iw] = s_fg_w_tex; s_wide_fbo[iw] = s_fg_w_fbo; s_wide_rb[iw] = s_fg_w_rb; s_wide_as[iw] = S; } + /* The copied stencil is stale: rebuilt from alpha if a draw needs it. */ + s_stencil_valid = 0; + rect_add(&s_stencil_stale, b->disp[0], dy, b->disp[0] + b->disp[2] - 1, dy + dh - 1); + if (iw >= 0) wst_add(iw, 0, dy, g_wide_w, dy + dh); s_fg_drawing = 1; fg_replay(a, b, t, iw >= 0 ? s_fg_w_fbo : 0); fg_compose(b, iw >= 0 ? s_fg_w_fbo : 0, iw >= 0 ? s_fg_w_tex : 0, b->linear, b->force43); @@ -10704,9 +10720,29 @@ static void rth_present_payload(uint16_t op, const uint8_t *p); /* below */ /* Short first hold, doubling on repeats within 2 s of the last hold's end, * up to 8 s: one hiccup costs a fraction of a second of generation. */ #define FG_BRK_INIT() do { if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 0.5, 8.0, 2.0); s_fg_brk_init = 1; } } while (0) +static FgCeiling s_fg_ceil; +static int s_fg_ceil_init = 0; +static FgCeiling *fg_ceil(void) { + if (!s_fg_ceil_init) { fg_ceiling_init(&s_fg_ceil, 7, 2.0); s_fg_ceil_init = 1; } + return &s_fg_ceil; +} +static uint32_t s_fg_trip_late = 0, s_fg_trip_bp = 0, s_fg_trip_behind = 0; static void fg_trip(const char *why) { FG_BRK_INIT(); - fg_breaker_trip(&s_fg_brk, fg_now_s(), why); + if (why[0] == 'g') s_fg_trip_late++; + else s_fg_trip_bp++; + const double now = fg_now_s(); + fg_breaker_trip(&s_fg_brk, now, why); + fg_ceiling_trip(fg_ceil(), s_fg_pending ? s_fg_n : s_fg_last_n, now); +} +/* The render thread a frame behind (a stale present, two frames queued): + * proportional, not a stop. 2P races show stale presents with generation + * off as well, so it lowers the plan's ceiling by one (recovering one per + * 2 s) and the real frame shows at once; the breaker is for the guest + * slipping or stalling on the queue. */ +static void fg_overload(void) { + s_fg_trip_behind++; + fg_ceiling_trip(fg_ceil(), s_fg_pending ? s_fg_n : s_fg_last_n, fg_now_s()); } static void fg_gen_cost_poll(void) { @@ -10833,9 +10869,9 @@ static uint64_t fg_tick(void *user, uint64_t now) { const uint64_t bp = rt_backpressure_events(); const int ahead = rt_frames_ahead(); if (!s_fg_force && (ahead >= 1 || bp != s_fg_bp_seen)) { - if (bp != s_fg_bp_seen) { - if (fg_recently_generated()) fg_trip("queue backed up"); else s_fg_ignored_bp++; - } else if (ahead >= 2) fg_trip("render thread behind"); + /* Backpressure itself is judged at the flip, by how long the guest + * waited (fg_on_present); here it only shows the real frame now. */ + if (bp == s_fg_bp_seen && ahead >= 2 && fg_recently_generated()) fg_overload(); s_fg_bp_seen = bp; fg_flush(); return 0; @@ -10916,12 +10952,25 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal const char *why = atomic_exchange(&s_fg_hold_reason, NULL); if (why) s_fg_hold_why = why; const int held = rt_now_ns() < atomic_load(&s_fg_hold_until); + /* The guest waiting on the queue trips the breaker when the wait since + * the last flip was a stall (a quarter of the game frame), not the short + * waits a 2P race shows with generation off as well. */ const uint64_t bp = rt_backpressure_events(); - if (bp != s_fg_bp_seen) { - s_fg_bp_seen = bp; - if (gen_recent) fg_trip("queue backed up"); else s_fg_ignored_bp++; + s_fg_bp_seen = bp; + { + RtStats rs; + rt_get_stats(&rs); + const uint64_t bns = rs.backpressure_ns + rs.ring_full_ns; + const double waited = (double)(bns - s_fg_bp_ns_seen) * 1e-9; + const double ghz0 = atomic_load(&s_fg_guest_hz); + s_fg_bp_ns_seen = bns; + if (waited > 0.25 * (double)s_fg_flip_vb / (ghz0 > 1.0 ? ghz0 : 59.94)) { + if (gen_recent) fg_trip("queue backed up"); else s_fg_ignored_bp++; + } else if (waited > 0.0) { + s_fg_ignored_bp++; + } } - if (stale) fg_trip("render thread behind"); + if (stale && gen_recent) fg_overload(); FG_BRK_INIT(); const double ghz = atomic_load(&s_fg_guest_hz), rhz = atomic_load(&s_fg_refresh_hz); const double flip_s = (double)s_fg_flip_vb / (ghz > 1.0 ? ghz : 59.94); @@ -10939,7 +10988,8 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal if (s_fg_force) n = slots > 1 ? slots - 1 : 1; else if (fg_breaker_open(&s_fg_brk, now) && !stale && !held) { const double est = fg_cost_estimate(fg_cost(), now, s_fg_fit_s); - n = fg_plan(flip_s, rhz, real_s, est > 0.0 ? est + s_fg_swap_ema : 0.0, 0.85, 7); + n = fg_plan(flip_s, rhz, real_s, est > 0.0 ? est + s_fg_swap_ema : 0.0, 0.85, + fg_ceiling_get(fg_ceil(), now)); } if (n == 0) s_fg_skipped_plan++; } @@ -11034,7 +11084,9 @@ int gl_renderer_frame_gen_json(char *out, int cap) { "\"prims\":%u,\"matched\":%u,\"moved\":%u,\"unmatched\":%u," "\"breaker_s\":%.2f,\"trips\":%u,\"last_trip\":\"%s\",\"held_s\":%.2f," "\"last_hold\":\"%s\",\"swaps\":%llu,\"gen_samples\":%u,\"gen_cold\":%u," - "\"gen_probes\":%u,\"ignored_late\":%llu,\"ignored_bp\":%llu", + "\"gen_probes\":%u,\"ignored_late\":%llu,\"ignored_bp\":%llu," + "\"real_cpu_ms\":%.3f,\"real_gpu_ms\":%.3f,\"ceiling\":%d," + "\"trips_late\":%u,\"trips_backed_up\":%u,\"trips_behind\":%u", s_fg_on, s_fg_on && s_rth_on && open, s_fg_force, (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, (unsigned long long)s_fg_flips, (unsigned long long)s_fg_dups, @@ -11050,7 +11102,9 @@ int gl_renderer_frame_gen_json(char *out, int cap) { ? atomic_load(&s_fg_hold_until) - rt_now_ns() : 0) * 1e-9, s_fg_hold_why ? s_fg_hold_why : "", (unsigned long long)s_swaps_total, fg_cost()->samples, fg_cost()->discarded, fg_cost()->probes, - (unsigned long long)s_fg_ignored_late, (unsigned long long)s_fg_ignored_bp); + (unsigned long long)s_fg_ignored_late, (unsigned long long)s_fg_ignored_bp, + s_fg_real_ema * 1e3, s_fg_real_gpu_ema * 1e3, fg_ceil()->cap, + s_fg_trip_late, s_fg_trip_bp, s_fg_trip_behind); } /* ---- the recording vtable ------------------------------------------------ */ diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c index b89b7b16..eed76216 100644 --- a/runtime/tests/test_frame_gen.c +++ b/runtime/tests/test_frame_gen.c @@ -210,7 +210,25 @@ static void test_pace(void) { check(trips > 0 && trips < 20, "a steadily slow guest is late now and then"); } +static void test_ceiling(void) { + FgCeiling c; + fg_ceiling_init(&c, 7, 2.0); + check(fg_ceiling_get(&c, 0.0) == 7, "no ceiling before a trip"); + fg_ceiling_trip(&c, 3, 1.0); + check(fg_ceiling_get(&c, 1.5) == 2, "an overload at three plans two"); + fg_ceiling_trip(&c, 2, 1.6); + check(fg_ceiling_get(&c, 1.7) == 1, "another lowers it again"); + fg_ceiling_trip(&c, 1, 1.8); + check(fg_ceiling_get(&c, 1.9) == 1, "never below one (the breaker stops generation)"); + check(fg_ceiling_get(&c, 3.7) == 1 && fg_ceiling_get(&c, 3.9) == 2, "recovers one per 2 s"); + check(fg_ceiling_get(&c, 7.9) == 4, "and keeps recovering"); + fg_ceiling_trip(&c, 0, 8.0); + check(fg_ceiling_get(&c, 8.0) == 4, "a trip with nothing planned is not an overload"); + check(fg_ceiling_get(&c, 100.0) == 7, "up to the maximum"); +} + int main(void) { + test_ceiling(); test_cost(); test_pace(); test_identical_and_moved(); From 64036e2ee70d4576de9827d65be5c8739d32ee0c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:28:44 -0700 Subject: [PATCH 50/57] Frame generation: in-between frames from an in-between camera Pairing triangles of consecutive draw lists by key and draw order paired the wrong ones whenever the game reordered, culled or switched LODs (R4: a grandstand of one repeated texture turned into a staircase; on a synthetic version 225 of 226 pairs were wrong). No primitive is paired by draw order any more. gte.cpp notes each RTPS/RTPT output (only while generation is on) with an identity (issuing ra + model-space vertex), its camera-space position and H, indexed by screen word; gpu.c records each polygon vertex's source ahead of the triangle (RTH_FG_SRC, never drawn). Per view the camera motion between the two frames is fitted from the paired camera-space vertices (RANSAC + least squares); one real frame is redrawn from the in-between camera: world vertices re-projected, vertices moving against the world (cars) lerped in camera space, CPU-built vertices moved with their triangle, HUD/2D unchanged. Moving forward the older frame is redrawn; uncovered areas show the newer real frame. A verdict rejects the generated frame (the real one shows) without projections, with few pairs/inliers, or with a motion no camera makes. Real frames are unchanged (gl_frame_gen_test). PSX_PRESENT_SHOT_BURST=N captures the next N composed frames, real and generated, for checking in-between frames against their neighbours. Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_GENERATION.md | 64 +++- runtime/include/cpu_state.h | 4 + runtime/include/frame_gen.h | 110 ++++-- runtime/include/gpu_gl_renderer.h | 6 + runtime/src/frame_gen.c | 553 ++++++++++++++++++++++++------ runtime/src/gpu.c | 12 + runtime/src/gpu_gl_renderer.c | 294 ++++++++++++++-- runtime/src/gte.cpp | 100 ++++++ runtime/tests/test_frame_gen.c | 241 ++++++++----- runtime/tests/test_gl_frame_gen.c | 52 ++- 10 files changed, 1184 insertions(+), 252 deletions(-) diff --git a/docs/FRAME_GENERATION.md b/docs/FRAME_GENERATION.md index 898dd6a0..01891e8b 100644 --- a/docs/FRAME_GENERATION.md +++ b/docs/FRAME_GENERATION.md @@ -30,19 +30,34 @@ has measured time to spare. game frame; a present of the same buffer is the same frame again. R4 updates every other VBlank, so generation interpolates between flips (30 Hz game frames), not between VBlanks. -- **Matching** (`frame_gen.c`). Triangles are keyed by op, texture page, - CLUT, texture coordinates and the draw area they were clipped to - (relative to their buffer); positions are relative to the buffer. Each - newer triangle pairs, in draw order, with the nearest older one of the same - key whose three vertices moved at most 96 native px and alike (within - 24 px of each other). R4 races pair 87-97 % of their triangles. -- **Drawing.** An in-between frame redraws the newer list into separate - surfaces (a copy of the displayed buffer's hr rect and wide rows) with each - matched triangle at `lerp(older, newer, t)`, sub-pixel via the precise - triangle path; unmatched triangles, rects, lines, fills (2D, HUD) draw as - the newer frame has them. The raw texture mirror is not packed from the - generated surfaces; all bookkeeping the draws touch is restored, so the - real stream is unaffected. +- **Sources.** While generation is on, every RTPS/RTPT output is noted + (gte.cpp, `gte_fg_source_*`) with an identity (the issuing function `ra` + and the model-space vertex), the camera-space position the GTE divided and + H, indexed by the packed screen word. gpu.c looks each polygon vertex up by + its packet word and records the sources ahead of the triangle + (`RTH_FG_SRC`, never drawn). CPU-built vertices have none. Guest-visible + GTE results are untouched. +- **Camera** (`frame_gen.c`, `fg_cam_fit`). No primitive is paired by draw + order. Per view (draw area), vertices of the two frames with the same + identity are paired in camera space (instances: the clearly nearest) and a + rigid motion is fitted (RANSAC + least squares, inliers within 1 screen px + at their depth). The static world agrees with it; paired vertices that + disagree are objects (cars, the followed car). Verdict: no projections, a + view with few pairs or under half inliers, or a turn/travel no camera makes + between two frames rejects the generated frame (the real frame shows). +- **Drawing** (`fg_cam_place`). One real frame is redrawn from an + in-between camera: world vertices re-projected through the fraction of the + fitted motion, object vertices lerped in camera space, CPU-built vertices + of placed triangles moved with their neighbours, everything else (HUD, 2D, + sprites, the mirror's own view if unprojected) unchanged. Moving forward + the older frame is redrawn (seen from further on it spreads past the + screen edges); otherwise the newer. Drawing starts on the newer real + frame's image and skips the clears before the first draw, and strips along + a view's edges the picture moved away from are copied from that image, so + what the in-between camera uncovers shows the newer frame instead of a + hole. Triangles crossing the camera plane are dropped. The raw texture + mirror is not packed from the generated surfaces; all bookkeeping the draws + touch is restored, so the real stream is unaffected. - **Schedule.** With `n` in-between frames per game frame, at the flip the real frame is composed and kept (its buffer may be drawn over before it is shown), frames `t = k/(n+1)` are presented `flip/(n+1)` apart, then the @@ -73,7 +88,9 @@ has measured time to spare. ## Limits -- Only triangles move; sprites, lines and fills are the newer frame's. +- Only GTE-projected triangles move; sprites, lines and fills are the + redrawn frame's. Areas uncovered by the in-between camera show the newer + frame (thin slivers at depth discontinuities are possible). - A texture the frame drew earlier into its own displayed buffer is sampled as the raw mirror holds it when the in-between frame is drawn. - Single-buffered games never flip, so nothing is generated. @@ -83,17 +100,26 @@ has measured time to spare. `{"cmd":"frame_gen"}` (not a sync point): generated / real presents, flips, dups, plan (`last_n`, `slots`), costs (`real_ms`, `gen_ms`, `swap_ms`), last -match counts, breaker, last hold, total swaps. +camera fit (`views`, `pairs`, `inliers`, `place_*`, `cam_angle_deg`, +`cam_shift`), verdict (`verdict_ok`, `rejected`, `reject_why`), breaker, +ceiling, last hold, total swaps. `PSX_PRESENT_SHOT_GENERATED=1` (diagnostic): a staged `present_shot` is taken at the next generated frame instead of the next present, to inspect -in-between images in game. +in-between images in game. `PSX_PRESENT_SHOT_BURST=N`: a staged +`present_shot` takes the next N composed frames (written after the burst as +`_NN_{r|g}_f.png`, in composition order: a real frame precedes +the in-between frames that lead up to it). ## Tests -- `frame_gen_test`: matching (moved scenes, keys, move and deformation - limits, duplicates, pair-once), interpolation endpoints, the plan, the - breaker. +- `frame_gen_test`: the camera fit on a synthetic grandstand of one + repeated texture, redrawn in another order with a row culled and one added + (the case the old draw-order matcher paired wrongly: 225 of 226 pairs) - + phase 0 / 0.5 / 1 land where the older / halfway / newer camera sees each + vertex; a followed car, a HUD triangle, a CPU-built vertex; the verdict + (no projections, a cut, an impossible turn, inconsistent motion); the plan, + breaker, cost, pace, ceiling. - `render_thread_test`: the tick hook runs on the render thread with the context, never early, and wakes an idle thread. - `gl_frame_gen_test` (real GL, `gpu;opengl;hardware`): a double-buffered diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 31b44d6b..cf75c591 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -221,6 +221,10 @@ extern void gte_precision_tracking_set(int enabled); * unchanged — this is a visual-only enhancement, default off. */ extern void gte_geometry_correction_set(int enabled); extern int gte_geometry_correction_enabled(void); +/* Frame generation's vertex sources (gte.cpp): 0 = unknown. */ +extern void gte_fg_source_set(int enabled); +extern int gte_fg_source_enabled(void); +extern int gte_fg_source_lookup(uint32_t packed, uint32_t *id, int32_t p[3], int32_t *hdist); /* Corrected vertices consumed since the last gte_geometry_correction_set — * the "is this actually doing anything on this title" counter. */ extern uint32_t gte_geometry_correction_hits(void); diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h index a24a18c6..5ea3eed7 100644 --- a/runtime/include/frame_gen.h +++ b/runtime/include/frame_gen.h @@ -8,14 +8,38 @@ * The GL backend (gpu_gl_renderer.c) owns the record lists themselves, * drawing and presenting; nothing here touches GL or guest state. * - * A primitive is a triangle of one frame's draw list with a match key (the - * draw op, texture page, CLUT, texture coordinates, the draw area it was - * clipped to) and its three screen positions in native pixels relative to - * that frame's displayed buffer. Matching pairs each primitive of the newer - * frame with one of the older frame's, in draw order: the same key, every - * vertex moved at most max_move, and the three vertices moved alike (a - * rigid-ish motion, at most max_deform apart). A primitive with no partner - * is drawn where the newer frame has it. + * A primitive is a triangle of one frame's draw list with a key (the draw + * op, texture page, CLUT, texture coordinates, the draw area it was clipped + * to), a view (its draw area: split-screen views have their own cameras), its + * three screen positions in native pixels relative to that frame's displayed + * buffer and, per vertex, the GTE projection that produced it when one did + * (gte_fg_source_lookup): an identity (the projecting function and the + * model-space vertex), the camera-space position the GTE divided and the + * projection distance H. + * + * In-between frames are the NEWER frame redrawn from an in-between camera; + * no primitive of the older frame is drawn and none is paired by draw order. + * Camera motion: per view, vertices of both frames with the same identity + * are paired in camera space and a rigid motion old -> new is fitted to them + * (RANSAC, then least squares on the inliers: the static world agrees, moving + * objects are outliers). A vertex is placed at phase t by + * - camera: the fitted motion's fraction t applied to the older camera, + * i.e. P_t = D^t D^-1 P_new (the static world, and anything new); + * - object: a paired vertex that moved against the world (a car, the + * player's own car under a chase camera) lerps its camera-space position; + * - neighbours: a vertex without a projection (CPU-built), or one new to + * a moving object, in a triangle with placed vertices moves by their + * mean screen motion, per position; + * - unchanged: everything else (HUD, 2D, sprites). + * Each is re-projected (H * x / z) and moved by the difference from its own + * newer projection, so draw offsets and sub-pixel positions stay the game's. + * What the in-between camera uncovers shows the newer frame: the caller + * starts from its image and skips the clears before the first draw, and the + * strips along a view's edges the picture moved away from (fg_cam_place's + * margins) are the newer frame's. + * The fit's verdict rejects the whole generated frame (the real frame shows) + * when a view with projections has too few pairs, too few inliers, or a + * motion no camera makes between two frames. */ #include #include @@ -26,8 +50,13 @@ extern "C" { typedef struct FgPrim { uint32_t rec; /* index of its record in the owner's list */ - uint32_t key; /* match key (fg_hash) */ + uint32_t key; /* fg_hash of what it draws */ + uint32_t view; /* fg_hash of its draw area */ + float area[4]; /* that draw area (x1, y1, x2, y2), relative like x/y */ + uint32_t vid[3]; /* projection identities, 0 = no projection */ float x[3], y[3]; /* native px, relative to the list's display origin */ + float p[3][3]; /* camera-space positions (GTE units) */ + float h[3]; /* projection distance */ } FgPrim; typedef struct FgPrimList { @@ -44,25 +73,56 @@ int fg_prims_add(FgPrimList *l, const FgPrim *p); uint32_t fg_hash(uint32_t h, const int32_t *w, int n); #define FG_HASH_INIT 2166136261u -typedef struct FgMatchParams { - float max_move; /* native px a vertex may move between the frames */ - float max_deform; /* native px the vertices' moves may differ by */ - int window; /* same-key candidates considered per primitive */ -} FgMatchParams; -void fg_match_defaults(FgMatchParams *p); +typedef struct FgCamParams { + int iters; /* RANSAC samples per view */ + float tol_px; /* inlier: |D a - b| within this many screen px at b's depth */ + float min_inliers; /* share of pairs the camera must explain */ + uint32_t min_pairs; /* pairs a view with projections needs */ + float max_angle; /* radians the camera may turn between frames */ + float max_shift; /* camera travel, as a share of the median depth */ + float max_obj; /* object pairing: |b - a| < max_obj * |b.z| */ +} FgCamParams; +void fg_cam_defaults(FgCamParams *p); -typedef struct FgMatchStats { - uint32_t prims, matched, moved, unmatched; -} FgMatchStats; +#define FG_MAX_VIEWS 4 +typedef struct FgView { + uint32_t view, sources, pairs, inliers; + int ok; + float area[4]; /* the view's draw area */ + double q[4], t[3]; /* old -> new: rotation quaternion (w,x,y,z), translation */ + const char *why; +} FgView; -/* For every primitive of `newer`, the index of its partner in `older` or -1 - * (match[newer->n]). Each older primitive pairs at most once. Returns the - * matched count. */ -uint32_t fg_match(const FgPrimList *older, const FgPrimList *newer, - const FgMatchParams *p, int32_t *match, FgMatchStats *st); +typedef struct FgCamFit { + int ok; + const char *why; + uint32_t nviews, prims, camera, object, neighbour, unchanged; + FgView v[FG_MAX_VIEWS]; +} FgCamFit; -/* Positions at phase t between a (t = 0) and b (t = 1). */ -void fg_lerp(const FgPrim *a, const FgPrim *b, double t, float x[3], float y[3]); +/* How each newer vertex is placed (fg_cam_fit fills it, newer->n * 3). */ +typedef struct FgVert { + uint8_t mode; /* FG_PLACE_* */ + int8_t view; /* index into FgCamFit.v, -1 */ + uint8_t paired; /* the older frame has this vertex */ + float a[3]; /* FG_PLACE_OBJECT: the older camera-space position */ +} FgVert; +enum { FG_PLACE_UNCHANGED = 0, FG_PLACE_CAMERA, FG_PLACE_OBJECT, FG_PLACE_NEIGHBOUR }; + +/* Fits the camera motion older -> newer per view and decides each newer + * vertex's placement; returns fit->ok (the verdict). */ +int fg_cam_fit(const FgPrimList *older, const FgPrimList *newer, const FgCamParams *p, + FgCamFit *fit, FgVert *verts); + +/* Screen positions of every newer vertex at phase t (0 = the older frame's + * camera, 1 = the newer frame exactly): x/y[newer->n * 3], NaN for a vertex + * behind the in-between camera (its triangle is not drawn). margin (may be + * NULL) receives, per view, how far the picture moved in from each edge of + * its draw area (left, top, right, bottom, px): the newer frame drew nothing + * beyond its edges, so that strip has no in-between picture and the caller + * shows the newer frame there. */ +void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *verts, + double t, float *x, float *y, float margin[][4]); /* How many in-between frames to draw per game frame. * flip_s the game frame's interval (time between flips) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index ebc6edb2..0ce21b8c 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -334,6 +334,12 @@ void gl_renderer_dynres_stats(GlDynresStats *out); * (not a breaker trip), e.g. while dynamic resolution is over budget. * json: the debug server's {"cmd":"frame_gen"} fields (no sync point). */ void gl_renderer_set_frame_generation(int on); +/* The sources of the next triangle's vertices (gte_fg_source_lookup: id, + * camera-space x/y/z, projection distance H; id 0 = none) and the integer + * positions it will be drawn at; recorded for frame generation only, never + * drawn. */ +void gl_renderer_fg_source(const uint32_t id[3], const int32_t pc[9], const int32_t h[3], + const int32_t x[3], const int32_t y[3]); int gl_renderer_frame_generation(void); void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz); void gl_renderer_frame_gen_hold(const char *reason, double secs); diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index 05d4d8b0..646f1fbd 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -37,119 +37,480 @@ uint32_t fg_hash(uint32_t h, const int32_t *w, int n) { return h; } -void fg_match_defaults(FgMatchParams *p) { - p->max_move = 96.0f; - p->max_deform = 24.0f; - p->window = 16; +void fg_cam_defaults(FgCamParams *p) { + p->iters = 64; + p->tol_px = 1.0f; + p->min_inliers = 0.5f; + p->min_pairs = 24; + p->max_angle = 0.35f; + p->max_shift = 0.5f; + p->max_obj = 0.25f; } -/* Open-addressing table from key to the first older primitive with it; the - * older primitives with one key are chained in draw order. */ -typedef struct { - uint32_t *keys; - int32_t *head; - uint32_t mask; -} KeyTab; +/* ---- rigid motion ---- */ +typedef struct { double q[4], t[3]; } Rigid; -static int tab_init(KeyTab *t, uint32_t n) { - uint32_t cap = 64; - while (cap < n * 2u) cap <<= 1; - t->keys = (uint32_t *)malloc((size_t)cap * sizeof *t->keys); - t->head = (int32_t *)malloc((size_t)cap * sizeof *t->head); - t->mask = cap - 1; - if (!t->keys || !t->head) return 0; - for (uint32_t i = 0; i < cap; i++) t->head[i] = -1; +static void q_to_m(const double q[4], double m[9]) { + const double w = q[0], x = q[1], y = q[2], z = q[3]; + m[0] = 1 - 2 * (y * y + z * z); m[1] = 2 * (x * y - w * z); m[2] = 2 * (x * z + w * y); + m[3] = 2 * (x * y + w * z); m[4] = 1 - 2 * (x * x + z * z); m[5] = 2 * (y * z - w * x); + m[6] = 2 * (x * z - w * y); m[7] = 2 * (y * z + w * x); m[8] = 1 - 2 * (x * x + y * y); +} + +static void m_mul_v(const double m[9], const double v[3], double o[3]) { + for (int i = 0; i < 3; i++) o[i] = m[3 * i] * v[0] + m[3 * i + 1] * v[1] + m[3 * i + 2] * v[2]; +} + +/* Least-squares rigid motion a -> b (Horn's quaternion method; the largest + * eigenvector by Jacobi rotations). */ +static int fit_rigid(const float (*a)[3], const float (*b)[3], const uint32_t *idx, uint32_t n, Rigid *r) { + if (n < 3) return 0; + double ca[3] = { 0, 0, 0 }, cb[3] = { 0, 0, 0 }; + for (uint32_t i = 0; i < n; i++) + for (int k = 0; k < 3; k++) { ca[k] += a[idx[i]][k]; cb[k] += b[idx[i]][k]; } + for (int k = 0; k < 3; k++) { ca[k] /= n; cb[k] /= n; } + double S[3][3] = { { 0 } }; + for (uint32_t i = 0; i < n; i++) + for (int u = 0; u < 3; u++) + for (int v = 0; v < 3; v++) + S[u][v] += (a[idx[i]][u] - ca[u]) * (b[idx[i]][v] - cb[v]); + const double N[4][4] = { + { S[0][0] + S[1][1] + S[2][2], S[1][2] - S[2][1], S[2][0] - S[0][2], S[0][1] - S[1][0] }, + { S[1][2] - S[2][1], S[0][0] - S[1][1] - S[2][2], S[0][1] + S[1][0], S[2][0] + S[0][2] }, + { S[2][0] - S[0][2], S[0][1] + S[1][0], -S[0][0] + S[1][1] - S[2][2], S[1][2] + S[2][1] }, + { S[0][1] - S[1][0], S[2][0] + S[0][2], S[1][2] + S[2][1], -S[0][0] - S[1][1] + S[2][2] } }; + /* Cyclic Jacobi: the eigenvector of the largest eigenvalue, exactly. */ + double A[4][4], V[4][4]; + for (int i = 0; i < 4; i++) + for (int j = 0; j < 4; j++) { A[i][j] = N[i][j]; V[i][j] = i == j; } + for (int sweep = 0; sweep < 50; sweep++) { + double off = 0; + for (int i = 0; i < 4; i++) for (int j = i + 1; j < 4; j++) off += A[i][j] * A[i][j]; + if (off < 1e-30) break; + for (int pp = 0; pp < 4; pp++) + for (int qq = pp + 1; qq < 4; qq++) { + if (fabs(A[pp][qq]) < 1e-300) continue; + const double th = (A[qq][qq] - A[pp][pp]) / (2.0 * A[pp][qq]); + const double tt = (th >= 0 ? 1.0 : -1.0) / (fabs(th) + sqrt(th * th + 1.0)); + const double c = 1.0 / sqrt(tt * tt + 1.0), sn = tt * c; + for (int k = 0; k < 4; k++) { + const double akp = A[k][pp], akq = A[k][qq]; + A[k][pp] = c * akp - sn * akq; A[k][qq] = sn * akp + c * akq; + } + for (int k = 0; k < 4; k++) { + const double apk = A[pp][k], aqk = A[qq][k]; + A[pp][k] = c * apk - sn * aqk; A[qq][k] = sn * apk + c * aqk; + } + for (int k = 0; k < 4; k++) { + const double vkp = V[k][pp], vkq = V[k][qq]; + V[k][pp] = c * vkp - sn * vkq; V[k][qq] = sn * vkp + c * vkq; + } + } + } + int best = 0; + for (int i = 1; i < 4; i++) if (A[i][i] > A[best][best]) best = i; + double q[4], len = 0; + for (int i = 0; i < 4; i++) { q[i] = V[i][best]; len += q[i] * q[i]; } + len = sqrt(len); + if (!(len > 0)) return 0; + for (int i = 0; i < 4; i++) q[i] /= len; + if (q[0] < 0) for (int i = 0; i < 4; i++) q[i] = -q[i]; + memcpy(r->q, q, sizeof q); + double m[9], rc[3]; + q_to_m(q, m); + m_mul_v(m, ca, rc); + for (int k = 0; k < 3; k++) r->t[k] = cb[k] - rc[k]; return 1; } -static int32_t *tab_slot(KeyTab *t, uint32_t key) { - uint32_t i = (key * 2654435761u) & t->mask; - while (t->head[i] >= 0 && t->keys[i] != key) i = (i + 1) & t->mask; - t->keys[i] = key; - return &t->head[i]; +static int is_inlier(const double m[9], const double t[3], const float a[3], const float b[3], + float h, const FgCamParams *p) { + const double av[3] = { a[0], a[1], a[2] }; + double o[3]; + m_mul_v(m, av, o); + double e = 0; + for (int k = 0; k < 3; k++) { const double d = o[k] + t[k] - b[k]; e += d * d; } + /* In screen pixels at the point's depth. */ + const double tol = p->tol_px * fmax(fabs(b[2]), 1.0) / fmax(h, 1.0); + return e < tol * tol; } -uint32_t fg_match(const FgPrimList *older, const FgPrimList *newer, - const FgMatchParams *p, int32_t *match, FgMatchStats *st) { - FgMatchStats s; - memset(&s, 0, sizeof s); - s.prims = newer->n; - for (uint32_t i = 0; i < newer->n; i++) match[i] = -1; - if (!older->n || !newer->n) { - s.unmatched = newer->n; - if (st) *st = s; - return 0; +/* ---- pairing ---- */ +typedef struct { uint32_t id; float p[3], h; } Src; + +static int src_cmp(const void *x, const void *y) { + const Src *a = (const Src *)x, *b = (const Src *)y; + if (a->id != b->id) return a->id < b->id ? -1 : 1; + for (int k = 0; k < 3; k++) if (a->p[k] != b->p[k]) return a->p[k] < b->p[k] ? -1 : 1; + return 0; +} + +/* The distinct sources of one view, sorted. */ +static uint32_t gather(const FgPrimList *l, uint32_t view, Src *out) { + uint32_t n = 0; + for (uint32_t j = 0; j < l->n; j++) { + const FgPrim *pr = &l->v[j]; + if (pr->view != view) continue; + for (int k = 0; k < 3; k++) { + if (!pr->vid[k]) continue; + out[n].id = pr->vid[k]; + memcpy(out[n].p, pr->p[k], sizeof out[n].p); + out[n].h = pr->h[k]; + n++; + } } - KeyTab t; - int32_t *next = (int32_t *)malloc((size_t)older->n * sizeof *next); - uint8_t *used = (uint8_t *)calloc(older->n, 1); - if (!tab_init(&t, older->n) || !next || !used) { - free(t.keys); free(t.head); free(next); free(used); - s.unmatched = newer->n; - if (st) *st = s; - return 0; + qsort(out, n, sizeof *out, src_cmp); + uint32_t w = 0; + for (uint32_t i = 0; i < n; i++) + if (!w || src_cmp(&out[w - 1], &out[i]) != 0) out[w++] = out[i]; + return w; +} + +static uint32_t lower(const Src *s, uint32_t n, uint32_t id) { + uint32_t lo = 0, hi = n; + while (lo < hi) { const uint32_t m = (lo + hi) / 2; if (s[m].id < id) lo = m + 1; else hi = m; } + return lo; +} + +static float dist3(const float a[3], const float b[3]) { + const float dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2]; + return sqrtf(dx * dx + dy * dy + dz * dz); +} + +static double median_z(const Src *s, uint32_t n) { + if (!n) return 0; + /* A coarse median: the depths in 64 buckets. */ + double lo = 1e30, hi = -1e30; + for (uint32_t i = 0; i < n; i++) { if (s[i].p[2] < lo) lo = s[i].p[2]; if (s[i].p[2] > hi) hi = s[i].p[2]; } + if (hi <= lo) return lo; + uint32_t b[64] = { 0 }; + for (uint32_t i = 0; i < n; i++) { + int k = (int)((s[i].p[2] - lo) / (hi - lo) * 63.999); + b[k]++; } - /* Chains in draw order: insert from the back. */ - for (uint32_t i = older->n; i-- > 0;) { - int32_t *h = tab_slot(&t, older->v[i].key); - next[i] = *h; - *h = (int32_t)i; + uint32_t c = 0; + for (int k = 0; k < 64; k++) { c += b[k]; if (c * 2 >= n) return lo + (hi - lo) * (k + 0.5) / 64.0; } + return hi; +} + +static int fit_view(const FgPrimList *older, const FgPrimList *newer, const FgCamParams *p, + FgView *v, Src **pa_out, Src **pb_out, uint8_t **in_out, uint32_t *np_out, + Src **nb_out, uint32_t *nnb_out) { + Src *sa = (Src *)malloc(((size_t)older->n * 3 + 1) * sizeof *sa); + Src *sb = (Src *)malloc(((size_t)newer->n * 3 + 1) * sizeof *sb); + if (!sa || !sb) { free(sa); free(sb); v->why = "out of memory"; return 0; } + const uint32_t na = gather(older, v->view, sa), nb = gather(newer, v->view, sb); + v->sources = nb; + /* Pairs: the same identity; among several (instances of one mesh), the + * nearest, when clearly the nearest. */ + float (*a)[3] = (float (*)[3])malloc(((size_t)nb + 1) * sizeof *a); + float (*b)[3] = (float (*)[3])malloc(((size_t)nb + 1) * sizeof *b); + float *hb = (float *)malloc(((size_t)nb + 1) * sizeof *hb); + uint32_t *bi = (uint32_t *)malloc(((size_t)nb + 1) * sizeof *bi); + uint32_t np = 0; + for (uint32_t i = 0; a && b && bi && hb && i < nb; i++) { + uint32_t j = lower(sa, na, sb[i].id); + float best = -1, second = -1; + uint32_t bj = 0; + for (; j < na && sa[j].id == sb[i].id; j++) { + const float d = dist3(sa[j].p, sb[i].p); + if (best < 0 || d < best) { second = best; best = d; bj = j; } + else if (second < 0 || d < second) second = d; + } + if (best < 0) continue; + if (second >= 0 && best > 0.5f * second) continue; + memcpy(a[np], sa[bj].p, sizeof a[np]); + memcpy(b[np], sb[i].p, sizeof b[np]); + hb[np] = sb[i].h; + bi[np++] = i; } - const float mm = p->max_move * p->max_move; - const float md = p->max_deform * p->max_deform; + v->pairs = np; + uint8_t *in = (uint8_t *)calloc((size_t)np + 1, 1); + uint32_t *idx = (uint32_t *)malloc(((size_t)np + 1) * sizeof *idx); + int ok = 0; + if (!a || !b || !bi || !hb || !in || !idx) { v->why = "out of memory"; goto done; } + if (np < p->min_pairs) { v->why = "few pairs"; goto done; } + { + Rigid best_r; + uint32_t best_n = 0, seed = 0x9E3779B9u ^ np; + double m[9]; + for (int it = 0; it < p->iters; it++) { + uint32_t s3[3]; + for (int k = 0; k < 3; k++) { seed = seed * 1664525u + 1013904223u; s3[k] = (seed >> 8) % np; } + if (s3[0] == s3[1] || s3[1] == s3[2] || s3[0] == s3[2]) continue; + Rigid r; + if (!fit_rigid((const float (*)[3])a, (const float (*)[3])b, s3, 3, &r)) continue; + q_to_m(r.q, m); + uint32_t c = 0; + for (uint32_t i = 0; i < np; i++) c += (uint32_t)is_inlier(m, r.t, a[i], b[i], hb[i], p); + if (c > best_n) { best_n = c; best_r = r; } + } + if (best_n < 3) { v->why = "no consistent camera"; goto done; } + /* Refine on the inliers, twice. */ + for (int pass = 0; pass < 2; pass++) { + q_to_m(best_r.q, m); + uint32_t c = 0; + for (uint32_t i = 0; i < np; i++) + if ((in[i] = (uint8_t)is_inlier(m, best_r.t, a[i], b[i], hb[i], p))) idx[c++] = i; + Rigid r; + if (c >= 3 && fit_rigid((const float (*)[3])a, (const float (*)[3])b, idx, c, &r)) best_r = r; + } + q_to_m(best_r.q, m); + uint32_t c = 0; + for (uint32_t i = 0; i < np; i++) c += (in[i] = (uint8_t)is_inlier(m, best_r.t, a[i], b[i], hb[i], p)); + v->inliers = c; + memcpy(v->q, best_r.q, sizeof v->q); + memcpy(v->t, best_r.t, sizeof v->t); + const double ang = 2.0 * acos(fmin(1.0, fabs(best_r.q[0]))); + const double mz = median_z(sb, nb); + const double sh = sqrt(best_r.t[0] * best_r.t[0] + best_r.t[1] * best_r.t[1] + best_r.t[2] * best_r.t[2]); + if ((float)c < p->min_inliers * (float)np || c < p->min_pairs) v->why = "few inliers"; + else if (ang > p->max_angle) v->why = "camera turned too far"; + else if (mz > 0 && sh > p->max_shift * mz) v->why = "camera moved too far"; + else ok = 1; + } +done: + v->ok = ok; + free(idx); + /* Pairs out (for the object placement), newer sources kept. */ + if (pa_out) { + Src *pa = (Src *)malloc(((size_t)np + 1) * sizeof *pa); + if (pa && a) for (uint32_t i = 0; i < np; i++) { pa[i] = sb[bi[i]]; memcpy(pa[i].p, a[i], sizeof pa[i].p); } + *pa_out = pa; + } + if (pb_out) { + Src *pb = (Src *)malloc(((size_t)np + 1) * sizeof *pb); + if (pb && bi) for (uint32_t i = 0; i < np; i++) pb[i] = sb[bi[i]]; + *pb_out = pb; + } + *in_out = in; *np_out = np; + *nb_out = sb; *nnb_out = nb; + free(sa); free(a); free(b); free(bi); free(hb); + return ok; +} + +int fg_cam_fit(const FgPrimList *older, const FgPrimList *newer, const FgCamParams *p, + FgCamFit *fit, FgVert *verts) { + memset(fit, 0, sizeof *fit); + fit->prims = newer->n; + for (uint32_t j = 0; j < newer->n * 3u; j++) { + verts[j].mode = FG_PLACE_UNCHANGED; verts[j].view = -1; verts[j].paired = 0; + } + /* The views with projections, most first. */ + uint32_t vk[FG_MAX_VIEWS], vc[FG_MAX_VIEWS], nv = 0; for (uint32_t j = 0; j < newer->n; j++) { - const FgPrim *b = &newer->v[j]; - int32_t *h = tab_slot(&t, b->key); - /* Drop used entries at the chain head so later lookups stay short. */ - while (*h >= 0 && used[*h]) *h = next[*h]; - int32_t best = -1; - float best_d = 0.0f; - int seen = 0; - for (int32_t i = *h; i >= 0 && seen < p->window; i = next[i]) { - if (used[i]) continue; - seen++; - const FgPrim *a = &older->v[i]; - float dx[3], dy[3], d = 0.0f; - int ok = 1; - for (int k = 0; k < 3 && ok; k++) { - dx[k] = b->x[k] - a->x[k]; - dy[k] = b->y[k] - a->y[k]; - float d2 = dx[k] * dx[k] + dy[k] * dy[k]; - if (d2 > mm) ok = 0; - d += d2; - } - for (int k = 1; k < 3 && ok; k++) { - float ex = dx[k] - dx[0], ey = dy[k] - dy[0]; - if (ex * ex + ey * ey > md) ok = 0; - } - if (!ok) continue; - if (best < 0 || d < best_d) { - best = i; - best_d = d; - if (d == 0.0f) break; /* did not move: nothing closer */ - } - } - if (best >= 0) { - used[best] = 1; - match[j] = best; - s.matched++; - if (best_d > 0.0f) s.moved++; - } else { - s.unmatched++; + const FgPrim *pr = &newer->v[j]; + if (!pr->vid[0] && !pr->vid[1] && !pr->vid[2]) continue; + uint32_t i = 0; + while (i < nv && vk[i] != pr->view) i++; + if (i == nv) { + if (nv == FG_MAX_VIEWS) { fit->why = "too many views"; return 0; } + vk[nv] = pr->view; vc[nv++] = 0; } + vc[i]++; } - free(t.keys); free(t.head); free(next); free(used); - if (st) *st = s; - return s.matched; + if (!nv) { fit->why = "no projections"; return 0; } + fit->nviews = nv; + int all = 1; + for (uint32_t vi = 0; vi < nv; vi++) { + FgView *v = &fit->v[vi]; + v->view = vk[vi]; + for (uint32_t j = 0; j < newer->n; j++) + if (newer->v[j].view == v->view) { memcpy(v->area, newer->v[j].area, sizeof v->area); break; } + Src *pa = NULL, *pb = NULL, *sb = NULL; + uint8_t *in = NULL; + uint32_t np = 0, nb = 0; + const int ok = fit_view(older, newer, p, v, &pa, &pb, &in, &np, &sb, &nb); + /* A view of a few projections (a 3D icon, a stray lookup) without a + * camera stays as it is; a real view without one rejects the frame. */ + if (!ok && vc[vi] >= p->min_pairs) { all = 0; if (!fit->why) fit->why = v->why; } + if (ok && pa && pb && in) { + /* Pairs are in newer-source order (pb sorted): look vertices up. */ + for (uint32_t j = 0; j < newer->n; j++) { + const FgPrim *pr = &newer->v[j]; + if (pr->view != v->view) continue; + for (int k = 0; k < 3; k++) { + if (!pr->vid[k]) continue; + FgVert *fv = &verts[3 * j + k]; + fv->view = (int8_t)vi; + fv->mode = FG_PLACE_CAMERA; + Src key; key.id = pr->vid[k]; memcpy(key.p, pr->p[k], sizeof key.p); + uint32_t lo = 0, hi = np; + while (lo < hi) { const uint32_t m = (lo + hi) / 2; if (src_cmp(&pb[m], &key) < 0) lo = m + 1; else hi = m; } + fv->paired = lo < np && src_cmp(&pb[lo], &key) == 0; + if (fv->paired && !in[lo] && + dist3(pa[lo].p, pr->p[k]) < p->max_obj * fabsf(pr->p[k][2]) + 1.0f) { + fv->mode = FG_PLACE_OBJECT; + memcpy(fv->a, pa[lo].p, sizeof fv->a); + } + } + } + } + free(pa); free(pb); free(in); free(sb); + } + fit->ok = all; + if (!all) { + for (uint32_t j = 0; j < newer->n * 3u; j++) verts[j].mode = FG_PLACE_UNCHANGED; + return 0; + } + /* An unpaired vertex of a moving object (a camera-attached sky dome + * whose vertex was culled last frame) moves with the object, not with + * the world. */ + for (uint32_t j = 0; j < newer->n; j++) { + int obj = 0; + for (int k = 0; k < 3; k++) obj |= verts[3 * j + k].mode == FG_PLACE_OBJECT; + if (!obj) continue; + for (int k = 0; k < 3; k++) + if (verts[3 * j + k].mode == FG_PLACE_CAMERA && !verts[3 * j + k].paired) + verts[3 * j + k].mode = FG_PLACE_NEIGHBOUR; + } + /* Vertices without a projection in triangles with placed ones. */ + for (uint32_t j = 0; j < newer->n; j++) { + int placed = 0; + for (int k = 0; k < 3; k++) placed += verts[3 * j + k].mode != FG_PLACE_UNCHANGED; + if (!placed || placed == 3) continue; + int8_t vw = -1; + for (int k = 0; k < 3; k++) if (verts[3 * j + k].view >= 0) vw = verts[3 * j + k].view; + for (int k = 0; k < 3; k++) + if (verts[3 * j + k].mode == FG_PLACE_UNCHANGED && !newer->v[j].vid[k]) { + verts[3 * j + k].mode = FG_PLACE_NEIGHBOUR; + verts[3 * j + k].view = vw; + } + } + for (uint32_t j = 0; j < newer->n * 3u; j++) + switch (verts[j].mode) { + case FG_PLACE_CAMERA: fit->camera++; break; + case FG_PLACE_OBJECT: fit->object++; break; + case FG_PLACE_NEIGHBOUR: fit->neighbour++; break; + default: fit->unchanged++; break; + } + return 1; } -void fg_lerp(const FgPrim *a, const FgPrim *b, double t, float x[3], float y[3]) { - for (int k = 0; k < 3; k++) { - if (t >= 1.0) { x[k] = b->x[k]; y[k] = b->y[k]; continue; } - if (t <= 0.0) { x[k] = a->x[k]; y[k] = a->y[k]; continue; } - x[k] = (float)((double)a->x[k] + ((double)b->x[k] - (double)a->x[k]) * t); - y[k] = (float)((double)a->y[k] + ((double)b->y[k] - (double)a->y[k]) * t); +/* Projection of a camera-space point (0 when behind the near plane). */ +static int proj(const double v[3], double h, double *sx, double *sy) { + if (v[2] < 1.0) return 0; + *sx = h * v[0] / v[2]; + *sy = h * v[1] / v[2]; + return 1; +} + +typedef struct { int32_t qx, qy; float dx, dy; uint32_t n; } NAcc; + +void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *verts, + double t, float *x, float *y, float margin[][4]) { + const uint32_t n = newer->n; + if (margin) memset(margin, 0, sizeof(float) * 4 * FG_MAX_VIEWS); + for (uint32_t j = 0; j < n; j++) + for (int k = 0; k < 3; k++) { x[3 * j + k] = newer->v[j].x[k]; y[3 * j + k] = newer->v[j].y[k]; } + if (!fit->ok || t >= 1.0) return; + if (t < 0.0) t = 0.0; + /* Per view: P_t = R_t (R^T (P - T)) + t T, R_t = the rotation's fraction t. */ + double M[FG_MAX_VIEWS][9], Rt[FG_MAX_VIEWS][9], T[FG_MAX_VIEWS][3], Tt[FG_MAX_VIEWS][3]; + for (uint32_t vi = 0; vi < fit->nviews; vi++) { + const FgView *v = &fit->v[vi]; + q_to_m(v->q, M[vi]); + const double w = fmin(1.0, fabs(v->q[0])); + const double ang = 2.0 * acos(w), s = sqrt(fmax(0.0, 1.0 - w * w)); + double qt[4] = { 1, 0, 0, 0 }; + if (s > 1e-9) { + const double sg = v->q[0] < 0 ? -1.0 : 1.0; + qt[0] = cos(0.5 * ang * t); + for (int k = 0; k < 3; k++) qt[k + 1] = sg * v->q[k + 1] / s * sin(0.5 * ang * t); + } + q_to_m(qt, Rt[vi]); + for (int k = 0; k < 3; k++) { T[vi][k] = v->t[k]; Tt[vi][k] = v->t[k] * t; } } + float *dxs = (float *)malloc((size_t)n * 3 * sizeof *dxs); + float *dys = (float *)malloc((size_t)n * 3 * sizeof *dys); + if (!dxs || !dys) { free(dxs); free(dys); return; } + for (uint32_t j = 0; j < n; j++) { + const FgPrim *pr = &newer->v[j]; + for (int k = 0; k < 3; k++) { + const uint32_t i = 3 * j + k; + dxs[i] = dys[i] = 0.0f; + const FgVert *fv = &verts[i]; + if (fv->mode != FG_PLACE_CAMERA && fv->mode != FG_PLACE_OBJECT) continue; + const double pn[3] = { pr->p[k][0], pr->p[k][1], pr->p[k][2] }; + double pt[3]; + if (fv->mode == FG_PLACE_OBJECT) { + for (int c = 0; c < 3; c++) pt[c] = fv->a[c] + (pn[c] - fv->a[c]) * t; + } else { + const int vi = fv->view; + double d[3], o[3]; + for (int c = 0; c < 3; c++) d[c] = pn[c] - T[vi][c]; + /* R^T d */ + for (int c = 0; c < 3; c++) o[c] = M[vi][c] * d[0] + M[vi][3 + c] * d[1] + M[vi][6 + c] * d[2]; + m_mul_v(Rt[vi], o, pt); + for (int c = 0; c < 3; c++) pt[c] += Tt[vi][c]; + } + double ax, ay, bx, by; + if (!proj(pn, pr->h[k], &bx, &by)) continue; + if (!proj(pt, pr->h[k], &ax, &ay)) { dxs[i] = dys[i] = NAN; continue; } /* behind the camera at t */ + dxs[i] = (float)(ax - bx); dys[i] = (float)(ay - by); + } + } + /* Neighbours: the mean motion of the placed vertices of the triangles + * that share the position (1/16 px). */ + uint32_t nn = 0; + for (uint32_t i = 0; i < n * 3u; i++) nn += verts[i].mode == FG_PLACE_NEIGHBOUR; + if (nn) { + uint32_t cap = 64; + while (cap < nn * 2u) cap <<= 1; + NAcc *tab = (NAcc *)calloc(cap, sizeof *tab); + if (tab) { + for (uint32_t j = 0; j < n; j++) { + float sx = 0, sy = 0; int c = 0; + for (int k = 0; k < 3; k++) { + const uint8_t m = verts[3 * j + k].mode; + if ((m == FG_PLACE_CAMERA || m == FG_PLACE_OBJECT) && !isnan(dxs[3 * j + k])) { + sx += dxs[3 * j + k]; sy += dys[3 * j + k]; c++; + } + } + if (!c) continue; + for (int k = 0; k < 3; k++) { + if (verts[3 * j + k].mode != FG_PLACE_NEIGHBOUR) continue; + const int32_t qx = (int32_t)lrintf(newer->v[j].x[k] * 16.0f), qy = (int32_t)lrintf(newer->v[j].y[k] * 16.0f); + uint32_t h = ((uint32_t)qx * 73856093u ^ (uint32_t)qy * 19349663u) & (cap - 1); + while (tab[h].n && (tab[h].qx != qx || tab[h].qy != qy)) h = (h + 1) & (cap - 1); + tab[h].qx = qx; tab[h].qy = qy; + tab[h].dx += sx / c; tab[h].dy += sy / c; tab[h].n++; + } + } + for (uint32_t j = 0; j < n; j++) + for (int k = 0; k < 3; k++) { + const uint32_t i = 3 * j + k; + if (verts[i].mode != FG_PLACE_NEIGHBOUR) continue; + const int32_t qx = (int32_t)lrintf(newer->v[j].x[k] * 16.0f), qy = (int32_t)lrintf(newer->v[j].y[k] * 16.0f); + uint32_t h = ((uint32_t)qx * 73856093u ^ (uint32_t)qy * 19349663u) & (cap - 1); + while (tab[h].n && (tab[h].qx != qx || tab[h].qy != qy)) h = (h + 1) & (cap - 1); + if (tab[h].n) { dxs[i] = tab[h].dx / tab[h].n; dys[i] = tab[h].dy / tab[h].n; } + } + free(tab); + } + } + /* Motion to 1/8 px: the fit's rounding error must not move an edge off + * a pixel boundary (a whole-pixel move stays whole). */ + for (uint32_t i = 0; i < n * 3u; i++) { + x[i] += roundf(dxs[i] * 8.0f) / 8.0f; + y[i] += roundf(dys[i] * 8.0f) / 8.0f; + } + /* Margins: vertices on or past a view edge that moved inward. */ + if (margin) + for (uint32_t j = 0; j < n; j++) + for (int k = 0; k < 3; k++) { + const uint32_t i = 3 * j + k; + const int vi = verts[i].view; + if (vi < 0 || verts[i].mode == FG_PLACE_UNCHANGED || isnan(dxs[i])) continue; + const float *ar = fit->v[vi].area, bx = newer->v[j].x[k], by = newer->v[j].y[k]; + /* Geometry that reached past an edge now ends this far inside it. */ + float m; + if (bx <= ar[0] + 1.0f && (m = bx + dxs[i] - ar[0]) > margin[vi][0]) margin[vi][0] = m; + if (by <= ar[1] + 1.0f && (m = by + dys[i] - ar[1]) > margin[vi][1]) margin[vi][1] = m; + if (bx >= ar[2] - 1.0f && (m = ar[2] - (bx + dxs[i])) > margin[vi][2]) margin[vi][2] = m; + if (by >= ar[3] - 1.0f && (m = ar[3] - (by + dys[i])) > margin[vi][3]) margin[vi][3] = m; + } + free(dxs); free(dys); } int fg_plan(double flip_s, double refresh_hz, double real_cost_s, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 523599d4..38f2c0cf 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -4470,6 +4470,18 @@ static void prepare_precise_triangle(int i0, int i1, int i2, ws_native_wide_active() && psx_gpu_triangle_oversize(raw_x,raw_y,0,1,2)); } + if (gr_backend() == GR_BACKEND_OPENGL && + gl_renderer_frame_generation() != gte_fg_source_enabled()) + gte_fg_source_set(gl_renderer_frame_generation()); /* from the next projection */ + if (gr_backend() == GR_BACKEND_OPENGL && gte_fg_source_enabled()) { + /* Frame generation re-projects vertices from their camera-space source. */ + uint32_t id[3] = { 0, 0, 0 }; + int32_t pc[9] = { 0 }, hd[3] = { 0, 0, 0 }; + const int ix[3] = { i0, i1, i2 }; + for (int k = 0; k < 3; k++) + if (!gte_fg_source_lookup(gp0_cmd_buf[ix[k]], &id[k], &pc[3 * k], &hd[k])) id[k] = 0; + gl_renderer_fg_source(id, pc, hd, vx, vy); + } gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); const int geometry = gte_geometry_correction_enabled(); if (!geometry && !s_native_wide_projection_correction) { diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 8420e5b3..8ce3c938 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -111,6 +111,7 @@ #include #include #include "png_write.h" /* png_write_rgb — present_shot readback */ +static uint64_t s_fg_flips; /* frame generation (below): game frames seen */ #ifndef GL_BGRA #define GL_BGRA 0x80E1 @@ -446,7 +447,7 @@ enum { RTH_WIDE_DISABLE, RTH_WIDE_CLEAR, RTH_WIDE_CLEAR_MARGINS, RTH_PROJ_TRI, RTH_WIDE_RECOVERY, RTH_INTERP_SUSPENDED, RTH_PRESENT_VRAM, RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK, RTH_RING_CAPTURE, RTH_DYN_STEP, - RTH_FRAME + RTH_FRAME, RTH_FG_SRC }; static int rth_record_mode(void); /* Render-thread frame cost (dynamic resolution; defined with GL_RT_BACKEND). */ @@ -8507,7 +8508,70 @@ static void gl_swap_with_osd(void) { const char *e = getenv("PSX_PRESENT_SHOT_GENERATED"); gen_only = (e && *e && *e != '0') ? 1 : 0; } - if ((!gen_only || s_fg_presenting) && + /* PSX_PRESENT_SHOT_BURST=N (diagnostic): a staged present_shot takes + * the next N composed frames, kept in memory and written when the + * burst ends as _NN_r.png (real) / _NN_g.png (generated), in + * composition order: with frame generation a real frame is composed + * before the in-between frames that lead up to it. */ + static int burst = -1, b_i = 0, b_w = 0, b_h = 0; + static uint8_t **b_px = NULL; static char *b_kind = NULL; static uint64_t b_flip[240]; + static char b_path[512]; + if (burst < 0) { + const char *e = getenv("PSX_PRESENT_SHOT_BURST"); + burst = e ? atoi(e) : 0; + if (burst < 0) burst = 0; + if (burst > 240) burst = 240; + if (burst) { + b_px = (uint8_t **)calloc((size_t)burst, sizeof *b_px); + b_kind = (char *)calloc((size_t)burst, 1); + if (!b_px || !b_kind) burst = 0; + } + } + if (burst && b_i > 0) { + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww == b_w && wh == b_h && (b_px[b_i] = (uint8_t *)malloc((size_t)ww * wh * 3))) { + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, ww, wh, GL_RGB, GL_UNSIGNED_BYTE, b_px[b_i]); + b_kind[b_i] = s_fg_presenting ? 'g' : 'r'; + b_flip[b_i] = s_fg_flips; + b_i++; + } else { + b_i = burst; /* resized: end it */ + } + if (b_i >= burst) { + int ok = 1; + uint8_t *flip = (uint8_t *)malloc((size_t)b_w * b_h * 3); + for (int i = 0; i < burst; i++) { + if (!b_px[i] || !flip) { ok = 0; continue; } + for (int y = 0; y < b_h; y++) + memcpy(flip + (size_t)y * b_w * 3, b_px[i] + (size_t)(b_h - 1 - y) * b_w * 3, + (size_t)b_w * 3); + char fn[600]; + snprintf(fn, sizeof fn, "%s_%02d_%c_f%llu.png", b_path, i, b_kind[i], + (unsigned long long)b_flip[i]); + FILE *pf = fopen(fn, "wb"); + if (!pf || !png_write_rgb(pf, flip, (uint32_t)b_w, (uint32_t)b_h)) ok = 0; + if (pf) fclose(pf); + free(b_px[i]); b_px[i] = NULL; + } + free(flip); + b_i = 0; + present_shot_done(ok); + } + } else if (burst && present_shot_take(shot_path, (int)sizeof(shot_path))) { + SDL_GL_GetDrawableSize(s_win, &b_w, &b_h); + snprintf(b_path, sizeof b_path, "%s", shot_path); + if (b_w > 0 && b_h > 0 && (b_px[0] = (uint8_t *)malloc((size_t)b_w * b_h * 3))) { + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, b_w, b_h, GL_RGB, GL_UNSIGNED_BYTE, b_px[0]); + b_kind[0] = s_fg_presenting ? 'g' : 'r'; + b_flip[0] = s_fg_flips; + b_i = 1; + } else { + present_shot_done(0); + } + } else if (!burst && (!gen_only || s_fg_presenting) && present_shot_take(shot_path, (int)sizeof(shot_path))) { int ww = 0, wh = 0; int wrote = 0; @@ -10015,7 +10079,7 @@ typedef struct { int wide_w, wide_off, scale, linear, force43; } FgList; /* Raw triangle captured while recording a list (absolute coords). */ -typedef struct { uint32_t rec, key0; float x[3], y[3]; int area[4]; } FgRaw; +typedef struct { uint32_t rec, key0, vid[3]; float x[3], y[3], p[3][3], h[3]; int area[4]; } FgRaw; static int s_fg_on = 0; /* configured (any thread reads) */ static int s_fg_force = 0; /* PSX_FRAME_GEN_FORCE: tests */ @@ -10035,8 +10099,24 @@ static int s_fg_have_last = 0, s_fg_last_dx = 0, s_fg_last_dy = 0; * start the next frame, so a flip is not a list boundary). */ static int s_fg_buf[4][4], s_fg_nbuf = 0, s_fg_cur_buf = -1; static int s_fg_vblanks = 0, s_fg_flip_vb = 1; /* presents per flip */ -static int32_t *s_fg_match = NULL; static uint32_t s_fg_match_cap = 0; -static FgMatchStats s_fg_mst; +/* The camera fit of the pair a schedule draws from and each newer vertex's + * placement (fg_cam_fit), positions at the current phase, and the pending + * vertex sources of the next triangle (RTH_FG_SRC: ids, integer x/y, + * camera-space x/y/z, H). */ +static FgVert *s_fg_verts = NULL; static uint32_t s_fg_verts_cap = 0; +static float *s_fg_px = NULL, *s_fg_py = NULL; static uint32_t s_fg_pos_cap = 0; +static FgCamFit s_fg_fit; +static float s_fg_margin[FG_MAX_VIEWS][4]; /* fg_cam_place, per generated frame */ +static int s_fg_src_older = 0; /* the older frame is redrawn (moving forward) */ +static uint32_t fg_main_view(const FgCamFit *f) { + uint32_t m = 0; + for (uint32_t i = 1; i < f->nviews; i++) if (f->v[i].sources > f->v[m].sources) m = i; + return m; +} +static int s_fg_src_valid = 0; static int32_t s_fg_src[21]; +static uint32_t s_fg_src_seen = 0; +static uint64_t s_fg_rejected = 0; /* flips the verdict refused */ +static const char *s_fg_reject_why = NULL; static FgBreaker s_fg_brk; static int s_fg_brk_init = 0; static uint64_t s_fg_bp_seen = 0, s_fg_bp_ns_seen = 0; @@ -10283,6 +10363,12 @@ static void fg_rotate(void) { /* Called for every replayed record (gl_rth_exec), before it executes. */ static void fg_capture(const RtCmd *c, const void *payload) { + if (c->op == RTH_FG_SRC) { + memcpy(s_fg_src, payload, sizeof s_fg_src); + s_fg_src_valid = 1; + s_fg_src_seen++; + return; + } if (!fg_op_kept(c->op)) return; /* Which buffer a draw goes to: a fill's rect, else the draw area. */ int tb = -1; @@ -10321,6 +10407,20 @@ static void fg_capture(const RtCmd *c, const void *payload) { FgRaw raw; if (fg_tri_geom(c->op, payload, s_fg_pc_valid ? s_fg_pc : NULL, raw.x, raw.y, &raw.key0)) { raw.rec = (uint32_t)r; + /* The identities belong to this triangle only if it is drawn where + * they were looked up (a culled triangle leaves its record behind). */ + for (int k = 0; k < 3; k++) raw.vid[k] = 0; + if (s_fg_src_valid) { + int at = 1; + for (int k = 0; k < 3; k++) + if (fabsf(raw.x[k] - (float)s_fg_src[3 + 2 * k]) > 1.5f || + fabsf(raw.y[k] - (float)s_fg_src[4 + 2 * k]) > 1.5f) at = 0; + if (at) for (int k = 0; k < 3; k++) { + raw.vid[k] = (uint32_t)s_fg_src[k]; + for (int c = 0; c < 3; c++) raw.p[k][c] = (float)s_fg_src[9 + 3 * k + c]; + raw.h[k] = (float)s_fg_src[18 + k]; + } + } raw.area[0] = s_area_x1; raw.area[1] = s_area_y1; raw.area[2] = s_area_x2; raw.area[3] = s_area_y2; (void)fg_raw_add(&raw); @@ -10329,6 +10429,7 @@ static void fg_capture(const RtCmd *c, const void *payload) { case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: case RTH_SHADED_TEX_TRI: case RTH_PROJ_TRI: s_fg_pc_valid = 0; + s_fg_src_valid = 0; break; default: break; } @@ -10359,6 +10460,13 @@ static void fg_list_close(FgList *l, const int disp[4]) { const int32_t aw[4] = { r->area[0] - disp[0], r->area[1] - disp[1], r->area[2] - disp[0], r->area[3] - disp[1] }; p.key = fg_hash(r->key0, aw, 4); + p.view = fg_hash(FG_HASH_INIT, aw, 4); + for (int k = 0; k < 4; k++) p.area[k] = (float)aw[k]; + for (int k = 0; k < 3; k++) { + p.vid[k] = r->vid[k]; + memcpy(p.p[k], r->p[k], sizeof p.p[k]); + p.h[k] = r->h[k]; + } for (int k = 0; k < 3; k++) { p.x[k] = r->x[k] - (float)disp[0]; p.y[k] = r->y[k] - (float)disp[1]; } if (!fg_prims_add(&l->prims, &p)) { l->valid = 0; return; } l->rec2prim[r->rec] = (int32_t)(l->prims.n - 1); @@ -10371,6 +10479,7 @@ static void fg_list_open(void) { fg_state_capture(&l->start); s_fg_nraw = 0; s_fg_pc_valid = 0; + s_fg_src_valid = 0; } static void fg_invalidate(void) { @@ -10441,6 +10550,14 @@ static void fg_surfaces_free(void) { * (most of a generated frame at high levels). The stencil is the mask-bit * mirror of alpha; the caller marks it stale and it is rebuilt from the * copied alpha only if a generated draw checks the mask. */ +static void fg_blit2(GLuint src, GLuint dst, int sx, int sy, int dx, int dy, int w, int h) { + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, src); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, dst); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer(sx, sy, sx + w, sy + h, dx, dy, dx + w, dy + h, GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); +} static void fg_blit(GLuint src, GLuint dst, int x, int y, int w, int h) { p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, src); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, dst); @@ -10507,10 +10624,28 @@ static int fg_rect_meets(int x, int y, int w, int h, const int *disp) { /* Redraw list b at phase t (0 = a's positions, 1 = b's) into the surfaces * currently bound as hr / the displayed wide surface. */ static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wide) { + (void)a; /* in-between frames redraw the newer frame only */ const int *disp = b->disp; + /* Every vertex's position at this phase (the newer frame from an + * in-between camera, frame_gen.h). */ + int placed = 0; + memset(s_fg_margin, 0, sizeof s_fg_margin); + if (s_fg_verts && s_fg_verts_cap >= b->prims.n) { + if (s_fg_pos_cap < b->prims.n) { + float *nx = (float *)realloc(s_fg_px, (size_t)b->prims.n * 3 * sizeof *nx); + if (nx) s_fg_px = nx; + float *ny = (float *)realloc(s_fg_py, (size_t)b->prims.n * 3 * sizeof *ny); + if (ny) s_fg_py = ny; + if (nx && ny) s_fg_pos_cap = b->prims.n; + } + if (s_fg_pos_cap >= b->prims.n) { + fg_cam_place(&b->prims, &s_fg_fit, s_fg_verts, t, s_fg_px, s_fg_py, s_fg_margin); + placed = 1; + } + } const float ddx = (float)(b->disp[0]), ddy = (float)(b->disp[1]); fg_state_apply(&b->start, (b->start.wide_on && b->start.wide_base == disp[0]) ? gen_wide : 0); - int pc_pending = 0; int32_t pc[7]; + int pc_pending = 0, drawn = 0; int32_t pc[7]; for (uint32_t r = 0; r < b->n; r++) { FgRec h; memcpy(&h, b->buf + b->off[r], sizeof h); @@ -10551,24 +10686,34 @@ static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wid glb_wide_clear_margins(v[0], v[1], v[2], (uint16_t)v[3], v[4]); break; case RTH_FILL: - if (fg_rect_meets(v[0], v[1], v[2], v[3], disp)) + /* The clears before anything is drawn are not redrawn: the + * surface starts as the newer real frame, so what the in-between + * camera uncovers (the road below the receding near edge) shows + * that frame's pixels instead of the clear colour. Fills after + * drawing (panels, letterbox bands) are part of the picture. */ + if (drawn && fg_rect_meets(v[0], v[1], v[2], v[3], disp)) glb_fill_rect(v[0], v[1], v[2], v[3], (uint16_t)v[4]); break; case RTH_FLAT_RECT: + drawn |= in; if (in) glb_draw_flat_rect(v[0], v[1], v[2], v[3], (uint16_t)v[4]); break; case RTH_TEX_RECT: + drawn |= in; if (in) glb_draw_textured_rect(v[0], v[1], v[2], v[3], v[4], v[5], (uint16_t)v[6], (uint16_t)v[7], (uint16_t)v[8]); break; case RTH_TEX_RECT_SCALED: + drawn |= in; if (in) glb_draw_textured_rect_scaled(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], (uint16_t)v[8], (uint16_t)v[9], (uint16_t)v[10]); break; case RTH_LINE: + drawn |= in; if (in) glb_draw_line(v[0], v[1], v[2], v[3], (uint16_t)v[4]); break; case RTH_SHADED_LINE: + drawn |= in; if (in) glb_draw_shaded_line(v[0], v[1], (uint16_t)v[2], v[3], v[4], (uint16_t)v[5]); break; case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: @@ -10578,15 +10723,23 @@ static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wid pc_pending = 0; break; } - const int32_t ai = s_fg_match ? s_fg_match[pi] : -1; const FgPrim *pb = &b->prims.v[pi]; float x[3], y[3]; - if (ai >= 0 && t < 1.0) { - fg_lerp(&a->prims.v[ai], pb, t, x, y); + if (placed) { + int gone = 0; + for (int k = 0; k < 3; k++) { + x[k] = s_fg_px[3 * pi + k]; y[k] = s_fg_py[3 * pi + k]; + gone |= isnan(x[k]); + } + if (gone) { /* crossed the camera plane: not in this frame */ + pc_pending = 0; + break; + } } else { for (int k = 0; k < 3; k++) { x[k] = pb->x[k]; y[k] = pb->y[k]; } } for (int k = 0; k < 3; k++) { x[k] += ddx; y[k] += ddy; } + drawn = 1; fg_draw_tri_at(h.op, v, pl, x, y); precise_consumed(); pc_pending = 0; @@ -10670,8 +10823,16 @@ static int fg_generate(double t, int swap) { /* The displayed buffer as the real frame has it, then swap the surfaces. */ const int dy = b->disp[1] < 0 ? 0 : b->disp[1]; const int dh = b->disp[1] + b->disp[3] > VRAM_H ? VRAM_H - dy : b->disp[1] + b->disp[3] - dy; - fg_blit(s_hr_fbo, s_fg_hr_fbo, b->disp[0] * S, dy * S, b->disp[2] * S, dh * S); - if (iw >= 0) fg_blit(s_wide_fbo[iw], s_fg_w_fbo, 0, dy * S, g_wide_w * S, dh * S); + /* The redrawn frame (the older one when moving forward) is drawn where + * its own buffer is, over the newer real frame's image: what the + * in-between camera uncovers shows that. */ + const FgList *L = s_fg_src_older ? a : b; + const double tl = s_fg_src_older ? 1.0 - t : t; + const int ly = L->disp[1] < 0 ? 0 : L->disp[1]; + const int lh = L->disp[1] + L->disp[3] > VRAM_H ? VRAM_H - ly : L->disp[1] + L->disp[3] - ly; + if (L->disp[0] != b->disp[0] || lh != dh || L->disp[2] != b->disp[2]) return 0; + fg_blit2(s_hr_fbo, s_fg_hr_fbo, b->disp[0] * S, dy * S, L->disp[0] * S, ly * S, b->disp[2] * S, dh * S); + if (iw >= 0) fg_blit2(s_wide_fbo[iw], s_fg_w_fbo, 0, dy * S, 0, ly * S, g_wide_w * S, dh * S); GLuint t0 = s_hr_tex, f0 = s_hr_fbo, r0 = s_hr_rb; s_hr_tex = s_fg_hr_tex; s_hr_fbo = s_fg_hr_fbo; s_hr_rb = s_fg_hr_rb; GLuint wt = 0, wf = 0, wr = 0; int was = 0; @@ -10682,11 +10843,43 @@ static int fg_generate(double t, int swap) { } /* The copied stencil is stale: rebuilt from alpha if a draw needs it. */ s_stencil_valid = 0; - rect_add(&s_stencil_stale, b->disp[0], dy, b->disp[0] + b->disp[2] - 1, dy + dh - 1); - if (iw >= 0) wst_add(iw, 0, dy, g_wide_w, dy + dh); + rect_add(&s_stencil_stale, L->disp[0], ly, L->disp[0] + L->disp[2] - 1, ly + lh - 1); + if (iw >= 0) wst_add(iw, 0, ly, g_wide_w, ly + lh); s_fg_drawing = 1; - fg_replay(a, b, t, iw >= 0 ? s_fg_w_fbo : 0); - fg_compose(b, iw >= 0 ? s_fg_w_fbo : 0, iw >= 0 ? s_fg_w_tex : 0, b->linear, b->force43); + fg_replay(NULL, L, tl, iw >= 0 ? s_fg_w_fbo : 0); + flush_line_batch(); + flush_flat_batch(); + flush_tex_batch(); + /* The strips along a view's edges the picture moved away from: the + * newer frame drew nothing beyond the edge, so they show it. On the + * native-wide surface native x maps to x + wide_off. */ + { + const int ox = iw >= 0 ? L->wide_off : 0, sw = iw >= 0 ? g_wide_w : L->disp[2]; + const GLuint src = iw >= 0 ? wf : f0, dst = iw >= 0 ? s_fg_w_fbo : s_fg_hr_fbo; + const int bx0 = iw >= 0 ? 0 : L->disp[0], sdy = dy - ly; /* source rows: the newer frame's */ + for (uint32_t vi = 0; vi < s_fg_fit.nviews; vi++) { + const float *ar = s_fg_fit.v[vi].area, *m = s_fg_margin[vi]; + int x1 = (int)floorf(ar[0]) + ox, x2 = (int)ceilf(ar[2]) + 1 + ox; + int y1 = L->disp[1] + (int)ar[1], y2 = L->disp[1] + (int)ar[3] + 1; + if (x1 < 0) x1 = 0; + if (x2 > sw) x2 = sw; + if (y1 < ly) y1 = ly; + if (y2 > ly + lh) y2 = ly + lh; + if (x2 <= x1 || y2 <= y1) continue; + const int w = x2 - x1, h = y2 - y1; + int ml = m[0] > 0 ? (int)ceilf(m[0]) + 1 : 0, mt = m[1] > 0 ? (int)ceilf(m[1]) + 1 : 0; + int mr = m[2] > 0 ? (int)ceilf(m[2]) + 1 : 0, mb = m[3] > 0 ? (int)ceilf(m[3]) + 1 : 0; + if (ml > w) ml = w; + if (mr > w) mr = w; + if (mt > h) mt = h; + if (mb > h) mb = h; + if (ml) fg_blit2(src, dst, (bx0 + x1) * S, (y1 + sdy) * S, (bx0 + x1) * S, y1 * S, ml * S, h * S); + if (mr) fg_blit2(src, dst, (bx0 + x2 - mr) * S, (y1 + sdy) * S, (bx0 + x2 - mr) * S, y1 * S, mr * S, h * S); + if (mt) fg_blit2(src, dst, (bx0 + x1) * S, (y1 + sdy) * S, (bx0 + x1) * S, y1 * S, w * S, mt * S); + if (mb) fg_blit2(src, dst, (bx0 + x1) * S, (y2 - mb + sdy) * S, (bx0 + x1) * S, (y2 - mb) * S, w * S, mb * S); + } + } + fg_compose(L, iw >= 0 ? s_fg_w_fbo : 0, iw >= 0 ? s_fg_w_tex : 0, b->linear, b->force43); s_fg_drawing = 0; if (swap) { s_fg_presenting = 1; @@ -10997,15 +11190,45 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal if (n <= 0) return 0; /* Match the two frames' triangles. */ const uint64_t m0 = rt_now_ns(); - if (s_fg_match_cap < B->prims.n) { - int32_t *nm = (int32_t *)realloc(s_fg_match, (size_t)B->prims.n * sizeof *nm); - if (!nm) return 0; - s_fg_match = nm; s_fg_match_cap = B->prims.n; + /* Which frame is redrawn: the one the in-between camera sees more of. + * Moving forward, the older frame seen from further on spreads past + * the screen edges (nothing missing); the newer frame seen from further + * back would shrink away from them. Otherwise the newer frame. */ + const uint32_t nmax = A->prims.n > B->prims.n ? A->prims.n : B->prims.n; + if (s_fg_verts_cap < nmax) { + FgVert *nv = (FgVert *)realloc(s_fg_verts, (size_t)nmax * 3 * sizeof *nv); + if (!nv) return 0; + s_fg_verts = nv; s_fg_verts_cap = nmax; } - FgMatchParams mp; - fg_match_defaults(&mp); - fg_match(&A->prims, &B->prims, &mp, s_fg_match, &s_fg_mst); + FgCamParams cp; + fg_cam_defaults(&cp); + FgList *src = A; + if (!fg_cam_fit(&B->prims, &A->prims, &cp, &s_fg_fit, s_fg_verts) || + s_fg_fit.v[fg_main_view(&s_fg_fit)].t[2] <= 0.0) { + src = B; + fg_cam_fit(&A->prims, &B->prims, &cp, &s_fg_fit, s_fg_verts); + } + s_fg_src_older = src == A; + /* A full-width view on the native-wide surface reaches its edges, not + * the native draw area's. */ + if (src->wide) + for (uint32_t vi = 0; vi < s_fg_fit.nviews; vi++) { + float *ar = s_fg_fit.v[vi].area; + if (ar[0] <= 0.0f && ar[2] >= (float)(src->disp[2] - 1)) { + ar[0] -= (float)src->wide_off; + ar[2] += (float)src->wide_off; + } + } s_fg_last_match_ms = (double)(rt_now_ns() - m0) * 1e-6; + /* The verdict: a frame whose correspondence is not trustworthy is not + * generated; the real frame shows at its own time. A forced run (the + * fixture's synthetic scenes carry no GTE identities) skips it. */ + if (!s_fg_fit.ok && !s_fg_force) { + s_fg_rejected++; + s_fg_reject_why = s_fg_fit.why; + s_fg_last_n = 0; + return 0; + } /* The real frame waits for the generated ones before it. */ if (s_fg_real_cap < bytes) { uint8_t *nb = (uint8_t *)realloc(s_fg_real_p, bytes); @@ -11045,6 +11268,14 @@ void gl_renderer_set_frame_generation(int on) { } int gl_renderer_frame_generation(void) { return s_fg_on; } +void gl_renderer_fg_source(const uint32_t id[3], const int32_t pc[9], const int32_t h[3], + const int32_t x[3], const int32_t y[3]) { + if (!s_fg_on || !rth_record_mode()) return; + RTH_REC(RTH_FG_SRC, 0, (int32_t)id[0], (int32_t)id[1], (int32_t)id[2], + x[0], y[0], x[1], y[1], x[2], y[2], + pc[0], pc[1], pc[2], pc[3], pc[4], pc[5], pc[6], pc[7], pc[8], h[0], h[1], h[2]); +} + void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz) { atomic_store(&s_fg_refresh_hz, refresh_hz); if (guest_hz > 1.0) atomic_store(&s_fg_guest_hz, guest_hz); @@ -11081,12 +11312,15 @@ int gl_renderer_frame_gen_json(char *out, int cap) { "\"last_n\":%d,\"slots\":%d,\"flip_vblanks\":%d,\"refresh_hz\":%.1f," "\"real_ms\":%.3f,\"gen_ms\":%.3f,\"gen_cpu_ms\":%.3f,\"gen_gpu_ms\":%.3f,\"swap_ms\":%.3f," "\"match_ms\":%.3f," - "\"prims\":%u,\"matched\":%u,\"moved\":%u,\"unmatched\":%u," + "\"prims\":%u,\"views\":%u,\"pairs\":%u,\"inliers\":%u,\"src_triangles\":%u," "\"breaker_s\":%.2f,\"trips\":%u,\"last_trip\":\"%s\",\"held_s\":%.2f," "\"last_hold\":\"%s\",\"swaps\":%llu,\"gen_samples\":%u,\"gen_cold\":%u," "\"gen_probes\":%u,\"ignored_late\":%llu,\"ignored_bp\":%llu," "\"real_cpu_ms\":%.3f,\"real_gpu_ms\":%.3f,\"ceiling\":%d," - "\"trips_late\":%u,\"trips_backed_up\":%u,\"trips_behind\":%u", + "\"trips_late\":%u,\"trips_backed_up\":%u,\"trips_behind\":%u," + "\"place_camera\":%u,\"place_object\":%u,\"place_neighbour\":%u," + "\"place_unchanged\":%u,\"cam_angle_deg\":%.3f,\"cam_shift\":%.1f," + "\"verdict_ok\":%d,\"rejected\":%llu,\"reject_why\":\"%s\"", s_fg_on, s_fg_on && s_rth_on && open, s_fg_force, (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, (unsigned long long)s_fg_flips, (unsigned long long)s_fg_dups, @@ -11095,7 +11329,7 @@ int gl_renderer_frame_gen_json(char *out, int cap) { s_fg_real_ema * 1e3, fg_cost()->ema * 1e3, s_fg_gen_cpu_ms, s_fg_gen_gpu_ms, s_fg_swap_ema * 1e3, s_fg_last_match_ms, - s_fg_mst.prims, s_fg_mst.matched, s_fg_mst.moved, s_fg_mst.unmatched, + s_fg_fit.prims, s_fg_fit.nviews, s_fg_fit.v[0].pairs, s_fg_fit.v[0].inliers, s_fg_src_seen, open ? 0.0 : s_fg_brk.until - now, s_fg_brk.trips, s_fg_brk.reason ? s_fg_brk.reason : "", (double)(atomic_load(&s_fg_hold_until) > rt_now_ns() @@ -11104,7 +11338,13 @@ int gl_renderer_frame_gen_json(char *out, int cap) { fg_cost()->samples, fg_cost()->discarded, fg_cost()->probes, (unsigned long long)s_fg_ignored_late, (unsigned long long)s_fg_ignored_bp, s_fg_real_ema * 1e3, s_fg_real_gpu_ema * 1e3, fg_ceil()->cap, - s_fg_trip_late, s_fg_trip_bp, s_fg_trip_behind); + s_fg_trip_late, s_fg_trip_bp, s_fg_trip_behind, + s_fg_fit.camera, s_fg_fit.object, s_fg_fit.neighbour, s_fg_fit.unchanged, + 2.0 * acos(fmin(1.0, fabs(s_fg_fit.v[0].q[0]))) * 57.29578, + sqrt(s_fg_fit.v[0].t[0] * s_fg_fit.v[0].t[0] + s_fg_fit.v[0].t[1] * s_fg_fit.v[0].t[1] + + s_fg_fit.v[0].t[2] * s_fg_fit.v[0].t[2]), + s_fg_fit.ok, + (unsigned long long)s_fg_rejected, s_fg_reject_why ? s_fg_reject_why : ""); } /* ---- the recording vtable ------------------------------------------------ */ diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 6b2d4634..0f8f099a 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -401,6 +401,104 @@ extern "C" int gte_geometry_correction_lookup_probe(uint32_t packed, return gte_geometry_correction_lookup_impl(packed, x16, y16, 0); } +/* Frame generation's vertex sources ([video] frame_generation, + * docs/FRAME_GENERATION.md). Every RTPS/RTPT output position is noted with + * what produced it: an identity (the game function that issued the + * projection, ra, and the model-space input vertex VX/VY/VZ - the same in + * consecutive frames for the same piece of geometry, whatever the camera did + * and in whatever order the game draws), the camera-space position RT*V+TR + * the GTE divided, and the projection distance H. The GPU side looks a packet + * vertex up by its packed screen word; frame generation estimates the camera + * motion from these positions and re-projects from an in-between camera. + * Direct-indexed by screen position like the geometry cache, pointing into a + * ring of the recent projections; a position two different vertices projected + * to in one frame is ambiguous. Entries a few frames old are not believed. + * Allocated only while frame generation is on: the guest-visible GTE results + * are untouched either way. */ +struct FgSrcEntry { uint32_t seq; uint32_t gen; }; /* gen: frame << 1 | ambiguous */ +struct FgSrcRec { uint32_t seq, id; int32_t p[3]; int32_t h; }; +#define FGSRC_RING (1u << 17) +static FgSrcEntry *s_fgsrc = nullptr; +static FgSrcRec *s_fgsrc_ring = nullptr; +static uint32_t s_fgsrc_seq = 0; +#define FGSRC_MAX_AGE 4u +extern "C" { extern uint64_t s_frame_count; } +static int32_t gte_h_scaled(const GTEState* gte); + +extern "C" void gte_fg_source_set(int enabled) { + if (enabled && !s_fgsrc) { + s_fgsrc = (FgSrcEntry *)std::calloc(GEOM_CACHE_SIZE, sizeof(FgSrcEntry)); + s_fgsrc_ring = (FgSrcRec *)std::calloc(FGSRC_RING, sizeof(FgSrcRec)); + if (!s_fgsrc || !s_fgsrc_ring) enabled = 0; + } + if (!enabled) { + std::free(s_fgsrc); s_fgsrc = nullptr; + std::free(s_fgsrc_ring); s_fgsrc_ring = nullptr; + } +} + +extern "C" int gte_fg_source_enabled(void) { return s_fgsrc != nullptr; } + +static inline uint32_t fgsrc_frame(void) { return (uint32_t)(s_frame_count + 1u) & 0x7FFFFFFFu; } + +static void fgsrc_note(const GTEState *g, int32_t packed, uint32_t ra, const int16_t v[3]) { + const int64_t slot = geom_slot((uint32_t)packed); + if (slot < 0) return; + uint32_t h = 2166136261u; + const uint32_t w[4] = { ra, (uint16_t)v[0], (uint16_t)v[1], (uint16_t)v[2] }; + for (int i = 0; i < 4; i++) + for (int k = 0; k < 4; k++) h = (h ^ ((w[i] >> (8 * k)) & 0xFFu)) * 16777619u; + if (!h) h = 1; + const uint32_t seq = ++s_fgsrc_seq ? s_fgsrc_seq : ++s_fgsrc_seq; + FgSrcRec &r = s_fgsrc_ring[seq & (FGSRC_RING - 1u)]; + r.seq = seq; + r.id = h; + for (int i = 0; i < 3; i++) { + int64_t m = (int64_t)g->TR[i] * 4096; + for (int k = 0; k < 3; k++) m += (int64_t)g->RT[i][k] * v[k]; + r.p[i] = (int32_t)(m >> 12); + } + r.h = gte_h_scaled(g); + FgSrcEntry &e = s_fgsrc[slot]; + const uint32_t f = fgsrc_frame(); + if ((e.gen >> 1) == f && e.seq && s_fgsrc_ring[e.seq & (FGSRC_RING - 1u)].seq == e.seq && + s_fgsrc_ring[e.seq & (FGSRC_RING - 1u)].id != h) { + e.gen |= 1u; /* two vertices, one pixel */ + return; + } + if ((e.gen >> 1) != f) e.gen = f << 1; + e.seq = seq; +} + +static void fgsrc_record(const GTEState *g, uint32_t cmd, uint32_t ra) { + ra &= 0x1FFFFFFFu; + if ((cmd & 0x3F) == 0x30) { + fgsrc_note(g, g->SXY[0], ra, g->V0); + fgsrc_note(g, g->SXY[1], ra, g->V1); + fgsrc_note(g, g->SXY[2], ra, g->V2); + } else { + fgsrc_note(g, g->SXY[2], ra, g->V0); + } +} + +/* The source of the vertex last projected to a packed screen word: 0 when + * none, ambiguous or stale; else 1 with its identity, camera-space position + * and projection distance. */ +extern "C" int gte_fg_source_lookup(uint32_t packed, uint32_t *id, int32_t p[3], int32_t *hdist) { + if (!s_fgsrc) return 0; + const int64_t slot = geom_slot(packed); + if (slot < 0) return 0; + const FgSrcEntry &e = s_fgsrc[slot]; + if (!e.seq || (e.gen & 1u)) return 0; + if (((fgsrc_frame() - (e.gen >> 1)) & 0x7FFFFFFFu) > FGSRC_MAX_AGE) return 0; + const FgSrcRec &r = s_fgsrc_ring[e.seq & (FGSRC_RING - 1u)]; + if (r.seq != e.seq) return 0; + *id = r.id; + p[0] = r.p[0]; p[1] = r.p[1]; p[2] = r.p[2]; + *hdist = r.h; + return 1; +} + /* Render-pass checkpoint for the gte.cpp side of precision tracking: the * position cache entries a pass writes are journaled and put back, and the * speculative bracket depth is restored (a watchdog abort can leave one open). @@ -2255,6 +2353,8 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { #endif gte_run_command(>e, cmd); pgxp_gte_op_end(func); /* IR / MAC scalars the op overwrote */ + if (s_fgsrc && !s_gte_replay_sandbox && (func == 0x01 || func == 0x30)) + fgsrc_record(>e, cmd, cpu->gpr[31]); #ifndef PSX_NO_DEBUG_TOOLS if (func == 0x01 || func == 0x30) gte_rtp_record(>e, cmd); diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c index eed76216..6767ceae 100644 --- a/runtime/tests/test_frame_gen.c +++ b/runtime/tests/test_frame_gen.c @@ -15,96 +15,182 @@ static void check(int ok, const char *what) { if (!ok) { failures++; fprintf(stderr, "FAIL: %s\n", what); } } -static FgPrim tri(uint32_t key, float x, float y, float s) { - FgPrim p; - memset(&p, 0, sizeof p); - p.key = key; - p.x[0] = x; p.y[0] = y; - p.x[1] = x + s; p.y[1] = y; - p.x[2] = x; p.y[2] = y + s; - return p; +/* ---- a synthetic 3D scene seen by a moving camera ---- + * World points on a grandstand facade (one repeated texture: every triangle + * has the same key), a camera at eye E with yaw Y (world -> camera: rotate by + * -Y about the vertical axis, after subtracting E), projection H / z. */ +#define GW 16 +#define GH 6 +#define HH 300.0 +typedef struct { double eye[3], yaw; } Cam; + +static void to_cam(const Cam *c, const double w[3], double o[3]) { + const double d[3] = { w[0] - c->eye[0], w[1] - c->eye[1], w[2] - c->eye[2] }; + const double cs = cos(c->yaw), sn = sin(c->yaw); + o[0] = cs * d[0] - sn * d[2]; + o[1] = d[1]; + o[2] = sn * d[0] + cs * d[2]; +} + +static void wpt(int r, int col, double w[3]) { + w[0] = -400.0 + col * 50.0; w[1] = -100.0 + r * 40.0; w[2] = 2000.0 + col * 30.0; +} + +static uint32_t vid_of(int r, int col) { return (uint32_t)(r * (GW + 1) + col) + 1; } + +static void vtx(FgPrim *p, int k, const Cam *c, int r, int col, uint32_t id) { + double w[3], q[3]; + wpt(r, col, w); + to_cam(c, w, q); + p->vid[k] = id; + for (int i = 0; i < 3; i++) p->p[k][i] = (float)q[i]; + p->h[k] = (float)HH; + p->x[k] = (float)(160.0 + HH * q[0] / q[2]); + p->y[k] = (float)(120.0 + HH * q[1] / q[2]); } static void add(FgPrimList *l, FgPrim p) { p.rec = l->n; check(fg_prims_add(l, &p), "add"); } -static void test_identical_and_moved(void) { +/* Rows r0..r1-1, bottom first when `reverse`. */ +static void stand(FgPrimList *l, const Cam *c, int r0, int r1, int reverse) { + for (int ri = r0; ri < r1; ri++) { + const int r = reverse ? r1 - 1 - (ri - r0) : ri; + for (int col = 0; col < GW; col++) { + FgPrim p; + memset(&p, 0, sizeof p); + p.key = 7; p.view = 1; + vtx(&p, 0, c, r, col, vid_of(r, col)); + vtx(&p, 1, c, r, col + 1, vid_of(r, col + 1)); + vtx(&p, 2, c, r + 1, col, vid_of(r + 1, col)); + add(l, p); + vtx(&p, 0, c, r, col + 1, vid_of(r, col + 1)); + vtx(&p, 1, c, r + 1, col + 1, vid_of(r + 1, col + 1)); + vtx(&p, 2, c, r + 1, col, vid_of(r + 1, col)); + add(l, p); + } + } +} + +/* Worst screen distance between where a newer vertex is placed at phase t + * and where camera c shows its world point. */ +static float place_error(const FgPrimList *b, const float *x, const float *y, const Cam *c) { + float worst = 0.0f; + for (uint32_t j = 0; j < b->n; j++) + for (int k = 0; k < 3; k++) { + const uint32_t id = b->v[j].vid[k] - 1; + double w[3], q[3]; + wpt((int)(id / (GW + 1)), (int)(id % (GW + 1)), w); + to_cam(c, w, q); + const float ex = x[3 * j + k] - (float)(160.0 + HH * q[0] / q[2]); + const float ey = y[3 * j + k] - (float)(120.0 + HH * q[1] / q[2]); + const float e = sqrtf(ex * ex + ey * ey); + if (e > worst) worst = e; + } + return worst; +} + +/* The grandstand regression: the camera pans and travels, the newer frame + * draws the rows in another order with the top row culled and a new row in. + * In-between frames are the newer frame from an in-between camera: at phase + * 0 every vertex lands where the older camera saw it, at 0.5 where the + * halfway camera does. */ +static void test_camera_reordered(void) { + const Cam co = { { 0, 0, 0 }, 0.00 }, cn = { { 30, 0, 60 }, 0.04 }, cm = { { 15, 0, 30 }, 0.02 }; FgPrimList a = { 0 }, b = { 0 }; - for (int i = 0; i < 200; i++) add(&a, tri((uint32_t)(i % 7) + 1, (float)(i % 20) * 16.0f, (float)(i / 20) * 16.0f, 12.0f)); - for (int i = 0; i < 200; i++) add(&b, tri((uint32_t)(i % 7) + 1, (float)(i % 20) * 16.0f + 3.0f, (float)(i / 20) * 16.0f - 2.0f, 12.0f)); - FgMatchParams mp; - fg_match_defaults(&mp); - int32_t *m = (int32_t *)malloc(sizeof(int32_t) * b.n); - FgMatchStats st; - uint32_t got = fg_match(&a, &b, &mp, m, &st); - check(got == 200 && st.matched == 200 && st.unmatched == 0, "a moved scene matches every triangle"); - int pairs_ok = 1; - for (uint32_t i = 0; i < b.n; i++) if (m[i] != (int32_t)i) pairs_ok = 0; - check(pairs_ok, "each triangle pairs with itself (draw order, nearest)"); - /* Same positions: zero moves. */ - got = fg_match(&a, &a, &mp, m, &st); - check(got == 200 && st.moved == 0, "an unchanged scene matches without motion"); - free(m); + stand(&a, &co, 0, GH - 1, 0); + stand(&b, &cn, 1, GH, 1); + FgCamParams cp; + fg_cam_defaults(&cp); + FgVert *v = (FgVert *)malloc(sizeof(FgVert) * b.n * 3); + float *x = (float *)malloc(sizeof(float) * b.n * 3), *y = (float *)malloc(sizeof(float) * b.n * 3); + FgCamFit fit; + check(fg_cam_fit(&a, &b, &cp, &fit, v) == 1 && fit.ok, "the reordered stand gets a camera"); + check(fit.camera == b.n * 3 && fit.v[0].inliers == fit.v[0].pairs, "every vertex placed by the camera"); + fg_cam_place(&b, &fit, v, 0.0, x, y, NULL); + check(place_error(&b, x, y, &co) < 0.1f, "phase 0: where the older camera saw them (new row too)"); + fg_cam_place(&b, &fit, v, 0.5, x, y, NULL); + check(place_error(&b, x, y, &cm) < 0.5f, "phase 0.5: where the halfway camera sees them"); + fg_cam_place(&b, &fit, v, 1.0, x, y, NULL); + check(place_error(&b, x, y, &cn) < 1e-3f, "phase 1: the newer frame exactly"); + free(v); free(x); free(y); fg_prims_free(&a); fg_prims_free(&b); } -static void test_keys_limits_and_duplicates(void) { +/* A car the camera follows (the same camera-space position in both frames) + * stays where it is; a HUD triangle (no projection) is not moved; a vertex + * without a projection in a world triangle moves with its neighbours. */ +static void test_objects_hud_neighbours(void) { + const Cam co = { { 0, 0, 0 }, 0.0 }, cn = { { 0, 0, 80 }, 0.03 }; FgPrimList a = { 0 }, b = { 0 }; - FgMatchParams mp; - fg_match_defaults(&mp); - /* 0: different key never pairs. 1: moved past max_move. 2: deformed. - * 3/4: two copies of one key; the newer ones swapped in draw order but - * each still nearest its own. */ - add(&a, tri(10, 0, 0, 10)); - add(&a, tri(11, 0, 0, 10)); - add(&a, tri(12, 0, 0, 10)); - add(&a, tri(13, 100, 100, 10)); - add(&a, tri(13, 200, 100, 10)); - add(&b, tri(99, 0, 0, 10)); - add(&b, tri(11, mp.max_move + 10.0f, 0, 10)); - FgPrim d = tri(12, 0, 0, 10); - d.x[1] += mp.max_deform + 8.0f; /* one vertex moves alone */ - add(&b, d); - add(&b, tri(13, 204, 101, 10)); - add(&b, tri(13, 103, 99, 10)); - int32_t m[5]; - FgMatchStats st; - fg_match(&a, &b, &mp, m, &st); - check(m[0] == -1, "a different key does not pair"); - check(m[1] == -1, "a move past max_move does not pair (placed, not moved)"); - check(m[2] == -1, "a triangle whose vertices moved apart does not pair"); - check(m[3] == 4 && m[4] == 3, "duplicate keys pair by distance"); - check(st.matched == 2 && st.unmatched == 3, "match stats"); + stand(&a, &co, 0, GH, 0); + stand(&b, &cn, 0, GH, 0); + FgPrim car; + memset(&car, 0, sizeof car); + car.key = 9; car.view = 1; + const float cpos[3][3] = { { -50, 60, 600 }, { 50, 60, 600 }, { 0, 20, 650 } }; + for (int k = 0; k < 3; k++) { + car.vid[k] = 900000u + (uint32_t)k; + memcpy(car.p[k], cpos[k], sizeof car.p[k]); + car.h[k] = (float)HH; + car.x[k] = (float)(160.0 + HH * cpos[k][0] / cpos[k][2]); + car.y[k] = (float)(120.0 + HH * cpos[k][1] / cpos[k][2]); + } + add(&a, car); add(&b, car); + FgPrim hud; + memset(&hud, 0, sizeof hud); + hud.key = 3; hud.view = 2; + hud.x[0] = 10; hud.y[0] = 10; hud.x[1] = 40; hud.y[1] = 10; hud.x[2] = 10; hud.y[2] = 30; + add(&b, hud); + FgPrim cpu = b.v[0]; /* a CPU-built vertex in a world triangle */ + cpu.vid[2] = 0; + add(&b, cpu); + FgCamParams cp; + fg_cam_defaults(&cp); + FgVert *v = (FgVert *)malloc(sizeof(FgVert) * b.n * 3); + float *x = (float *)malloc(sizeof(float) * b.n * 3), *y = (float *)malloc(sizeof(float) * b.n * 3); + FgCamFit fit; + check(fg_cam_fit(&a, &b, &cp, &fit, v) && fit.object == 3, "the followed car is an object"); + fg_cam_place(&b, &fit, v, 0.0, x, y, NULL); + const uint32_t ic = 3 * (b.n - 3), ih = 3 * (b.n - 2), iu = 3 * (b.n - 1); + check(fabsf(x[ic] - car.x[0]) < 1e-3f && fabsf(y[ic + 2] - car.y[2]) < 1e-3f, "the followed car stays put"); + check(x[ih] == hud.x[0] && y[ih + 2] == hud.y[2], "the HUD is not moved"); + check(v[iu + 2].mode == FG_PLACE_NEIGHBOUR && fabsf(x[iu + 2] - b.v[b.n - 1].x[2]) > 0.5f, + "a CPU-built vertex moves with its triangle"); + free(v); free(x); free(y); fg_prims_free(&a); fg_prims_free(&b); } -static void test_each_older_pairs_once(void) { +static void test_verdict(void) { + const Cam co = { { 0, 0, 0 }, 0.0 }, cn = { { 0, 0, 40 }, 0.02 }, far = { { 0, 0, 0 }, 0.9 }; + FgCamParams cp; + fg_cam_defaults(&cp); FgPrimList a = { 0 }, b = { 0 }; - add(&a, tri(5, 0, 0, 10)); - add(&b, tri(5, 0, 0, 10)); - add(&b, tri(5, 1, 0, 10)); - FgMatchParams mp; - fg_match_defaults(&mp); - int32_t m[2]; - fg_match(&a, &b, &mp, m, NULL); - check(m[0] == 0 && m[1] == -1, "an older triangle pairs at most once"); - /* Empty older list: nothing pairs. */ - FgPrimList e = { 0 }; - fg_match(&e, &b, &mp, m, NULL); - check(m[0] == -1 && m[1] == -1, "no history: nothing pairs"); + FgVert v[3 * GW * 2 * GH + 64]; + FgCamFit fit; + /* No projections (a 2D screen). */ + for (int i = 0; i < 40; i++) { + FgPrim p; memset(&p, 0, sizeof p); p.key = 1; p.x[1] = (float)i; add(&a, p); add(&b, p); + } + check(!fg_cam_fit(&a, &b, &cp, &fit, v) && fit.why && strstr(fit.why, "no projections"), "no projections: rejected"); + fg_prims_reset(&a); fg_prims_reset(&b); + /* A cut: other geometry entirely. */ + stand(&a, &co, 0, GH, 0); + stand(&b, &cn, 0, GH, 0); + for (uint32_t j = 0; j < b.n; j++) for (int k = 0; k < 3; k++) b.v[j].vid[k] += 100000u; + check(!fg_cam_fit(&a, &b, &cp, &fit, v) && fit.why && strstr(fit.why, "few pairs"), "a cut: rejected"); + fg_prims_reset(&b); + /* A turn no camera makes between two frames. */ + stand(&b, &far, 0, GH, 0); + check(!fg_cam_fit(&a, &b, &cp, &fit, v), "an implausible turn: rejected"); + fg_prims_reset(&b); + /* Half the identities moved independently (wrong lookups): no consistent camera. */ + stand(&b, &cn, 0, GH, 0); + for (uint32_t j = 0; j < b.n; j += 2) + for (int k = 0; k < 3; k++) { b.v[j].p[k][0] += 300.0f * (float)((j * 7 + k) % 5); b.v[j].p[k][2] += 150.0f; } + check(!fg_cam_fit(&a, &b, &cp, &fit, v), "inconsistent motion: rejected"); fg_prims_free(&a); fg_prims_free(&b); } -static void test_lerp(void) { - FgPrim a = tri(1, 10, 20, 8), b = tri(1, 30, 10, 8); - float x[3], y[3]; - fg_lerp(&a, &b, 0.0, x, y); - check(x[0] == a.x[0] && y[2] == a.y[2], "phase 0 is the older frame exactly"); - fg_lerp(&a, &b, 1.0, x, y); - check(x[1] == b.x[1] && y[1] == b.y[1], "phase 1 is the newer frame exactly"); - fg_lerp(&a, &b, 0.25, x, y); - check(fabsf(x[0] - 15.0f) < 1e-5f && fabsf(y[0] - 17.5f) < 1e-5f, "phase 0.25"); -} - static void test_plan(void) { const double f30 = 2.0 / 59.94, f60 = 1.0 / 59.94; /* 30 Hz game frames on a 120 Hz panel: 4 slots, 3 in-between. */ @@ -231,10 +317,9 @@ int main(void) { test_ceiling(); test_cost(); test_pace(); - test_identical_and_moved(); - test_keys_limits_and_duplicates(); - test_each_older_pairs_once(); - test_lerp(); + test_camera_reordered(); + test_objects_hud_neighbours(); + test_verdict(); test_plan(); test_breaker(); printf("frame_gen_test: checks=%d failures=%d\n", checks, failures); diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index 2075794f..351abed0 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -5,7 +5,9 @@ * x = 0 / x = 512 and presents every VBlank (each game frame twice), paced at * 60 Hz: a fill, textured / shaded / flat triangles that move a little each * game frame, a fixed HUD rect, render-to-texture and a VRAM copy outside the - * display, and every fifth frame a triangle that jumps (never matched). + * display, and every fifth frame a triangle that jumps (never matched). The + * triangles carry projection sources (fixture_sources) as gpu.c records them + * for GTE-projected geometry, so in-between frames follow the camera. * argv[3] == "wide" presents through a native-wide surface instead of VRAM; * argv[5] == "late" flips to each frame one VBlank after the next one has * started drawing (as R4 does). @@ -177,6 +179,29 @@ static void textures(void) { } static int wide_mode; +/* What gpu.c records for a GTE-projected triangle (gl_renderer_fg_source): + * vertex identities and camera-space positions that project (H = z = 1000) + * to the triangle's screen positions relative to its buffer. */ +/* Structurally different: a channel off by more than 48 (sub-pixel + * placement at a high scale resamples textures by a few LSB). */ +static int px_far(uint32_t a, uint32_t b) { + for (int c = 0; c < 24; c += 8) { int d = (int)((a >> c) & 255) - (int)((b >> c) & 255); if (d > 48 || d < -48) return 1; } + return 0; +} + +static void fixture_sources(int i, int bx, int x, int y) { + static const int off[4][6] = { + { 0, 0, 30, 2, 3, 32 }, { 0, 0, 28, 0, 0, 28 }, { 0, 0, 26, 4, 6, 30 }, { 0, 0, 24, 20, -4, 26 } }; + const int *o = off[i % 4]; + uint32_t id[3]; int32_t pc[9], h[3], xs[3], ys[3]; + for (int k = 0; k < 3; k++) { + id[k] = (uint32_t)(i * 3 + k + 1); + xs[k] = x + o[2 * k]; ys[k] = y + o[2 * k + 1]; + pc[3 * k] = xs[k] - bx; pc[3 * k + 1] = ys[k]; pc[3 * k + 2] = 1000; h[k] = 1000; + } + gl_renderer_fg_source(id, pc, h, xs, ys); +} + /* One game frame drawn into buffer bx. Positions move by whole pixels with * g; every fifth frame triangle 0 jumps across the screen. */ static void game_frame_part(int g, int bx, int part) { @@ -191,9 +216,12 @@ static void game_frame_part(int g, int bx, int part) { gr_set_color_modulation(128, 128, 128, 0); gr_set_semi_transparency(0, 0); for (int i = part ? 20 : 0; i < (part ? 40 : 20); i++) { - int x = bx + 10 + (i % 8) * 36 + ((i * 3 + g * (1 + i % 3)) % 9) - 4; - int y = 12 + (i / 8) * 42 + (g * (i % 2 ? 1 : -1)) % 5; + /* The camera pans one pixel per game frame (every triangle moves + * alike); triangle 5 is an object moving on its own. */ + int x = bx + 10 + (i % 8) * 36 + (i == 5 ? (g * 3) % 9 : g % 9) - 4; + int y = 12 + (i / 8) * 42 + (i == 5 ? g % 5 : 0); if (i == 0 && g % 5 == 0) { x = bx + 250 - (g * 37) % 200; y = 180; } + fixture_sources(i, bx, x, y); switch (i % 4) { case 0: gr_draw_shaded_textured_triangle(x, y, 0, 0, 0x808080, x + 30, y + 2, 63, 4, 0x6090b0, x + 3, y + 32, 8, 63, 0xb09060, 512, 300, 0x000e, 0); break; @@ -310,10 +338,20 @@ int main(int argc, char **argv) { check(fg_generate(0.0, 0) == 1, "compose at phase 0"); uint64_t d0 = read_back(img); uint64_t r0 = fnv(img_last[0], (size_t)ww * wh * 4, 0xcbf29ce484222325ull); - long diff = 0; - for (int i = 0; i < ww * wh; i++) if (img[i] != img_last[0][i]) diff++; - printf("phase0_diff_px=%ld matched=%u/%u\n", diff, s_fg_mst.matched, s_fg_mst.prims); - check(d0 == r0, "phase 0 equals the older real frame"); + /* Every projected triangle is where the older frame had it; what the + * in-between camera uncovers (here: the background beside a moved + * triangle, through its transparent texels) shows the newer frame + * (frame_gen.h). So each phase-0 pixel is the older frame's, or the + * newer frame's where they differ. */ + if (getenv("FG_DUMP")) { FILE *f = fopen("p0.rgba", "wb"); fwrite(img, 4, (size_t)ww * wh, f); fclose(f); } + long diff = 0, neither = 0; + for (int i = 0; i < ww * wh; i++) + if (img[i] != img_last[0][i]) { diff++; neither += px_far(img[i], img_last[0][i]) && px_far(img[i], img_last[1][i]); } + printf("phase0_diff_px=%ld (neither frame %ld) of %d camera=%u object=%u\n", diff, neither, ww * wh, + s_fg_fit.camera, s_fg_fit.object); + (void)d0; (void)r0; + check(s_fg_fit.ok && s_fg_fit.camera + s_fg_fit.object == 120, "every projected vertex placed"); + check(neither * 1000 < (long)ww * wh, "phase 0: the older real frame, uncovered areas the newer (<0.1%)"); check(fg_generate(0.5, 0) == 1, "compose at phase 0.5"); uint64_t dm = read_back(img); if (getenv("FG_DUMP")) { From 6be9b6a807d5a1aa50472c3ddf9ebc8944ce315d Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:44:34 -0700 Subject: [PATCH 51/57] Frame generation: close cracks, skip frames the camera passes through Corner and tunnel runs showed light cracks along seams and near the view edge in generated frames: - weld: every placed vertex at one screen position of a view moves alike (the same corner arrives from several projections); - a vertex without a projection on an edge between placed vertices moves as that point of the edge (T-junctions from the game's polygon splits); - a vertex the in-between camera passes is projected at a clamped depth, and such a game frame is not generated (no near-plane clipping: tunnel ceilings, overhead bridges); - debug: "clamped", "guessed" (CPU-built vertices placed by a triangle mean over a >1 px motion spread). Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_GENERATION.md | 12 +++- runtime/include/frame_gen.h | 9 ++- runtime/src/frame_gen.c | 123 ++++++++++++++++++++++++++++++++-- runtime/src/gpu_gl_renderer.c | 18 ++++- 4 files changed, 151 insertions(+), 11 deletions(-) diff --git a/docs/FRAME_GENERATION.md b/docs/FRAME_GENERATION.md index 01891e8b..4ab4c081 100644 --- a/docs/FRAME_GENERATION.md +++ b/docs/FRAME_GENERATION.md @@ -55,7 +55,13 @@ has measured time to spare. frame's image and skips the clears before the first draw, and strips along a view's edges the picture moved away from are copied from that image, so what the in-between camera uncovers shows the newer frame instead of a - hole. Triangles crossing the camera plane are dropped. The raw texture + hole. Every placed vertex at one screen position moves alike (a weld: the same + corner reaches the GPU from several projections), and a vertex without a + projection lying on an edge between placed vertices moves as that point of + the edge (closes the T-junctions the game's polygon splits leave). A game + frame whose in-between camera would pass through geometry (a vertex behind + it: tunnel ceilings, overhead bridges) is not generated: without near-plane + clipping its image would be wrong. The raw texture mirror is not packed from the generated surfaces; all bookkeeping the draws touch is restored, so the real stream is unaffected. - **Schedule.** With `n` in-between frames per game frame, at the flip the @@ -90,7 +96,9 @@ has measured time to spare. - Only GTE-projected triangles move; sprites, lines and fills are the redrawn frame's. Areas uncovered by the in-between camera show the newer - frame (thin slivers at depth discontinuities are possible). + frame. Vertices without a projection that are neither on a placed edge nor + shared take their triangle's mean motion (`guessed` counts those over a + 1 px spread); near R4's tunnel walls this still opens thin cracks. - A texture the frame drew earlier into its own displayed buffer is sampled as the raw mirror holds it when the in-between frame is drawn. - Single-buffered games never flip, so nothing is generated. diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h index 5ea3eed7..8e17ec95 100644 --- a/runtime/include/frame_gen.h +++ b/runtime/include/frame_gen.h @@ -97,6 +97,9 @@ typedef struct FgCamFit { int ok; const char *why; uint32_t nviews, prims, camera, object, neighbour, unchanged; + uint32_t clamped; /* fg_cam_place: vertices the in-between camera passes (frame rejected) */ + uint32_t guessed; /* fg_cam_place: vertices without a projection placed by a triangle + * mean over a motion that varies by more than 1 px */ FgView v[FG_MAX_VIEWS]; } FgCamFit; @@ -115,13 +118,13 @@ int fg_cam_fit(const FgPrimList *older, const FgPrimList *newer, const FgCamPara FgCamFit *fit, FgVert *verts); /* Screen positions of every newer vertex at phase t (0 = the older frame's - * camera, 1 = the newer frame exactly): x/y[newer->n * 3], NaN for a vertex - * behind the in-between camera (its triangle is not drawn). margin (may be + * camera, 1 = the newer frame exactly): x/y[newer->n * 3]; a vertex the + * in-between camera is passing is projected at a clamped depth. margin (may be * NULL) receives, per view, how far the picture moved in from each edge of * its draw area (left, top, right, bottom, px): the newer frame drew nothing * beyond its edges, so that strip has no in-between picture and the caller * shows the newer frame there. */ -void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *verts, +void fg_cam_place(const FgPrimList *newer, FgCamFit *fit, const FgVert *verts, double t, float *x, float *y, float margin[][4]); /* How many in-between frames to draw per game frame. diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index 646f1fbd..70a7ec55 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -397,7 +397,7 @@ static int proj(const double v[3], double h, double *sx, double *sy) { typedef struct { int32_t qx, qy; float dx, dy; uint32_t n; } NAcc; -void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *verts, +void fg_cam_place(const FgPrimList *newer, FgCamFit *fit, const FgVert *verts, double t, float *x, float *y, float margin[][4]) { const uint32_t n = newer->n; if (margin) memset(margin, 0, sizeof(float) * 4 * FG_MAX_VIEWS); @@ -421,6 +421,7 @@ void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *ve q_to_m(qt, Rt[vi]); for (int k = 0; k < 3; k++) { T[vi][k] = v->t[k]; Tt[vi][k] = v->t[k] * t; } } + uint32_t clamped = 0, guessed = 0; float *dxs = (float *)malloc((size_t)n * 3 * sizeof *dxs); float *dys = (float *)malloc((size_t)n * 3 * sizeof *dys); if (!dxs || !dys) { free(dxs); free(dys); return; } @@ -446,10 +447,79 @@ void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *ve } double ax, ay, bx, by; if (!proj(pn, pr->h[k], &bx, &by)) continue; - if (!proj(pt, pr->h[k], &ax, &ay)) { dxs[i] = dys[i] = NAN; continue; } /* behind the camera at t */ + /* Near geometry the in-between camera is passing: projected at a + * clamped depth. A function of the vertex alone, so triangles + * sharing it stay closed (no cracks onto what is behind). */ + const double zmin = fmax(16.0, 0.25 * pn[2]); + if (pt[2] < zmin) { pt[2] = zmin; clamped++; } + if (!proj(pt, pr->h[k], &ax, &ay)) continue; dxs[i] = (float)(ax - bx); dys[i] = (float)(ay - by); } } + /* A vertex without a projection lying on an edge between two placed + * vertices (the game split a polygon there: near-plane clipping, + * subdivision) moves as that point of the edge does, so the T-junction + * stays closed. */ + uint8_t *done = (uint8_t *)calloc((size_t)n * 3, 1); + { + uint32_t ne = 0; + for (uint32_t i = 0; i < n * 3u; i++) ne += verts[i].mode == FG_PLACE_NEIGHBOUR; + if (ne && done) { + enum { G = 16 }; /* grid cell, native px */ + int32_t gx0 = INT32_MAX, gy0 = INT32_MAX, gx1 = INT32_MIN, gy1 = INT32_MIN; + for (uint32_t i = 0; i < n * 3u; i++) { + const int32_t cx = (int32_t)floorf(newer->v[i / 3].x[i % 3] / G), cy = (int32_t)floorf(newer->v[i / 3].y[i % 3] / G); + if (cx < gx0) gx0 = cx; if (cx > gx1) gx1 = cx; if (cy < gy0) gy0 = cy; if (cy > gy1) gy1 = cy; + } + if (gx0 < -64) gx0 = -64; if (gy0 < -64) gy0 = -64; if (gx1 > 128) gx1 = 128; if (gy1 > 128) gy1 = 128; + const int32_t gw = gx1 - gx0 + 1, gh = gy1 - gy0 + 1; + /* Cells hold neighbour vertices; edges look them up. */ + int32_t *head = gw > 0 && gh > 0 ? (int32_t *)malloc((size_t)gw * gh * sizeof *head) : NULL; + int32_t *nxt = (int32_t *)malloc((size_t)n * 3 * sizeof *nxt); + float *best = (float *)malloc((size_t)n * 3 * sizeof *best); + if (head && nxt && best) { + for (int32_t c = 0; c < gw * gh; c++) head[c] = -1; + for (uint32_t i = 0; i < n * 3u; i++) { + best[i] = 0.75f; + if (verts[i].mode != FG_PLACE_NEIGHBOUR) continue; + const int32_t cx = (int32_t)floorf(newer->v[i / 3].x[i % 3] / G) - gx0; + const int32_t cy = (int32_t)floorf(newer->v[i / 3].y[i % 3] / G) - gy0; + if (cx < 0 || cy < 0 || cx >= gw || cy >= gh) continue; + nxt[i] = head[cy * gw + cx]; head[cy * gw + cx] = (int32_t)i; + } + for (uint32_t j = 0; j < n; j++) { + const FgPrim *pr = &newer->v[j]; + for (int e = 0; e < 3; e++) { + const uint32_t i0 = 3 * j + e, i1 = 3 * j + (e + 1) % 3; + const uint8_t m0 = verts[i0].mode, m1 = verts[i1].mode; + if ((m0 != FG_PLACE_CAMERA && m0 != FG_PLACE_OBJECT) || + (m1 != FG_PLACE_CAMERA && m1 != FG_PLACE_OBJECT)) continue; + const float ax = pr->x[e], ay = pr->y[e], bx = pr->x[(e + 1) % 3], by = pr->y[(e + 1) % 3]; + const float ex = bx - ax, ey = by - ay, l2 = ex * ex + ey * ey; + if (l2 < 1.0f) continue; + int32_t cx0 = (int32_t)floorf(fminf(ax, bx) / G) - gx0, cx1 = (int32_t)floorf(fmaxf(ax, bx) / G) - gx0; + int32_t cy0 = (int32_t)floorf(fminf(ay, by) / G) - gy0, cy1 = (int32_t)floorf(fmaxf(ay, by) / G) - gy0; + if (cx0 < 0) cx0 = 0; if (cy0 < 0) cy0 = 0; if (cx1 >= gw) cx1 = gw - 1; if (cy1 >= gh) cy1 = gh - 1; + if ((int64_t)(cx1 - cx0 + 1) * (cy1 - cy0 + 1) > 4096) continue; + for (int32_t cy = cy0; cy <= cy1; cy++) + for (int32_t cx = cx0; cx <= cx1; cx++) + for (int32_t v = head[cy * gw + cx]; v >= 0; v = nxt[v]) { + const float px = newer->v[v / 3].x[v % 3], py = newer->v[v / 3].y[v % 3]; + const float u = ((px - ax) * ex + (py - ay) * ey) / l2; + if (u <= 0.0f || u >= 1.0f) continue; + const float dx = ax + u * ex - px, dy = ay + u * ey - py, d = sqrtf(dx * dx + dy * dy); + if (d >= best[v]) continue; + best[v] = d; + dxs[v] = dxs[i0] + (dxs[i1] - dxs[i0]) * u; + dys[v] = dys[i0] + (dys[i1] - dys[i0]) * u; + done[v] = 1; + } + } + } + } + free(head); free(nxt); free(best); + } + } /* Neighbours: the mean motion of the placed vertices of the triangles * that share the position (1/16 px). */ uint32_t nn = 0; @@ -468,8 +538,19 @@ void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *ve } } if (!c) continue; + /* The mean stands in for a motion that varies across the + * triangle with depth; where it varies by more than a pixel + * the guess opens cracks (near walls split by the game). */ + float spread = 0; for (int k = 0; k < 3; k++) { - if (verts[3 * j + k].mode != FG_PLACE_NEIGHBOUR) continue; + const uint8_t m = verts[3 * j + k].mode; + if (m != FG_PLACE_CAMERA && m != FG_PLACE_OBJECT) continue; + const float ex = dxs[3 * j + k] - sx / c, ey = dys[3 * j + k] - sy / c; + if (ex * ex + ey * ey > spread) spread = ex * ex + ey * ey; + } + for (int k = 0; k < 3; k++) { + if (verts[3 * j + k].mode != FG_PLACE_NEIGHBOUR || (done && done[3 * j + k])) continue; + if (spread > 1.0f) guessed++; const int32_t qx = (int32_t)lrintf(newer->v[j].x[k] * 16.0f), qy = (int32_t)lrintf(newer->v[j].y[k] * 16.0f); uint32_t h = ((uint32_t)qx * 73856093u ^ (uint32_t)qy * 19349663u) & (cap - 1); while (tab[h].n && (tab[h].qx != qx || tab[h].qy != qy)) h = (h + 1) & (cap - 1); @@ -480,7 +561,7 @@ void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *ve for (uint32_t j = 0; j < n; j++) for (int k = 0; k < 3; k++) { const uint32_t i = 3 * j + k; - if (verts[i].mode != FG_PLACE_NEIGHBOUR) continue; + if (verts[i].mode != FG_PLACE_NEIGHBOUR || (done && done[i])) continue; const int32_t qx = (int32_t)lrintf(newer->v[j].x[k] * 16.0f), qy = (int32_t)lrintf(newer->v[j].y[k] * 16.0f); uint32_t h = ((uint32_t)qx * 73856093u ^ (uint32_t)qy * 19349663u) & (cap - 1); while (tab[h].n && (tab[h].qx != qx || tab[h].qy != qy)) h = (h + 1) & (cap - 1); @@ -489,6 +570,40 @@ void fg_cam_place(const FgPrimList *newer, const FgCamFit *fit, const FgVert *ve free(tab); } } + /* Weld: every placed vertex at one screen position (1/16 px) of one view + * moves alike. The same corner reaches the GPU from several projections + * (another function, another model vertex, an object and the world); if + * they moved apart, the shared edge would crack open onto whatever was + * drawn behind it (sky through a tunnel ceiling). */ + { + uint32_t cap = 64; + while (cap < n * 6u) cap <<= 1; + NAcc *tab = (NAcc *)calloc(cap, sizeof *tab); + uint32_t *slot = (uint32_t *)malloc((size_t)n * 3 * sizeof *slot); + if (tab && slot) { + for (uint32_t i = 0; i < n * 3u; i++) { + slot[i] = UINT32_MAX; + if (verts[i].mode == FG_PLACE_UNCHANGED) continue; + const FgPrim *pr = &newer->v[i / 3]; + const int32_t qx = (int32_t)lrintf(pr->x[i % 3] * 16.0f) ^ (verts[i].view << 24); + const int32_t qy = (int32_t)lrintf(pr->y[i % 3] * 16.0f); + uint32_t h = ((uint32_t)qx * 73856093u ^ (uint32_t)qy * 19349663u) & (cap - 1); + while (tab[h].n && (tab[h].qx != qx || tab[h].qy != qy)) h = (h + 1) & (cap - 1); + tab[h].qx = qx; tab[h].qy = qy; + tab[h].dx += dxs[i]; tab[h].dy += dys[i]; tab[h].n++; + slot[i] = h; + } + for (uint32_t i = 0; i < n * 3u; i++) + if (slot[i] != UINT32_MAX) { + dxs[i] = tab[slot[i]].dx / tab[slot[i]].n; + dys[i] = tab[slot[i]].dy / tab[slot[i]].n; + } + } + free(tab); free(slot); + } + free(done); + fit->clamped = clamped; + fit->guessed = guessed; /* Motion to 1/8 px: the fit's rounding error must not move an edge off * a pixel boundary (a whole-pixel move stays whole). */ for (uint32_t i = 0; i < n * 3u; i++) { diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 8ce3c938..329f1c45 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -11223,6 +11223,20 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal /* The verdict: a frame whose correspondence is not trustworthy is not * generated; the real frame shows at its own time. A forced run (the * fixture's synthetic scenes carry no GTE identities) skips it. */ + /* Geometry the in-between camera passes through (a tunnel ceiling, a + * bridge overhead) has no correct in-between image without clipping at + * the near plane: such a game frame shows its real frames. */ + if (s_fg_fit.ok && s_fg_pos_cap < src->prims.n) { + float *nx = (float *)realloc(s_fg_px, (size_t)src->prims.n * 3 * sizeof *nx); + if (nx) s_fg_px = nx; + float *ny = (float *)realloc(s_fg_py, (size_t)src->prims.n * 3 * sizeof *ny); + if (ny) s_fg_py = ny; + if (nx && ny) s_fg_pos_cap = src->prims.n; + } + if (s_fg_fit.ok && s_fg_pos_cap >= src->prims.n) { /* the phase furthest from the redrawn frame */ + fg_cam_place(&src->prims, &s_fg_fit, s_fg_verts, 1.0 / (double)(n + 1), s_fg_px, s_fg_py, NULL); + if (s_fg_fit.clamped) { s_fg_fit.ok = 0; s_fg_fit.why = "camera passes through geometry"; } + } if (!s_fg_fit.ok && !s_fg_force) { s_fg_rejected++; s_fg_reject_why = s_fg_fit.why; @@ -11320,7 +11334,7 @@ int gl_renderer_frame_gen_json(char *out, int cap) { "\"trips_late\":%u,\"trips_backed_up\":%u,\"trips_behind\":%u," "\"place_camera\":%u,\"place_object\":%u,\"place_neighbour\":%u," "\"place_unchanged\":%u,\"cam_angle_deg\":%.3f,\"cam_shift\":%.1f," - "\"verdict_ok\":%d,\"rejected\":%llu,\"reject_why\":\"%s\"", + "\"clamped\":%u,\"guessed\":%u,\"verdict_ok\":%d,\"rejected\":%llu,\"reject_why\":\"%s\"", s_fg_on, s_fg_on && s_rth_on && open, s_fg_force, (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, (unsigned long long)s_fg_flips, (unsigned long long)s_fg_dups, @@ -11343,7 +11357,7 @@ int gl_renderer_frame_gen_json(char *out, int cap) { 2.0 * acos(fmin(1.0, fabs(s_fg_fit.v[0].q[0]))) * 57.29578, sqrt(s_fg_fit.v[0].t[0] * s_fg_fit.v[0].t[0] + s_fg_fit.v[0].t[1] * s_fg_fit.v[0].t[1] + s_fg_fit.v[0].t[2] * s_fg_fit.v[0].t[2]), - s_fg_fit.ok, + s_fg_fit.clamped, s_fg_fit.guessed, s_fg_fit.ok, (unsigned long long)s_fg_rejected, s_fg_reject_why ? s_fg_reject_why : ""); } From 22fe3669cf5139e85a33bdf8aa51d1ce624589ad Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:56:55 -0700 Subject: [PATCH 52/57] Frame generation: shared corners are one vertex; no motion rounding The remaining tunnel cracks came from vertex sources, not from missing depth: 46 % of R4 lookups were marked ambiguous because the same 3D point is projected several times per frame (corners shared by separate meshes, other functions), so those vertices fell back to a triangle-mean guess. - The same point projected again is one vertex (the smaller identity, so the choice does not follow draw order). - Up to two different points on one pixel are kept; gpu.c picks the one projected by the same function as the triangle's other vertices, else the one nearest their depth. - Per-vertex motion is no longer rounded to 1/8 px: at high internal scales the rounding opened hairlines along long T-junction seams. Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_GENERATION.md | 8 ++++-- runtime/include/cpu_state.h | 3 ++- runtime/src/frame_gen.c | 7 +---- runtime/src/gpu.c | 25 +++++++++++++++-- runtime/src/gte.cpp | 53 ++++++++++++++++++++++++------------- 5 files changed, 66 insertions(+), 30 deletions(-) diff --git a/docs/FRAME_GENERATION.md b/docs/FRAME_GENERATION.md index 4ab4c081..054fe14d 100644 --- a/docs/FRAME_GENERATION.md +++ b/docs/FRAME_GENERATION.md @@ -33,7 +33,11 @@ has measured time to spare. - **Sources.** While generation is on, every RTPS/RTPT output is noted (gte.cpp, `gte_fg_source_*`) with an identity (the issuing function `ra` and the model-space vertex), the camera-space position the GTE divided and - H, indexed by the packed screen word. gpu.c looks each polygon vertex up by + H, indexed by the packed screen word. The same point projected again in a + frame (a corner shared by separate meshes, another function) is one vertex + (the smaller identity); up to two different points on one pixel are kept, + and gpu.c picks the one projected by the same function as the triangle's + other vertices, else the one nearest their depth. gpu.c looks each polygon vertex up by its packet word and records the sources ahead of the triangle (`RTH_FG_SRC`, never drawn). CPU-built vertices have none. Guest-visible GTE results are untouched. @@ -98,7 +102,7 @@ has measured time to spare. redrawn frame's. Areas uncovered by the in-between camera show the newer frame. Vertices without a projection that are neither on a placed edge nor shared take their triangle's mean motion (`guessed` counts those over a - 1 px spread); near R4's tunnel walls this still opens thin cracks. + 1 px spread; 0 in R4 races once shared corners resolve). - A texture the frame drew earlier into its own displayed buffer is sampled as the raw mirror holds it when the in-between frame is drawn. - Single-buffered games never flip, so nothing is generated. diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index cf75c591..1a9e2466 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -224,7 +224,8 @@ extern int gte_geometry_correction_enabled(void); /* Frame generation's vertex sources (gte.cpp): 0 = unknown. */ extern void gte_fg_source_set(int enabled); extern int gte_fg_source_enabled(void); -extern int gte_fg_source_lookup(uint32_t packed, uint32_t *id, int32_t p[3], int32_t *hdist); +typedef struct GteFgSrc { uint32_t id, ra; int32_t p[3], h; } GteFgSrc; +extern int gte_fg_source_lookup(uint32_t packed, GteFgSrc out[2]); /* Corrected vertices consumed since the last gte_geometry_correction_set — * the "is this actually doing anything on this title" counter. */ extern uint32_t gte_geometry_correction_hits(void); diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index 70a7ec55..79e080d8 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -604,12 +604,7 @@ void fg_cam_place(const FgPrimList *newer, FgCamFit *fit, const FgVert *verts, free(done); fit->clamped = clamped; fit->guessed = guessed; - /* Motion to 1/8 px: the fit's rounding error must not move an edge off - * a pixel boundary (a whole-pixel move stays whole). */ - for (uint32_t i = 0; i < n * 3u; i++) { - x[i] += roundf(dxs[i] * 8.0f) / 8.0f; - y[i] += roundf(dys[i] * 8.0f) / 8.0f; - } + for (uint32_t i = 0; i < n * 3u; i++) { x[i] += dxs[i]; y[i] += dys[i]; } /* Margins: vertices on or past a view edge that moved inward. */ if (margin) for (uint32_t j = 0; j < n; j++) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 38f2c0cf..bee72a73 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -4478,8 +4478,29 @@ static void prepare_precise_triangle(int i0, int i1, int i2, uint32_t id[3] = { 0, 0, 0 }; int32_t pc[9] = { 0 }, hd[3] = { 0, 0, 0 }; const int ix[3] = { i0, i1, i2 }; - for (int k = 0; k < 3; k++) - if (!gte_fg_source_lookup(gp0_cmd_buf[ix[k]], &id[k], &pc[3 * k], &hd[k])) id[k] = 0; + GteFgSrc c[3][2]; + int nc[3]; + for (int k = 0; k < 3; k++) nc[k] = gte_fg_source_lookup(gp0_cmd_buf[ix[k]], c[k]); + /* Two points on one pixel: the one projected by the same function as + * a vertex with one source, else the one nearest its depth. */ + for (int k = 0; k < 3; k++) { + int pick = nc[k] == 1 ? 0 : -1; + if (nc[k] == 2) { + for (int m = 0; m < 3 && pick < 0; m++) + if (m != k && nc[m] == 1) + for (int q = 0; q < 2; q++) + if (c[k][q].ra == c[m][0].ra) { pick = q; break; } + if (pick < 0) + for (int m = 0; m < 3 && pick < 0; m++) + if (m != k && nc[m] == 1) { + const int32_t z = c[m][0].p[2]; + pick = abs(c[k][0].p[2] - z) <= abs(c[k][1].p[2] - z) ? 0 : 1; + } + } + if (pick < 0) continue; + id[k] = c[k][pick].id; hd[k] = c[k][pick].h; + for (int q = 0; q < 3; q++) pc[3 * k + q] = c[k][pick].p[q]; + } gl_renderer_fg_source(id, pc, hd, vx, vy); } gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 0f8f099a..6279b3a4 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -415,8 +415,8 @@ extern "C" int gte_geometry_correction_lookup_probe(uint32_t packed, * to in one frame is ambiguous. Entries a few frames old are not believed. * Allocated only while frame generation is on: the guest-visible GTE results * are untouched either way. */ -struct FgSrcEntry { uint32_t seq; uint32_t gen; }; /* gen: frame << 1 | ambiguous */ -struct FgSrcRec { uint32_t seq, id; int32_t p[3]; int32_t h; }; +struct FgSrcEntry { uint32_t seq, seq2; uint32_t gen; }; /* gen: frame << 1 | ambiguous; seq2: a second point */ +struct FgSrcRec { uint32_t seq, id, ra; int32_t p[3]; int32_t h; }; #define FGSRC_RING (1u << 17) static FgSrcEntry *s_fgsrc = nullptr; static FgSrcRec *s_fgsrc_ring = nullptr; @@ -453,6 +453,7 @@ static void fgsrc_note(const GTEState *g, int32_t packed, uint32_t ra, const int FgSrcRec &r = s_fgsrc_ring[seq & (FGSRC_RING - 1u)]; r.seq = seq; r.id = h; + r.ra = ra; for (int i = 0; i < 3; i++) { int64_t m = (int64_t)g->TR[i] * 4096; for (int k = 0; k < 3; k++) m += (int64_t)g->RT[i][k] * v[k]; @@ -461,13 +462,22 @@ static void fgsrc_note(const GTEState *g, int32_t packed, uint32_t ra, const int r.h = gte_h_scaled(g); FgSrcEntry &e = s_fgsrc[slot]; const uint32_t f = fgsrc_frame(); - if ((e.gen >> 1) == f && e.seq && s_fgsrc_ring[e.seq & (FGSRC_RING - 1u)].seq == e.seq && - s_fgsrc_ring[e.seq & (FGSRC_RING - 1u)].id != h) { - e.gen |= 1u; /* two vertices, one pixel */ - return; - } - if ((e.gen >> 1) != f) e.gen = f << 1; - e.seq = seq; + if ((e.gen >> 1) != f) { e.gen = f << 1; e.seq = seq; e.seq2 = 0; return; } + /* Earlier this frame: the same point projected again (another function, + * another model vertex: shared corners of separate meshes) is one vertex, + * keeping the smaller identity so the choice does not depend on draw + * order. Up to two different points on one pixel are kept (the GPU side + * picks by the triangle's other vertices); a third is ambiguous. */ + auto same = [&](uint32_t sq) { + const FgSrcRec &o = s_fgsrc_ring[sq & (FGSRC_RING - 1u)]; + return o.seq == sq && std::abs(o.p[0] - r.p[0]) <= 2 && std::abs(o.p[1] - r.p[1]) <= 2 && + std::abs(o.p[2] - r.p[2]) <= 2; + }; + auto keep = [&](uint32_t &sq) { if (s_fgsrc_ring[sq & (FGSRC_RING - 1u)].id > h) sq = seq; }; + if (same(e.seq)) { keep(e.seq); return; } + if (!e.seq2) { e.seq2 = seq; return; } + if (same(e.seq2)) { keep(e.seq2); return; } + e.gen |= 1u; } static void fgsrc_record(const GTEState *g, uint32_t cmd, uint32_t ra) { @@ -481,22 +491,27 @@ static void fgsrc_record(const GTEState *g, uint32_t cmd, uint32_t ra) { } } -/* The source of the vertex last projected to a packed screen word: 0 when - * none, ambiguous or stale; else 1 with its identity, camera-space position - * and projection distance. */ -extern "C" int gte_fg_source_lookup(uint32_t packed, uint32_t *id, int32_t p[3], int32_t *hdist) { +/* The sources of the vertices last projected to a packed screen word this + * frame (or a few frames ago): 0 when none, ambiguous or stale, else 1 or 2 + * (two different points on that pixel). */ +extern "C" int gte_fg_source_lookup(uint32_t packed, GteFgSrc out[2]) { if (!s_fgsrc) return 0; const int64_t slot = geom_slot(packed); if (slot < 0) return 0; const FgSrcEntry &e = s_fgsrc[slot]; if (!e.seq || (e.gen & 1u)) return 0; if (((fgsrc_frame() - (e.gen >> 1)) & 0x7FFFFFFFu) > FGSRC_MAX_AGE) return 0; - const FgSrcRec &r = s_fgsrc_ring[e.seq & (FGSRC_RING - 1u)]; - if (r.seq != e.seq) return 0; - *id = r.id; - p[0] = r.p[0]; p[1] = r.p[1]; p[2] = r.p[2]; - *hdist = r.h; - return 1; + int n = 0; + const uint32_t sq[2] = { e.seq, e.seq2 }; + for (int i = 0; i < 2; i++) { + if (!sq[i]) continue; + const FgSrcRec &r = s_fgsrc_ring[sq[i] & (FGSRC_RING - 1u)]; + if (r.seq != sq[i]) continue; + out[n].id = r.id; out[n].ra = r.ra; out[n].h = r.h; + for (int k = 0; k < 3; k++) out[n].p[k] = r.p[k]; + n++; + } + return n; } /* Render-pass checkpoint for the gte.cpp side of precision tracking: the From f4160b55d94f01a8912dfae1bbbb474ba351488d Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:56:00 -0700 Subject: [PATCH 53/57] Test: GPU exec fixtures stub the frame-generation vertex-source hooks gpu.c asks the GL backend and gte.cpp for Smooth motion's vertex sources; master's native_wide_projection_test (cd9981cd) links gpu.c with these stubs. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/gpu_exec_stubs.inc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/runtime/tests/gpu_exec_stubs.inc b/runtime/tests/gpu_exec_stubs.inc index 37d106a8..ed557d2a 100644 --- a/runtime/tests/gpu_exec_stubs.inc +++ b/runtime/tests/gpu_exec_stubs.inc @@ -408,3 +408,12 @@ static int fixture_triangle_recovered; void gl_renderer_note_wide_triangle_recovery(int enabled) { fixture_triangle_recovered=enabled; } +/* Smooth motion (frame generation) vertex sources: off in these fixtures. */ +int gl_renderer_frame_generation(void) { return 0; } +void gl_renderer_fg_source(const uint32_t id[3], const int32_t pc[9], const int32_t h[3], + const int32_t x[3], const int32_t y[3]) { + (void)id; (void)pc; (void)h; (void)x; (void)y; +} +void gte_fg_source_set(int enabled) { (void)enabled; } +int gte_fg_source_enabled(void) { return 0; } +int gte_fg_source_lookup(uint32_t packed, GteFgSrc out[2]) { (void)packed; (void)out; return 0; } From 38fbe239f12461c86082ccbe5a5e0c286dc63525 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:58:31 -0700 Subject: [PATCH 54/57] Test: GP0 history guard fixture stubs the frame-generation hooks gpu.c's precise-triangle path references Smooth motion's vertex sources; the software-only fixture never reaches them (abort if it does). Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_gpu_gp0_history_guard.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/runtime/tests/test_gpu_gp0_history_guard.py b/runtime/tests/test_gpu_gp0_history_guard.py index 2b4aa1d3..33058ce5 100644 --- a/runtime/tests/test_gpu_gp0_history_guard.py +++ b/runtime/tests/test_gpu_gp0_history_guard.py @@ -291,6 +291,15 @@ void gl_renderer_draw_projected_triangle(const PSXProjectedVertex vertices[3], { (void)vertices; (void)texpage; (void)cx; (void)cy; (void)raw; (void)semi; (void)perspective; abort(); } void gl_renderer_note_wide_triangle_recovery(int enabled) { if (enabled) abort(); } +/* Smooth motion vertex sources are an OpenGL path: never reached here. */ +int gl_renderer_frame_generation(void) { return 0; } +void gl_renderer_fg_source(const uint32_t id[3], const int32_t pc[9], const int32_t h[3], + const int32_t x[3], const int32_t y[3]) +{ (void)id; (void)pc; (void)h; (void)x; (void)y; abort(); } +void gte_fg_source_set(int enabled) { if (enabled) abort(); } +int gte_fg_source_enabled(void) { return 0; } +int gte_fg_source_lookup(uint32_t packed, GteFgSrc out[2]) +{ (void)packed; (void)out; abort(); } void pgxp_set_enabled(int enabled) { (void)enabled; } int pgxp_get_gte_sxy_checked(uint32_t slot, uint32_t packed, int require_valid, int32_t *x16, int32_t *y16) From 4f25e6de835da49a9a60579b5aa2fc409b0cb8b7 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:30:04 -0700 Subject: [PATCH 55/57] Pipeline toggles as player settings and launcher rows render_thread, present_thread and frame_generation are read from the player's settings.toml [video] (over the game.toml default; PSX_* env still overrides one run and is never saved), written back by save_user_settings, and round-tripped through the recomp-ui Display rows when the launcher defines RECOMP_LAUNCHER_HAS_RENDER_PIPELINE. The pipeline starts once at boot: a first-boot launcher choice applies immediately, an in-game change is saved and applies at next launch. Regenerated the OpenBIOS emitter stamp for the config_loader change. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_THREAD.md | 12 +++- recompiler/src/config_loader.cpp | 18 ++++++ recompiler/src/config_loader.h | 7 +++ .../tests/video_enhancement_settings_test.cpp | 44 +++++++++++++++ runtime/src/main.cpp | 55 +++++++++++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 376fa606..6f0228ec 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -11,6 +11,15 @@ render_thread = true # game.toml; PSX_RENDER_THREAD=0/1 overrides `PSX_RENDER_THREAD_FRAMES=N` (default 2) bounds the closed frames in flight. +**Player settings.** `render_thread`, `present_thread` and `frame_generation` +are also read from the player's `settings.toml` `[video]`, which wins over the +game.toml default; the `PSX_*` env vars override both for one run and are +never saved. The launcher shows them as Settings → Display rows (Render +thread, Present thread, Frame generation; recomp-ui +`RECOMP_LAUNCHER_HAS_RENDER_PIPELINE`, OpenGL only, the latter two disabled +while Render thread is off). The pipeline starts once at boot, so a change +from the in-game launcher is saved and applies at next launch. + ## Why Before this change one thread ran the recompiled guest, turned every GP0 @@ -348,7 +357,8 @@ means), `guest_bound`, the step counters and `up_blocked`. mistakes: replay reading the tags live, staging uploads from the guest array, dropping the stream state. - `video_enhancement_settings_test`: the keys (`render_thread`, - `present_thread`) default off and parse. + `present_thread`, `frame_generation`) default off and parse, and the + settings.toml copies parse and round-trip through `save_user_settings`. - `present_thread_test` (`test_present_thread.c`): the present core against a fake presenter: presentation order is submission order over 600 frames with 2-4 slots, a slot is never composed while queued or on screen, each diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index f089e553..7392d6e5 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -2807,6 +2807,18 @@ UserSettings load_user_settings(const fs::path& path) { s.low_latency_input = toml::find(v, "low_latency_input"); s.has_low_latency_input = true; }); + if (v.contains("render_thread")) try_get([&]{ + s.render_thread = toml::find(v, "render_thread"); + s.has_render_thread = true; + }); + if (v.contains("present_thread")) try_get([&]{ + s.present_thread = toml::find(v, "present_thread"); + s.has_present_thread = true; + }); + if (v.contains("frame_generation")) try_get([&]{ + s.frame_generation = toml::find(v, "frame_generation"); + s.has_frame_generation = true; + }); if (v.contains("vsync")) try_get([&]{ const auto m = toml::find(v, "vsync"); if (m == "on" || m == "vsync") { s.vsync = 1; s.has_vsync = true; } @@ -3164,6 +3176,12 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { f << "fullscreen = " << s.fullscreen << "\n"; if (s.has_low_latency_input) f << "low_latency_input = " << (s.low_latency_input ? "true" : "false") << "\n"; + if (s.has_render_thread) + f << "render_thread = " << (s.render_thread ? "true" : "false") << "\n"; + if (s.has_present_thread) + f << "present_thread = " << (s.present_thread ? "true" : "false") << "\n"; + if (s.has_frame_generation) + f << "frame_generation = " << (s.frame_generation ? "true" : "false") << "\n"; if (s.has_vsync) f << "vsync = \"" << (s.vsync == 0 ? "immediate" : s.vsync < 0 ? "adaptive" : "on") << "\"\n"; if (s.has_frame_interpolation) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 60895332..1870da5a 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -1463,6 +1463,13 @@ struct UserSettings { // on a vsync-light box). vsync: 1=on (tear-free), 0=immediate (lowest // display latency, may tear), -1=adaptive. bool has_low_latency_input = false; bool low_latency_input = true; + // Rendering pipeline (RuntimeConfig::video_render_thread, _present_thread, + // _frame_generation; docs/RENDER_THREAD.md). Written only once the player + // changed them, so game.toml stays the default. Applied at next launch; + // PSX_RENDER_THREAD / PSX_PRESENT_THREAD / PSX_FRAME_GEN override one run. + bool has_render_thread = false; bool render_thread = false; + bool has_present_thread = false; bool present_thread = false; + bool has_frame_generation = false; bool frame_generation = false; bool has_vsync = false; int vsync = 1; bool has_frame_interpolation = false; bool frame_interpolation = false; bool has_frame_interpolation_fps = false; int frame_interpolation_fps = 0; diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 39cb4264..587b5f20 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -320,6 +320,49 @@ static void test_texture_window_batching() { fs::remove(q); } +/* settings.toml [video] render_thread / present_thread / frame_generation: + * the player's override of the game.toml default. Absent keys stay unset (so + * the title default stands), present keys parse, and a launcher save writes + * back exactly what was set -- including an explicit false. */ +static void test_pipeline_user_settings() { + fs::path p = write_temp("psxrecomp_pipe_absent.toml", "[video]\n"); + auto a = PSXRecompV4::load_user_settings(p); + check(!a.has_render_thread && !a.has_present_thread && !a.has_frame_generation, + "pipeline keys absent => unset (game.toml default stands)"); + fs::remove(p); + + p = write_temp("psxrecomp_pipe_read.toml", + "[video]\n" + "render_thread = true\n" + "present_thread = false\n" + "frame_generation = true\n"); + auto u = PSXRecompV4::load_user_settings(p); + check(u.has_render_thread && u.render_thread, "settings render_thread = true parses"); + check(u.has_present_thread && !u.present_thread, "settings present_thread = false parses"); + check(u.has_frame_generation && u.frame_generation, "settings frame_generation = true parses"); + fs::remove(p); + + PSXRecompV4::UserSettings out; + out.has_render_thread = true; out.render_thread = false; + out.has_present_thread = true; out.present_thread = true; + out.has_frame_generation = true; out.frame_generation = true; + p = fs::temp_directory_path() / "psxrecomp_pipe_rt.toml"; + check(PSXRecompV4::save_user_settings(p, out), "save_user_settings writes pipeline keys"); + auto back = PSXRecompV4::load_user_settings(p); + check(back.has_render_thread && !back.render_thread, "render_thread = false round-trips"); + check(back.has_present_thread && back.present_thread, "present_thread round-trips"); + check(back.has_frame_generation && back.frame_generation, "frame_generation round-trips"); + fs::remove(p); + + PSXRecompV4::UserSettings none; + p = fs::temp_directory_path() / "psxrecomp_pipe_none.toml"; + check(PSXRecompV4::save_user_settings(p, none), "save_user_settings writes defaults"); + auto nb = PSXRecompV4::load_user_settings(p); + check(!nb.has_render_thread && !nb.has_present_thread && !nb.has_frame_generation, + "unset pipeline keys are not written"); + fs::remove(p); +} + /* [video] render_thread (docs/RENDER_THREAD.md): opt-in, off by default so * every title keeps the synchronous OpenGL path unless it asks. */ static void test_render_thread() { @@ -432,6 +475,7 @@ int main() { test_render_thread(); test_frame_generation(); test_present_thread(); + test_pipeline_user_settings(); test_pgxp_title_keys(); if (failures) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 97ec2d28..50d36a06 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1461,6 +1461,13 @@ static int g_render_thread_frames = 2; static int g_frame_generation = 0; /* [video] present_thread (docs/RENDER_THREAD.md), with the render thread. */ static int g_present_thread = 0; +/* The player's persisted pipeline choice (game.toml default < settings.toml), + * before any PSX_* env override for this run. The launcher seeds from and + * saves to these so a one-run env A/B is never written to settings.toml. + * The pipeline starts once at boot: a change applies at next launch. */ +static int g_render_thread_pref = 0; +static int g_present_thread_pref = 0; +static int g_frame_generation_pref = 0; static int g_present_thread_slots = 3; static int s_render_thread_tried = 0; static int g_video_vsync = 1; @@ -15123,6 +15130,9 @@ namespace { #endif #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) gi->has_dynamic_resolution = 1; +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + gi->has_render_pipeline = 1; /* OpenGL rows; the launcher gates */ #endif if (language_labels && num_languages > 0) { gi->language_labels = language_labels; @@ -16305,6 +16315,9 @@ int main(int argc, char** argv) { } apply_offline_pad_count(game_players, multitap_enabled); if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; + if (us.has_render_thread) g_render_thread = us.render_thread ? 1 : 0; + if (us.has_present_thread) g_present_thread = us.present_thread ? 1 : 0; + if (us.has_frame_generation) g_frame_generation = us.frame_generation ? 1 : 0; if (us.has_vsync) g_video_vsync = us.vsync; if (us.has_frame_interpolation) g_frame_interpolation = us.frame_interpolation ? 1 : 0; @@ -16755,6 +16768,13 @@ int main(int argc, char** argv) { #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) seed.dynamic_resolution = dynres_requested() != 0; seed.has_dynamic_resolution = true; +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + seed.render_thread = g_render_thread != 0; + seed.present_thread = g_present_thread != 0; + seed.frame_generation = g_frame_generation != 0; +#endif +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) seed.dynamic_resolution_min = dynres_min_value(); seed.has_dynamic_resolution_min = true; #endif @@ -16964,6 +16984,11 @@ int main(int argc, char** argv) { #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) ls.dynamic_resolution = seed.dynamic_resolution ? 1 : 0; ls.dynamic_resolution_min = seed.dynamic_resolution_min; +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + ls.render_thread = seed.render_thread ? 1 : 0; + ls.present_thread = seed.present_thread ? 1 : 0; + ls.frame_generation = seed.frame_generation ? 1 : 0; #endif ls.antialiasing = seed.antialiasing ? 1 : 0; ls.texture_filter = seed.texture_filter; @@ -17310,6 +17335,11 @@ int main(int argc, char** argv) { seed.has_dynamic_resolution = true; seed.dynamic_resolution_min = ls.dynamic_resolution_min; seed.has_dynamic_resolution_min = true; +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + seed.render_thread = ls.render_thread != 0; seed.has_render_thread = true; + seed.present_thread = ls.present_thread != 0; seed.has_present_thread = true; + seed.frame_generation = ls.frame_generation != 0; seed.has_frame_generation = true; #endif seed.antialiasing = ls.antialiasing != 0; seed.has_antialiasing = true; seed.geometry_correction = ls.geometry_correction != 0; @@ -17570,6 +17600,12 @@ int main(int argc, char** argv) { #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) g_video_dynres = seed.dynamic_resolution ? 1 : 0; g_video_dynres_min = seed.dynamic_resolution_min; +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + /* First boot: the pipeline has not started yet. */ + g_render_thread = seed.render_thread ? 1 : 0; + g_present_thread = seed.present_thread ? 1 : 0; + g_frame_generation = seed.frame_generation ? 1 : 0; #endif g_video_aa = seed.antialiasing; g_video_texfilter = seed.texture_filter; @@ -18038,6 +18074,9 @@ session_reboot: gl_renderer_set_texture_window_batching((*e && *e != '0') ? 1 : 0); /* [video] render_thread A/B; PSX_RENDER_THREAD_FRAMES bounds frames in * flight (default 2). */ + g_render_thread_pref = g_render_thread; + g_present_thread_pref = g_present_thread; + g_frame_generation_pref = g_frame_generation; if (const char* e = std::getenv("PSX_RENDER_THREAD")) g_render_thread = (*e && *e != '0') ? 1 : 0; if (const char* e = std::getenv("PSX_PRESENT_THREAD")) @@ -19308,6 +19347,12 @@ soft_return_lobby: #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) ls.dynamic_resolution = dynres_requested() != 0; ls.dynamic_resolution_min = dynres_min_value(); +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + /* The saved choice, not this run's (env A/B or already running). */ + ls.render_thread = g_render_thread_pref; + ls.present_thread = g_present_thread_pref; + ls.frame_generation = g_frame_generation_pref; #endif ls.antialiasing = g_video_aa ? 1 : 0; ls.texture_filter = g_video_texfilter; @@ -19689,6 +19734,16 @@ soft_return_lobby: us.has_dynamic_resolution_min = true; g_video_dynres = us.dynamic_resolution ? 1 : 0; g_video_dynres_min = us.dynamic_resolution_min; +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + /* Persisted only: the running pipeline keeps its threads; + * the new choice applies at next launch. */ + us.render_thread = ls.render_thread != 0; us.has_render_thread = true; + us.present_thread = ls.present_thread != 0; us.has_present_thread = true; + us.frame_generation = ls.frame_generation != 0; us.has_frame_generation = true; + g_render_thread_pref = ls.render_thread ? 1 : 0; + g_present_thread_pref = ls.present_thread ? 1 : 0; + g_frame_generation_pref = ls.frame_generation ? 1 : 0; #endif us.antialiasing = ls.antialiasing != 0; us.has_antialiasing = true; From ae4f45f2c33ac44030d11bfbd86afc89c70bf97c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:04:28 -0700 Subject: [PATCH 56/57] Call frame generation "Smooth motion" in user-visible text Log lines and doc headings say "Smooth motion (frame generation)", the player-facing name. Config key [video] frame_generation and PSX_FRAME_GEN are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_GENERATION.md | 2 +- docs/RENDER_THREAD.md | 4 ++-- runtime/src/debug_server.c | 2 +- runtime/src/main.cpp | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/FRAME_GENERATION.md b/docs/FRAME_GENERATION.md index 054fe14d..1af3eaf4 100644 --- a/docs/FRAME_GENERATION.md +++ b/docs/FRAME_GENERATION.md @@ -1,4 +1,4 @@ -# Frame generation (`[video] frame_generation`) +# Smooth motion (frame generation, `[video] frame_generation`) Opt-in, OpenGL with the render thread only, off by default (`PSX_FRAME_GEN=0/1` overrides for one run). Off, nothing in this document diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 6f0228ec..8bb91625 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -15,7 +15,7 @@ render_thread = true # game.toml; PSX_RENDER_THREAD=0/1 overrides are also read from the player's `settings.toml` `[video]`, which wins over the game.toml default; the `PSX_*` env vars override both for one run and are never saved. The launcher shows them as Settings → Display rows (Render -thread, Present thread, Frame generation; recomp-ui +thread, Present thread, Smooth motion (frame generation); recomp-ui `RECOMP_LAUNCHER_HAS_RENDER_PIPELINE`, OpenGL only, the latter two disabled while Render thread is off). The pipeline starts once at boot, so a change from the in-game launcher is saved and applies at next launch. @@ -176,7 +176,7 @@ resolution (below), not of this layer. | render passes | first `gl_renderer_pass_*` call in a frame is a sync point; the rest of that frame is synchronous. Recommended: leave `render_thread` off with the frame-rate mod | | frame interpolation | ineligible (held) while enabled; not started when it is on at boot | | OpenXR | ineligible while a session is active | -| frame generation | `[video] frame_generation`: in-between frames drawn by the render thread from recorded lists, docs/FRAME_GENERATION.md | +| Smooth motion (frame generation) | `[video] frame_generation`: in-between frames drawn by the render thread from recorded lists, docs/FRAME_GENERATION.md | | native-wide / widescreen | recorded; tags, latch and wide-surface mirror as above | | internal resolution changes | `gr_set_scale` and every resolution entry point are sync points; dynamic-resolution level steps are recorded (below) | | screenshots / debug captures | `screenshot*` sync; `present_shot` is fulfilled on the render thread | diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 9481d3bc..36aa0b17 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -10424,7 +10424,7 @@ static void handle_render_thread(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf[0] ? buf : "\"active\":0"); } -/* Frame generation ([video] frame_generation, docs/FRAME_GENERATION.md): +/* Smooth motion (frame generation; [video] frame_generation, docs/FRAME_GENERATION.md): * {"cmd":"frame_gen"} -> enabled, active, generated / real presents, flips, * the last plan (in-between frames per game frame, display slots), costs, * match counts, breaker. Not a sync point (render-thread counters, racy). */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 50d36a06..f1dec917 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9236,7 +9236,7 @@ static void render_thread_vblank(void) { g_render_thread_frames); if (g_frame_generation) { gl_renderer_set_frame_generation(1); - std::fprintf(stdout, "psxrecomp: frame generation on (render thread, " + std::fprintf(stdout, "psxrecomp: Smooth motion (frame generation) on (render thread, " "from surplus only)\n"); } } else { @@ -9244,7 +9244,7 @@ static void render_thread_vblank(void) { "(needs the OpenGL backend without netplay, frame " "interpolation or a 24-bit display)\n"); if (g_frame_generation) - std::fprintf(stdout, "psxrecomp: frame generation needs the render thread; off\n"); + std::fprintf(stdout, "psxrecomp: Smooth motion (frame generation) needs the render thread; off\n"); } std::fflush(stdout); } From d29390a85d35ac8535ab945e571bd3801ddc629d Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:54:07 -0700 Subject: [PATCH 57/57] Regenerate the OpenBIOS emitter stamp Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index e9b2f22a..7c60ff2d 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -7452d9baed94969d54e5ea4f59d742e4f51fe519d783a83ba0fb92565d1ccc34 +70c9cd6d1290def8933f248f03e598702add2ed97235498f4d6215149afc4f93