Files
psxrecomp/runtime/tests/test_render_pass_guards.py
T
Shokunin 4bb58c2a81 Render passes: measure a stale pass-cost estimate again
Only passes that run are measured. The first passes of a race can all run in
a transient (a busy host, code run for the first time): in earlier Grand
Prix runs the three warm-up passes measured 13-15 ms against a steady 3-7 ms
at the same setting. An estimate that high prices most plans out, no pass
runs to correct it, and a plugin that crossfades while passes are shed (R4
Frame Rate) stayed on the crossfade for most or all of the run: 4, 9 and 29
passes in 22 s at 300 Hz, 7 in a verify run at 120 Hz, against about 1400
when the first passes were fast.

render_pass_cost_note_plan() (render_pass_plan.c) counts the plans that
wanted passes since the last measured pass. After 30 (about a second of a
30 Hz game) the warm-up restarts, so plans ask for one pass until three have
been measured again. When the new median is more than 3/4 of the old
estimate, the old one was right (a size that is truly too expensive, or a
host at its limit) and the next wait doubles, up to 960 plans, so these
one-pass warm-ups stay rare; a re-measure that finds the estimate stale
keeps the wait at 30. render_pass_stats reports cost_rewarms.

render_pass_plan_test covers the restart after 30 plans, an estimate that
passes keep measuring never restarting, the minimum wait after a stale
estimate, and the doubling waits when re-measures confirm the estimate
(47 ms at 4K, 10 ms re-measured as 9); render_pass_guards pins the planner's
call and its one-pass re-plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:19:08 -07:00

207 lines
11 KiB
Python

#!/usr/bin/env python3
"""Pin the render-pass sandbox (docs/RENDER_PASSES.md) at its choke points.
A render pass runs guest draw code in frozen time and restores the machine
afterwards. That only holds if every path that could let time pass, deliver an
interrupt, touch a device the restore does not cover, or record the pass into
the live timeline checks g_psx_render_pass_active first. These are the choke
points; each guard below names the hole it closes.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "runtime" / "src"
def body(text, signature):
"""Return the first ~40 lines of the function whose definition starts with
`signature` (enough to see its early-return gates)."""
i = text.index(signature)
return "\n".join(text[i:].splitlines()[:40])
cycles = (SRC / "psx_cycles.c").read_text(encoding="utf-8")
b = body(cycles, "void psx_devices_service_to_now(void) {")
assert re.search(r"if \(g_psx_render_pass_active\)[^\n]*return;", b), (
"device servicing must stop during a pass (time would advance)")
b = body(cycles, "void psx_devices_mmio_sync(void) {")
assert "g_psx_render_pass_active" in b, (
"MMIO sync must not catch devices up during a pass")
b = body(cycles, "void psx_advance_cycles_slow(uint32_t cycles) {")
assert "g_psx_render_pass_active" in b, (
"the slow/conservative cycle path must not advance devices during a pass")
irq = (SRC / "interrupts.c").read_text(encoding="utf-8")
for sig in ("int psx_interrupt_delivery_needed(const CPUState* cpu) {",
"void psx_check_interrupts(CPUState* cpu) {"):
lines = body(irq, sig).splitlines()[:6]
assert any("g_psx_render_pass_active" in l and "return" in l for l in lines), (
"no interrupt may be delivered inside a pass: " + sig)
dma = (SRC / "dma.c").read_text(encoding="utf-8")
assert "while (g_psx_render_pass_active && gpu_linked_list.active)" in dma, (
"GPU linked-list DMA must complete synchronously in a pass")
b = body(dma, "static void schedule_delayed_complete(int ch, uint32_t total_words,")
assert "g_psx_render_pass_active" in b, (
"delayed DMA completion would never arrive in frozen time")
mem = (SRC / "memory.c").read_text(encoding="utf-8")
for fn in ("void psx_write_word(uint32_t addr, uint32_t val) {",
"void psx_write_half(uint32_t addr, uint16_t val) {",
"void psx_write_byte(uint32_t addr, uint8_t val) {"):
lines = body(mem, fn).splitlines()[:6]
assert any("render_pass_store" in l for l in lines), (
"pass stores must bypass live-timeline observers: " + fn)
b = body(mem, "static void render_pass_store(")
assert "render_pass_store_to(&t, addr, val, width)" in b and \
"g_render_pass_dropped_writes[cls]++" in b, (
"pass stores must go through the tested store policy "
"(render_pass_store_to, render_pass_sandbox_test) and count drops")
assert "t.ram_size = psx_ram_live_bytes();" in b, (
"pass stores must fold RAM through the LIVE 2/8 MiB geometry, as "
"psx_ram_map_write does (not the 8 MiB backing size)")
rp = (SRC / "render_pass.c").read_text(encoding="utf-8")
assert "uint32_t ram_bytes = memory_get_ram_bytes();" in body(
rp, "static int checkpoint_save(") and \
"memcpy(memory_get_ram_ptr(), s_ram_copy, s_ck.ram_bytes);" in body(
rp, "static void checkpoint_restore(") and \
"memory_get_ram_bytes()" in body(rp, "static uint64_t state_hash("), (
"the pass checkpoint, restore and verify hash must cover the live RAM "
"size (8 MiB with the 8 MB RAM mod)")
plan = (SRC / "render_pass_plan.c").read_text(encoding="utf-8")
assert "render_pass_mmio_class(phys, val, width)" in body(
plan, "int render_pass_store_to("), (
"pass MMIO stores must go through the MMIO allow-list")
gl = (SRC / "gpu_gl_renderer.c").read_text(encoding="utf-8")
assert "render_pass_vram_policy(&s_pj_cpu" in body(
gl, "static int pass_refuse_write("), (
"out-of-rect VRAM writes must go through the tested journal policy")
assert "render_pass_journal_rollback(&s_pj_cpu" in body(
gl, "static void pass_journal_rollback("), (
"the journal rollback must restore the CPU VRAM rows")
# The journal backs up the hr surface, the raw mirror and the CPU rows. That
# is complete in native-wide too only while the out-of-rect write paths never
# touch a wide surface; R4 copies 2x1 pixels outside the display every frame,
# so refusing them under native-wide rolled back every widescreen pass.
def definition(text, name):
"""The whole body of function `name` (its definition, not a prototype)."""
m = re.search(r"^static [\w ]+\b" + name + r"\([^;{]*\)\s*\{", text, re.M)
assert m, "no definition of " + name
depth, i = 0, m.end() - 1
while True:
depth += {"{": 1, "}": -1}.get(text[i], 0)
if depth == 0:
return text[m.start():i + 1]
i += 1
assert "g_wide" not in definition(gl, "pass_journal_protect"), (
"out-of-rect writes are journaled in native-wide as well")
for name in ("gpu_fill", "gpu_copy_rect", "flush_cpu_upload",
"glb_vram_transfer_in", "glb_vram_write"):
assert "wide" not in definition(gl, name), (
"journaled writes must not reach a native-wide surface: " + name)
# One-time pass allocations are not cost samples (render_pass_plan_test):
# every path that makes a pass texture or framebuffer must count it, and the
# cost average must go through the tested sampling rule.
for name in ("pass_make_color_fbo", "pass_gen_reserve"):
assert "s_pass_allocs++" in definition(gl, name), (
"pass allocations must be counted: " + name)
assert "render_pass_cost_add(" in body(
gl, "void gl_renderer_pass_note_cost(uint64_t ticks) {"), (
"the pass-cost average must leave allocating passes out")
assert "s_pass_allocs_begin = s_pass_allocs;" in body(
gl, "int gl_renderer_pass_begin("), (
"each pass must mark where its allocations start")
# The average belongs to one presented image size. A plan at another size
# passes an unknown cost (0), for which render_pass_plan_phases plans a single
# measuring pass (render_pass_plan_test): passes run on the emulation thread.
nc = body(gl, "void gl_renderer_pass_note_cost(uint64_t ticks) {")
assert "s_pass_cost_w != s_interp_w || s_pass_cost_h != s_interp_h" in nc and \
"memset(&s_pass_cost, 0, sizeof s_pass_cost);" in nc, (
"a new image size must start the pass-cost average over")
assert re.search(r"in\.pass_cost = \(s_pass_cost_w == s_interp_w && "
r"s_pass_cost_h == s_interp_h\)\s*"
r"\? render_pass_cost_estimate\(&s_pass_cost\) : 0\.0;",
gl[gl.index("uint32_t gl_renderer_pass_plan("):][:4000]), (
"a plan must not use a cost measured at another image size")
# Only passes that run are measured: an estimate that prices every plan out
# (measured in a transient) must be measured again, or passes stay shed and a
# plugin's crossfade fallback never ends (render_pass_plan_test).
gp = gl[gl.index("uint32_t gl_renderer_pass_plan("):]
gp = gp[:gp.index("\n}\n")]
assert re.search(r"if \(want && s_pass_cost_w == s_interp_w && "
r"s_pass_cost_h == s_interp_h &&\s*"
r"render_pass_cost_note_plan\(&s_pass_cost\)\) \{", gp) and \
re.search(r"in\.pass_cost = 0\.0;\s*n = render_pass_plan_phases\(&in, alpha_q16, NULL\);",
gp), (
"every plan that wants passes must let a stale estimate be re-measured, "
"and the re-measuring plan must ask for one pass")
# 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_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(")
for dev in ("RENDER_PASS_DROP_SPU", "RENDER_PASS_DROP_CD",
"RENDER_PASS_DROP_TIMER"):
assert dev in cls, "a pass must never reach " + dev
dbg = (SRC / "debug_server.c").read_text(encoding="utf-8")
for fn in ("void debug_server_trace_write_check(",
"void debug_server_trace_mmio_write("):
assert "g_psx_render_pass_active" in body(dbg, fn), (
"rolled-back pass writes must stay out of the frame fingerprints: " + fn)
rp = (SRC / "render_pass.c").read_text(encoding="utf-8")
for gate in ("psx_netplay_active()", "psx_rewind_is_open()",
"psx_selfcheck_resim_active()", "psx_get_in_exception()",
"dma_gpu_linked_list_active()",
"psx_presentation_fast_forward()"):
assert gate in rp, "passes must refuse to run when " + gate
main = (SRC / "main.cpp").read_text(encoding="utf-8")
ff = main[main.index("const int present_every ="):]
ff = ff[:ff.index("turbo_skip = (turbo_skip + 1) % present_every;")]
assert "s_presentation_fast_forward = 1;" in ff, (
"manual fast-forward must refuse render passes (it presents every 2nd "
"VBlank, so the plan would otherwise still see presents)")
b = body(rp, "static void checkpoint_restore(CPUState *cpu) {")
assert "nesting_restore(&s_ck.nest);" in b, (
"a watchdog abort longjmps past the exits of the frames it leaves: the "
"restore must put the host nesting back (render_pass_abort_test)")
gl = (SRC / "gpu_gl_renderer.c").read_text(encoding="utf-8")
b = body(gl, "static int pass_gen_reserve(int gi, uint32_t need, int w, int h) {")
assert "need > pass_slot_cap(w, h)" in b, (
"pass image textures must stay inside the slot cap's memory budget")
assert "pass_gen_reserve(gi, g->n + 1u, g->tex_w, g->tex_h)" in gl, (
"pass image textures are made as slots fill, not all PASS_SLOTS up front")
assert "for (uint32_t i = 0; i < PASS_SLOTS; i++) {\n if (!s_pgen_tex" not in gl, (
"no eager allocation of every slot texture")
assert "pass_resources_release();" in body(gl, "void gl_renderer_shutdown(void) {"), (
"pass textures die with the context: forget them at shutdown")
# Default off means no cost for other titles: the headers folded into the
# overlay codegen hash (runtime/codegen_hash_sources.cmake) must not carry the
# render-pass API, or every title's overlay cache and savestates would be
# invalidated by a feature they never use. The freeze lives in the
# runtime-only psx_cycle_freeze.h.
INC = ROOT / "runtime" / "include"
hash_list = (ROOT / "runtime" / "codegen_hash_sources.cmake").read_text(
encoding="utf-8")
for header in re.findall(r"runtime/include/([\w.]+)", hash_list):
text = (INC / header).read_text(encoding="utf-8")
for name in ("g_psx_render_pass_active", "PsxCycleFreeze",
"psx_cycle_freeze", "render_pass"):
assert name not in text, (
f"{header} is in the codegen hash and must not mention {name}")
assert "psx_cycle_freeze.h" not in hash_list, (
"psx_cycle_freeze.h is runtime-only; keep it out of the codegen hash")
print("render-pass sandbox guards passed")