From b0bb115dd0b7fcfda42cb17ae5fa32a482b28ab9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 18:36:52 -0700 Subject: [PATCH] Honor HOLD when presenting native replay generations --- docs/PARITY_CAMPAIGN.md | 14 ++++++++++++++ runtime/include/render_pass_plan.h | 3 +++ runtime/src/gpu_gl_renderer.c | 4 ++-- runtime/src/render_pass_plan.c | 7 +++++++ runtime/tests/test_gl_texture_filter.c | 24 ++++++++++++++++++++++++ runtime/tests/test_render_pass_guards.py | 4 ++-- runtime/tests/test_render_pass_plan.c | 14 ++++++++++++++ tools/qualify_render_passes.py | 16 +++++++++++----- 8 files changed, 77 insertions(+), 9 deletions(-) diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index 5bff7a0f..ec56fe48 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -108,3 +108,17 @@ hash and its execution manifest. Zero replay, image blending, disabled replay, counter resets or verification failures cannot produce a passing sample. This is one evidence window; visual coverage, load speed and owner acceptance remain separate checks. Use `tools/load_probe.py` for passive load-window measurements. + +Run correctness and performance windows separately. `PSX_RENDER_PASS_VERIFY=1` +adds synchronous GPU readbacks plus state hashing. After recording correctness, +relaunch without it and use `--purpose performance`. The tool rejects a +performance sample with active verification. Spider-Man 2's initial verified +replay took 28-35 ms; verification off measured 19.531 ms, of which 19.176 ms +was guest draw. That evidence points to title geometry processing rather than +shared GPU checkpoint/capture as the remaining throughput bottleneck. + +The shared presenter now honors HOLD for native phase generations instead of +crossfading neighboring phase images. Phase selection tests and the real OpenGL +probe (329 checks) pass, including red/blue native images that must stay distinct +under HOLD while explicit blend mode still mixes them. Title requalification +on this correction remains required. diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index 8d2d4c98..fea817a2 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -64,6 +64,9 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, * game that stops flipping for several frame lengths gets that back. */ int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, uint32_t *lo, uint32_t *hi, float *t); +/* HOLD presents one native phase image without a temporal crossfade. */ +int render_pass_gen_select_mode(const uint32_t *phases, uint32_t n, double p, + int hold, uint32_t *lo, uint32_t *hi, float *t); /* Is the flip the FLIP source just saw the one a pending generation waits * for? A generation built for the next flip (`shown` 0, the canonical PsyQ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 09fca3e0..6f6fa2b5 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -7052,13 +7052,13 @@ static int pass_gen_present(uint64_t deadline) { /* Past phase 1 the next flip is late (a lagging tick): the newest image * holds until it comes. Only a game that stops flipping for several * frame lengths expires the generation. */ - if (!render_pass_gen_select(g->phase, g->n, p, &lo, &hi, &t)) { + if (!render_pass_gen_select_mode(g->phase, g->n, p, s_interp_hold, &lo, &hi, &t)) { g->valid = 0; s_pgen_expired++; return 0; } if (!interp_present_pair(s_pgen_tex[s_pgen_cur][lo], - s_pgen_tex[s_pgen_cur][hi], t, 0)) + s_pgen_tex[s_pgen_cur][hi], t, s_interp_blend_mode)) return 0; s_pgen_presents++; if (lo != hi) s_pgen_blends++; diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index e235f832..40fd6b14 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -127,6 +127,13 @@ int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, return render_pass_select(phases, n, p, lo, hi, t); } +int render_pass_gen_select_mode(const uint32_t *phases, uint32_t n, double p, + int hold, uint32_t *lo, uint32_t *hi, float *t) { + if (!render_pass_gen_select(phases, n, p, lo, hi, t)) return 0; + if (hold) { *hi = *lo; *t = 0.0f; } + return 1; +} + double render_pass_ema(double current, double sample) { if (!(sample >= 0.0) || !isfinite(sample)) return current; if (!(current > 0.0)) return sample; diff --git a/runtime/tests/test_gl_texture_filter.c b/runtime/tests/test_gl_texture_filter.c index b0aa8ea5..9906e99a 100644 --- a/runtime/tests/test_gl_texture_filter.c +++ b/runtime/tests/test_gl_texture_filter.c @@ -73,6 +73,29 @@ static void atlas_edge(int depth) { check(gl_renderer_fbo_peek(0,0,64,64,peek),"atlas-edge readback"); check((peek[21*64+21]&0x7fff)==0x7c00,"footprint clamps before wrapping into far atlas row"); } +static void native_hold_images(void) { + GLuint textures[2]; + const uint8_t colors[2][4] = {{255,0,0,255},{0,0,255,255}}; + const uint32_t phases[2] = {0,32768}; + glGenTextures(2,textures); + for(int i=0;i<2;++i) { + glBindTexture(GL_TEXTURE_2D,textures[i]); + glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,1,1,0,GL_RGBA,GL_UNSIGNED_BYTE,colors[i]); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE); + } + for(int hold=0;hold<2;++hold) { + uint32_t lo,hi; float blend; uint8_t pixel[4]; + check(render_pass_gen_select_mode(phases,2,0.25,hold,&lo,&hi,&blend),"native phase selection"); + glDisable(GL_SCISSOR_TEST); + interp_draw_textures(textures[lo],textures[hi],blend,0,0,0,8,8); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,0); + glReadPixels(4,4,1,1,GL_RGBA,GL_UNSIGNED_BYTE,pixel); + if(hold) check(pixel[0]>250 && pixel[2]<5,"HOLD presents original native red image without ghosting"); + else check(pixel[0]>60 && pixel[2]>60,"explicit blend still mixes red and blue images"); + } + glDeleteTextures(2,textures); +} int main(void) { if(SDL_Init(SDL_INIT_VIDEO)!=0) return 2; SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION,3); @@ -82,6 +105,7 @@ int main(void) { if(!win) return 2; glb_init(vram); glb_set_scale(1); gl_renderer_set_swap_interval(0); if(!gl_renderer_init_context(win)) return 2; + native_hold_images(); for(int depth=0;depth<3;++depth) { upload_checker(depth,0); triangle(0,depth,0,1); int nearest=chroma(); diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index d8285fa5..25464ec9 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -147,8 +147,8 @@ assert re.search(r"if \(want && s_pass_cost_w == s_interp_w && " # A frame on screen longer than planned (a lagging tick) must hold its newest # pass image, never fall back to the older capture (render_pass_plan_test). pgp = definition(gl, "pass_gen_present") -assert "render_pass_gen_select(g->phase, g->n, p, &lo, &hi, &t)" in pgp, ( - "pass images must be selected through the tested late-flip rule") +assert "render_pass_gen_select_mode(g->phase, g->n, p, s_interp_hold, &lo, &hi, &t)" in pgp, ( + "pass images must honor HOLD through the tested late-flip selection rule") assert "render_pass_select(" not in pgp and "0.5 / (double)g->period" not in pgp, ( "no early expiry of a late frame's images") cls = body(plan, "int render_pass_mmio_class(") diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c index 1c1edaf8..22ac3d86 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -327,6 +327,20 @@ static void test_stereo_pair_fresh(void) { } int main(void) { + { + const uint32_t phases[] = {0, 16384, 32768, 65536}; + uint32_t lo, hi; float blend; + CHECK(render_pass_gen_select_mode(phases, 4, 0.375, 1, &lo, &hi, &blend) && + lo == 1 && hi == 1 && blend == 0.0f, "HOLD keeps a native sample between phases"); + CHECK(render_pass_gen_select_mode(phases, 4, 0.5, 1, &lo, &hi, &blend) && + lo == 2 && hi == 2 && blend == 0.0f, "HOLD advances at the next native phase"); + CHECK(render_pass_gen_select_mode(phases, 4, 0.375, 0, &lo, &hi, &blend) && + lo == 1 && hi == 2 && blend == 0.5f, "explicit blending remains supported"); + CHECK(render_pass_gen_select_mode(phases, 4, 2.0, 1, &lo, &hi, &blend) && + lo == 3 && hi == 3, "late HOLD uses newest native image"); + CHECK(!render_pass_gen_select_mode(phases, 4, 5.0, 1, &lo, &hi, &blend), + "HOLD cannot revive an expired generation"); + } test_stereo_pair_fresh(); test_store_policy(); test_counts_per_rate(); diff --git a/tools/qualify_render_passes.py b/tools/qualify_render_passes.py index a27ce0ff..805b5314 100644 --- a/tools/qualify_render_passes.py +++ b/tools/qualify_render_passes.py @@ -3,7 +3,9 @@ This observes an already running debug build. It never boots, drives, pauses or changes settings. A passing sample is one gameplay window, not title acceptance. -Run with PSX_RENDER_PASS_VERIFY=1 to check that replay restores guest state. +Use correctness with PSX_RENDER_PASS_VERIFY=1 to check guest-state restoration. +For performance, relaunch without verification and select --purpose performance; +synchronous verification readbacks and hashing are not normal-play costs. """ import argparse import hashlib @@ -19,7 +21,7 @@ COUNTERS = ("passes", "pass_presents", "verify_checks", "verify_mismatch", "aborted", "blended_presents") -def assess(before, after): +def assess(before, after, purpose="correctness"): reasons, delta = [], {} for key in COUNTERS: a, b = before.get(key), after.get(key) @@ -27,7 +29,8 @@ def assess(before, after): reasons.append("missing, invalid or reset counter: " + key) else: delta[key] = b - a - for key in ("passes", "pass_presents", "verify_checks"): + required = ("passes", "pass_presents", "verify_checks") if purpose == "correctness" else ("passes", "pass_presents") + for key in required: if key in delta and not delta[key]: reasons.append("no evidence during this window: " + key) for key in ("verify_mismatch", "vram_leaks", "watchdog", "nesting_repairs", @@ -44,6 +47,7 @@ def main(): parser.add_argument("--port", type=int, required=True) parser.add_argument("--case", required=True, help="actual gameplay path being observed") parser.add_argument("--seconds", type=float, default=10) + parser.add_argument("--purpose", choices=("correctness", "performance"), default="correctness") parser.add_argument("--executable", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() @@ -57,7 +61,7 @@ def main(): receipt = dict(schema=1, case=args.case, executable=str(executable), executable_sha256=binary_sha, execution=manifest, started_utc=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - port=args.port, sample_passed=False) + port=args.port, purpose=args.purpose, sample_passed=False) def query(): with connect(port=args.port) as sock: @@ -72,7 +76,9 @@ def main(): time.sleep(args.seconds) receipt["after"] = query() receipt["elapsed_seconds"] = time.monotonic() - start - receipt["delta"], receipt["reasons"] = assess(receipt["before"], receipt["after"]) + receipt["delta"], receipt["reasons"] = assess(receipt["before"], receipt["after"], args.purpose) + if args.purpose == "performance" and receipt["delta"].get("verify_checks", 0): + receipt["reasons"].append("performance sample includes verification readbacks and hashing") receipt["sample_passed"] = not receipt["reasons"] except (OSError, ValueError, RuntimeError) as error: receipt["reasons"] = [str(error)]