From 28e12c737252036ac406263f230b7934f2fe9afa Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 14 Oct 2026 14:25:24 -0700 Subject: [PATCH] fix(gpu): retain wide composites when internal resolution changes --- runtime/src/gpu_gl_renderer.c | 7 +- runtime/tests/run_gl_scale_invariance.py | 26 +++++- runtime/tests/test_gl_scale_invariance.c | 112 ++++++++++++++++++++++- 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index f038644e..b6026e34 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -10691,12 +10691,13 @@ static int dyn_apply(int snew) { 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. */ + * the centre is canonical it is spliced from hr at present, so only the + * margins need preserving. Explicit backgrounds and shifted views own + * their centre pixels too; preserve their full composite. */ 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) { + if (wide_center_is_canonical() && 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; diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index d0e055c7..b0e01e2b 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -40,7 +40,8 @@ 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. +The window mode refuses steps. A held full-composite case checks both buffer +banks through 3x -> 5x -> 3x, including their centre pixels before any redraw. 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 @@ -100,6 +101,8 @@ def main(): ap.add_argument("--fixture", type=pathlib.Path) ap.add_argument("--scales", default="1,2,3,5,9") ap.add_argument("--window-scales", default="2,3,5,9,18") + ap.add_argument("--steps-only", action="store_true", + help="Run only canonical and held full-composite 3x/5x scale-step checks") args = ap.parse_args() # ';'-separated when CMake hands over a target's include list. sdl_includes = [str(pathlib.Path(d).resolve()) for d in args.sdl_include.split(";") if d] @@ -188,6 +191,21 @@ def main(): print("SKIP:", r.stderr.strip()[-600:]) return SKIP_EXIT + if args.steps_only: + env = os.environ.copy() + env["PSX_GL_HIRES_WINDOW"] = "0" + ok = r.returncode == 0 + for case in ([probe, 5, "steps", "3,5,3", "chain"], + [probe, 5, "composite-steps"]): + r = run(case, env=env) + parsed = parse_run(r.stdout) + print(f"{case[2]}: exit={r.returncode}", r.stdout.strip().splitlines()[-8:], + r.stderr.strip()[-1200:]) + if r.returncode or not parsed or parsed[1]: + ok = False + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + ok = True digests = {} hires_full = {} @@ -399,6 +417,12 @@ def main(): print(f"FAIL {label}: frame/wide surface differ from the fixed {final}x run:", got, hires_full.get(final)) ok = False + r = run([probe, 5, "composite-steps"], env=env) + parsed = parse_run(r.stdout) + print(f"held full composite 3x -> 5x -> 3x: exit={r.returncode}", + r.stdout.strip().splitlines()[-6:], r.stderr.strip()[-600:]) + if r.returncode or not parsed or parsed[1]: + ok = False if not digests_agree(digests): print("FAIL native VRAM digest differs across steps:", digests) ok = False diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 11f81fe2..e89d304c 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -76,6 +76,9 @@ * 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. + * - mode "composite-steps": explicitly tagged backgrounds own the centre + * of native-wide too. Two held buffer composites must survive 3x -> 5x -> + * 3x as the nearest rescale of all their pixels, before any redraw. * Original source-owned scene; no retail payload. */ #include "gpu_gl_renderer.c" #include "gpu_hd_texture_stubs.inc" @@ -103,7 +106,34 @@ 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;} int gpu_ws_nw_flat_backdrop_enabled(void){return 0;} -int gpu_ws_background_requires_full_composite(void){return 0;} +static int test_background_full_composite; +int gpu_ws_background_requires_full_composite(void){return test_background_full_composite;} +/* Fixtures embedding this source rename main and supply their own frontend / + * HD adapters. The standalone scale fixture has no overlays or external pack. */ +#ifndef main +int host_osd_needs_present(void){return 0;} +int psx_present_vsync_owns_cadence(void){return 0;} +void latency_ring_mark(LatencyStage stage){(void)stage;} +void psx_host_sleep_ms(unsigned ms){SDL_Delay(ms);} +void psx_host_sleep_micros(unsigned us){SDL_Delay((us + 999u) / 1000u);} +int present_shot_take(char *out,int n){(void)out;(void)n;return 0;} +void present_shot_done(int ok){(void)ok;} +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){return host_osd_image(p,w,h);} +void host_osd_present_done(void){} +int psx_rewind_overlay_image(const uint32_t **p,int *w,int *h){return host_osd_image(p,w,h);} +float psx_rewind_slide(void){return 0.0f;} +int psx_savestate_menu_overlay_image(const uint32_t **p,int *w,int *h){return host_osd_image(p,w,h);} +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_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){} +void gpu_hd_textures_invalidate(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +#endif void gpu_timeline_note(uint8_t kind,uint32_t a,uint32_t b){(void)kind;(void)a;(void)b;} int g_ws_tex_edge_pct=0; int psx_ws_prim_is_tagged(void){return 0;} @@ -974,10 +1004,10 @@ static uint64_t steps_hires_digest(int x, int y, int w, int h) { return d; } -static uint64_t steps_wide_digest(uint32_t **keep) { +static uint64_t steps_wide_digest_for(int base, 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; + int got = wb ? glb_wide_dump_full(wb, ww * wh, &gw, &gh, base) : 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); @@ -985,6 +1015,74 @@ static uint64_t steps_wide_digest(uint32_t **keep) { return d; } +static uint64_t steps_wide_digest(uint32_t **keep) { + return steps_wide_digest_for(0, keep); +} + +static int composite_steps_main(int ceiling, int window) { + check(!window && ceiling == 5, "full composite fixture uses full VRAM with a 5x ceiling"); + if (window || ceiling != 5) return 1; + check(gl_renderer_step_internal_scale_now(3) && s_out_scale == 3, + "initial 3x level applied before drawing"); + test_background_full_composite = 1; + gl_renderer_set_wide_fast(1); + glb_wide_configure(426, 53); + check(!view_enabled && !wide_center_is_canonical(), + "explicit background owns the centre with fast mirroring and no shifted view"); + glb_set_draw_offset(0, 0); + glb_set_mask_bits(0, 0); + glb_set_semi_transparency(0, 0); + for (int bank = 0; bank < 2; bank++) { + int base = bank * 512; + glb_wide_disable_target(); + glb_set_draw_area(base, 0, base + FRAME_W - 1, FRAME_H - 1); + glb_fill_rect(base, 0, FRAME_W, FRAME_H, 0x03e0); + /* A background composite differs from canonical VRAM in its centre. + * Keep both banks distinct and cover the held rows, including margins. */ + glb_wide_clear(base, 0, 512, bank ? 0x4210 : 0x2d6b); + glb_wide_set_target(base); + glb_draw_gouraud_triangle(base - 40, 20, 0x001f, + base + 360, 50, 0x7c00, + base + 100, 220, 0x03ff); + glb_draw_flat_rect(base + 90, 80, 90, 50, bank ? 0x7c1f : 0x7fff); + } + gl_renderer_sync_cpu(); + uint32_t *before[2] = {NULL, NULL}; + for (int bank = 0; bank < 2; bank++) { + (void)steps_wide_digest_for(bank * 512, &before[bank]); + check(before[bank] && (before[bank][400 * 3 * 426 * 3 + 160 * 3] & 0xFFFFFFu), + "held composite has nonzero centre pixels outside the canonical frame"); + } + const int levels[2] = {5, 3}; + for (int i = 0; i < 2; i++) { + int sold = s_out_scale, snew = levels[i]; + uint64_t native_before = steps_native_digest(0); + check(gl_renderer_step_internal_scale_now(snew) && s_out_scale == snew, + "held full composite scale step applied"); + check(steps_native_digest(0) == native_before, + "held full composite step preserves guest-visible VRAM"); + /* Read both banks before submitting any draw. The dump uses present's + * centre-copy rule, so copying canonical pixels cannot hide a loss. */ + for (int bank = 0; bank < 2; bank++) { + uint32_t *after = NULL; + (void)steps_wide_digest_for(bank * 512, &after); + long bad = before[bank] && after + ? steps_rescale_mismatch(before[bank], sold, after, snew, 426, 512) : -1; + char label[128]; + snprintf(label, sizeof label, "bank %d held full composite %dx -> %dx: every pixel rescaled before redraw", + bank, sold, snew); + check(bad == 0, label); + printf("full composite bank=%d %dx -> %dx mismatch=%ld\n", bank, sold, snew, bad); + free(before[bank]); + before[bank] = after; + } + } + free(before[0]); free(before[1]); + check(glGetError() == GL_NO_ERROR, "full composite scale steps: GL error"); + printf("checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; +} + /* 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. */ @@ -1212,7 +1310,8 @@ 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 (!strcmp(mode, "steps") || !strcmp(mode, "composite-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); @@ -1243,6 +1342,11 @@ int main(int argc, char **argv) { } check(si.effective == scale, "requested scale allocated"); + if (!strcmp(mode, "composite-steps")) { + int rc = composite_steps_main(scale, si.windowed); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return rc; + } if (!strcmp(mode, "steps")) { int rc = steps_main(scale, si.windowed, argc > 3 ? argv[3] : "", argc > 4 && !strcmp(argv[4], "fresh"));