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>
render_pass_plan_phases() treated an unknown pass cost (0) as free, so the
first plans of a process scheduled every phase up to the slot cap. The
passes that allocate textures are left out of the average (up to 8 in a
row), so the first two frames ran at full count, and after an aspect or
resolution change the stale cost of the old size stayed in force. Passes
run on the emulation thread: at 4K (46.9 ms a pass) the first race frames
stalled the guest by several passes each.
- An unknown cost with a budget now plans one pass: it measures the cost
and costs the guest at most one pass per frame while it is learnt.
- The cost is a RenderPassCost (render_pass_plan.c), replacing
render_pass_cost_sample: the same allocation rule, then a warm-up of
three kept samples whose median seeds the average. Until then the
estimate is unknown. With one-pass plans a single slow first pass (a busy
host) would otherwise set the average alone, and a plan that sheds every
pass never measures again.
- The renderer keys the cost to the presented image size: a plan at
another size passes an unknown cost, and the first pass there starts the
cost over.
- RENDER_PASSES.md no longer claims passes never slow the guest, and says
a cost above every frame's budget keeps passes shed until the size
changes.
render_pass_plan_test covers the one-pass plan (none without budget), the
warm-up median (one outlier ignored; a truly expensive size still prices
itself out) and the allocation rule; render_pass_guards pins the size key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A generation's timeline is fixed at plan time (t_len = plan period x
VBlank). When the game frame stayed up longer (a 30 Hz tick that lags to
three VBlanks, or host-interval drift), pass_gen_present expired the
generation past phase 1 + 0.5/period and the presenter fell back to its own
capture, which in FLIP mode is the frame's phase-0 image: the output ran
0 -> .25 -> .5 -> .75, stepped back to 0, then jumped to the next frame.
render_pass_gen_select() (render_pass_plan.c) now keeps render_pass_select's
hold of the newest image past phase 1 and only expires a generation after
RENDER_PASS_GEN_HOLD_MAX (4) frame lengths without a flip, i.e. a game that
stopped flipping. render_pass_stats reports late_presents (presents more
than 1/64 of a frame past its planned end). The plan keeps the plugin's
period: its phases were placed on the output deadlines of a frame of that
length, and the flip tracker's period is the previous frame's, which cannot
predict a sporadic lag.
render_pass_plan_test covers phases 1 to 4 (hold) and past 4 / NaN
(expire); render_pass_guards pins pass_gen_present to the rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream now has one live main-RAM geometry: retail 2 MiB mirrored four
times across the 8 MiB decode window, or 8 MiB unique with the opt-in
8 MB RAM mod (psx_memory.h). memory.c's RAM array is sized for the
largest geometry, so the pass store policy was handed 8 MiB even on a
retail boot, and a store to a mirror (0x80200000..) would have landed in
the unused upper backing bytes instead of folding onto RAM. With the
8 MB map live, the checkpoint copied and restored only the low 2 MiB.
- memory.c passes the store policy psx_ram_live_bytes(), so pass stores
fold exactly as psx_ram_map_write does outside a pass.
- render_pass.c checkpoints, restores and (PSX_RENDER_PASS_VERIFY)
hashes memory_get_ram_bytes() bytes; the copy grows if a later session
boots with the larger map.
- render_pass_sandbox_test: policy and a real pass with 8 MiB live
(unique addresses up to 0x7FFFFF, all 8 MiB restored), then a retail
pass folding mirrors again. Restoring a fixed 2 MiB fails it twice;
folding with the backing size fails it twice. render_pass_guards pins
the live size in memory.c and in the checkpoint, restore and hash.
- psx_ram_runtime_map_test (upstream's 2/8 MiB test over the real
memory.c and dma.c) links those files with section GC. The pass paths
make six more symbols reachable there (the pass flag and store policy,
the synchronous GPU linked-list walk); its stubs double them, trapping
if a pass path ever runs. With the macOS-only link gaps stubbed as well,
the test passes on this branch as on master.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in the R4 integration build at 4K (internal resolution 9x): the first
passes of a session create their image slots and backup framebuffers at
2880x2160 and cost ~70 ms; the steady passes after them cost ~10 ms. The
plan's cost average (seeded by the first sample) then sat near 20 ms, above
the ~14 ms the frame had to spare, so every later plan was shed for time --
and since a shed plan runs no pass, the average was never measured again:
Interpolated drew no in-between frame for the rest of the session.
A pass that made a pass texture or framebuffer (pass_make_color_fbo, a new
pass image slot) is no longer a cost sample: render_pass_cost_sample()
leaves it out, at most RENDER_PASS_ALLOC_SKIPS (8) in a row so the average
can never freeze on a path that allocates every pass. The time still counts
towards the frame's pass-time budget as before.
render_pass_plan_test covers the rule (allocating pass skipped, next steady
pass seeds the average, the skip cap, no counter = every pass counts);
test_render_pass_guards pins that both allocation sites count, that each
pass marks its start, and that the average goes through the rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in the R4 integration build (widescreen + Interpolated): R4 copies
2x1 VRAM pixels at 0,0, outside the pass rect, every frame. The journal
backs such writes up and restores them, but pass_journal_protect() refused
whenever a native-wide surface existed, so with the widescreen view on
every pass was rolled back ("render pass refused copy 0,0 2x1 outside
0,240 320x240", "rolled back (VRAM write outside the pass rect)") and no
in-between frame was ever shown.
The refusal was not needed: fills (gpu_fill), copies (gpu_copy_rect),
uploads (glb_vram_transfer_in / flush_cpu_upload) and pokes
(glb_vram_write) write the hr surface, the raw mirror and the CPU VRAM
rows only -- exactly what the journal backs up -- never a wide surface.
The wide margins come from mirrored draws, which the pass scissor already
keeps inside the pass band.
test_render_pass_guards pins both halves: pass_journal_protect() must not
test g_wide, and none of those five write paths may reference a wide
surface. With the old refusal put back the guard fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store policy and the CPU side of the out-of-rect VRAM journal move into
render_pass_plan.c as pure functions (render_pass_store_to,
render_pass_vram_policy, render_pass_journal_add/_rollback); memory.c and
gpu_gl_renderer.c only wire them to their arrays and GL copies. Behaviour is
unchanged.
render_pass_sandbox_test covers what the plan's U5a list asked for:
- every device class a pass may not reach (SPU key-on and control, CD,
timer, other DMA channels, GP1, SIO, memory control, MDEC, expansion,
KSEG2) is dropped under its class and never delivered, while GP0,
GP1 DMA mode/info, I_STAT/I_MASK, GPU/OTC DMA and DPCR reach the device;
- a real pass (render_pass.c, psx_cycles.c, timers.c) that makes those
stores, acknowledges and masks every interrupt, runs 10^6 cycles while
timer 2 is armed to interrupt every 1000, and changes a timer behind the
store policy's back: drops counted per class, no device advanced, no
interrupt raised, RAM, scratchpad, I_STAT/I_MASK, timers and clock
restored, and the timer interrupts again once time is live;
- fast-forward and backend status refuse the pass without running it;
- VRAM writes inside the rect are allowed, outside ones journaled once
(covered writes not twice, clipped to VRAM) and rolled back exactly,
newest first; a full journal or a mode that cannot journal refuses.
Mutations it catches: SPU or CD mis-classed, device service not frozen,
rollback order or entry skipped, I_STAT, timer, RAM or scratchpad restore
removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Publish an overlay DLL's pending cycles before a pass freezes the clock;
otherwise the pass's first store would publish them into the frozen clock
and the restore would drop them from the live timeline.
- The pass store policy moves into render_pass_plan.c as
render_pass_mmio_class() and gets a table test (SPU key-on, CD, timers,
SIO, MDEC and other DMA channels dropped; GP0, GP1 DMA mode/info, GPU and
OTC DMA, DICR/DPCR, I_STAT/I_MASK allowed).
- PSX_RENDER_PASS_VERIFY also hashes CD, SPU, SIO and MDEC state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
psx_mod_render_pass_plan() / psx_mod_render_pass() let a trusted plugin
redraw its game between two logic ticks with the game's own draw code. A
pass is a sandboxed nested guest call:
- time is frozen: cycles are counted (GTE and mult/div deadlines keep
working) but no device advances, no VBlank or device event fires and no
interrupt is delivered; GPU linked-list DMA and delayed completions finish
synchronously (psx_cycles freeze, interrupts, dma);
- stores go straight to RAM/scratchpad, bypassing code-page tracking,
overlay watch and write traces; MMIO is an allow-list (GP0, GP1 DMA mode /
info, GPU and OTC DMA, I_STAT/I_MASK) and SPU, CD, timer and other device
stores are dropped and counted (memory.c);
- afterwards CPU (with the GTE), RAM, scratchpad, I-cache tags, interrupt,
timer, DMA and GPU registers are restored exactly (render_pass.c, a GPU
register checkpoint without savestate side effects), with a guest-cycle
watchdog that rolls a runaway pass back;
- on OpenGL the declared display rect (hr colour + mask stencil, raw mirror,
native-wide band, CPU VRAM rows) is backed up, drawn, captured and rolled
back; writes outside it are journaled and restored, or refused.
The presenter keeps two generations of images (the game's own frame plus
one image per pass phase), promotes one when the flip-aware source sees the
display flip to its rect, and shows the newest image at each output
deadline's phase, crossfading across gaps when passes were shed. Presents
that fall due while passes run are made between them. The plan predicts the
output deadlines of the coming frame and sheds passes to fit a host-time
budget learnt from the presenter's idle time (PSX_RENDER_PASS_BUDGET).
PSX_MOD_FRAME_INTERPOLATION_HOLD repeats the newest frame where no pass
image applies, so a pass-driven title never shows anything later than stock.
Default off: nothing runs unless a plugin calls the API, and the plan
refuses in netplay, rollback, rewind, turbo, self-check resimulation, while
suspended (FMV) or without GL flip-aware interpolation. Pass writes are kept
out of frame_fingerprint. Debug: render_pass_stats, render_pass_dump,
PSX_RENDER_PASS_VERIFY=1 (state + VRAM hashes around every pass).
The freeze API lives in the runtime-only psx_cycle_freeze.h. psx_cycles.h is
part of the overlay codegen hash and stays byte-identical, so titles that
never use passes keep their overlay caches and savestates.
Tests: render_pass_plan_test (deadline-exact phases per rate, shedding,
selection), render_pass_freeze_test (10^6 frozen cycles, exact restore,
watchdog), render_pass_guards (sandbox choke points; the codegen-hashed headers stay
free of the pass API).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>