Honor HOLD when presenting native replay generations

This commit is contained in:
Matthew Stanley
2026-10-06 18:36:52 -07:00
parent 711c1d933b
commit b0bb115dd0
8 changed files with 77 additions and 9 deletions
+14
View File
@@ -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.
+3
View File
@@ -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
+2 -2
View File
@@ -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++;
+7
View File
@@ -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;
+24
View File
@@ -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();
+2 -2
View File
@@ -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(")
+14
View File
@@ -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();
+11 -5
View File
@@ -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)]