pass_capture_into copied the unshifted canonical frame over an anchored wide
surface: interpolated frames showed stale margins and a displaced, flickering centre.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fixture no longer compiled (poisoned s_scale, duplicate psx_cycle_count),
and gl_renderer_capture_display_hires fell back to a write that ignored pitch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pair is fresh only within 24 vblanks of its capture cycle; savestate/session reset already invalidate it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The OpenGL textured program read the GP0(E2h) texture window from a
uniform, so every window change ended the open textured batch (batch
reason 5). Titles that tile far textures with per-primitive windows pay
for that in draws: Ridge Racer Type 4's split screen changes the window
about 515 times a game frame and drew about 180 batches, against 17 with
batching on. On the native-wide path every batch that reaches the margins
is drawn again into the wide surface; under heavy load on an M4, 2P with
widescreen at Match display ran at 39-43 guest VBlanks/s and 1P at 50,
against 50-58 and 57 with batching on.
The window now rides in each textured vertex (a_twin, the E2h low 20 bits
as one float) like texpage and CLUT already do, and the shader decodes it
there; the uniform and the window-replay queue's copy of it are gone.
With the default unchanged, batches still split at every window change,
so every vertex carries the batch's window and the image is the same as
before: the new test's oracle passes on the unmodified renderer too, with
the same digests at 1x, 4x, 9x and in the windowed 9x/18x mode.
[video] texture_window_batching = true (game.toml only, default off) lets
primitives with different windows share a batch. The image is the same:
an opaque batch draws its colour in one painter-ordered pass and
semi-transparent primitives are still drawn one per batch. While mask
checking is on the window stays a batch key, because an opaque batch
fixes the stencil for its STP=1 texels after its colour pass.
PSX_GL_TEXWIN_BATCH=0|1 overrides it for a run and the TCP command
gl_texwin_batch switches it live. Software and Vulkan are unchanged.
Tests: gl_scale_invariance_test gains mode "twin" (alternating windows
over opaque, semi-transparent, set-mask, mask-check and bilinear prims,
into the native-wide margins): off and on give the same native VRAM,
frame at S and wide surface, on draws 17 batches instead of 46 and flushes
only for the 5 mask-check window changes. Each run also checks an oracle
that does not depend on the renderer: raw rects through all four windows
into a 4-bit and a 15-bit page must give, pixel for pixel in the native
VRAM and at the centre of each pixel at S, the texel the PS1 window rule
picks from the VRAM as read back. A decode or packing error both settings
share fails it (checked with (v_twin >> 11) and with off_y left out of the
packed window). video_enhancement_settings_test checks the key's default
and opt-in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve current segmented dispatch, active-plan mod hooks and software scale limits. Extend function filters through the active plan, retain the last-store pointer with overlay ABI 25, and keep default config fingerprints stable. Refresh BIOS emitter stamps after byte-identical regeneration and reconcile regression fixtures.
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>
The GL_LINES batch was gated on g_wide_w > 0, which glb_wide_configure
sets while native-wide is active and nothing clears, so after the first
wide frame every later 1x line was batched, also in 4:3 scenes and after
the view went back to 4:3. It now requires g_wide_cur (the mirror is
live); with no live mirror a line takes the immediate path as on master.
flush_flat_batch() reads the mask-check, mirror-suppress and wide-target
state when it runs, not when a primitive was queued. flush_line_batch()
drains a pending line batch before each of them changes: a GP0(E6) check
change (before the stencil rebuild), a full-screen flat rect's mirror
suppression, wide reconfiguration, and switching or disabling the wide
target. A pending triangle batch keeps its existing behaviour at these
points (the target switches no longer drain it), so nothing changes for
flat triangles.
The native_wide_line_batching guard pins the gate and each drain point;
each removal fails it. A drain point may also be a full
flush_flat_batch(), which drains a line batch too, so a branch that
drains every batch there (feat/internal-resolution does at a mask-check
change and at target switches) still passes.
Pixels, on a hidden real OpenGL context at 1x with native-wide
configured (a scratch probe that #includes this renderer; this tree has
no macOS GL harness): a line issued with the mask check on and drawn
after E6 turns it off keeps all 40 masked pixels, one issued with the
check off overwrites all 40, a line reaching the right margin keeps its
wide mirror under a following full-screen semi-transparent rect, and a
line after the wide target is disabled is drawn immediately. The
previous head fails all four; master's immediate path gives the same
pixels as this head.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Above 1x the internal-resolution work draws a line as a one-native-pixel
quad (line_to_quad) and batches that itself; a GL_LINES batch there would
bypass the quad and draw a one-hr-pixel line once the two branches meet.
The native-wide GL_LINES batch now applies at 1x only (s_scale == 1).
The guard requires the 1x condition.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in the R4 integration round: with the widescreen package at 21:9 a
Grand Prix race ran at ~55 guest frames/s at 1x (debug build, GL timer
queries off) instead of 60. A CPU sample put ~57% of the emulation thread
in glb_draw_line -> gpu_geometry: every line was drawn on its own,
flushing the textured batch, then rebinding the wide surface and the hr
surface for its native-wide mirror. R4 draws hundreds of lines per race
frame.
With native-wide on, a line now joins the flat batch as a GL_LINES batch:
same vertices, program, line width, painter order and batch keys (semi,
mask-set), and one draw plus one mirror per batch. The
batch holds one primitive mode; a line after triangles (or the other way
round) flushes it first. A line the backdrop-stretch gate would widen keeps
the immediate path, since the flat batch mirrors unstretched. Until
native-wide first configures a wide surface nothing changes.
glb_wide_set_target() and glb_wide_disable_target() now drain the flat
batch into the old target first, as they already did the textured batch
(the draw-area change that precedes a switch flushed it anyway; this makes
it hold for any caller).
- R4 21:9 at 1x from one race savestate (widescreen-only build, timer
queries off): 54.8 -> 59.9 guest frames/s over 10 s.
- New ctest native_wide_line_batching (source guard): the gate, one mode
per batch, the batch and its mirror drawn with the batch's mode and line
width, the target switches draining first. It fails on the old code.
- Pixels: the 1x native-wide case of the GL scale fixture (a line across
both margins) is checked on the integration branch, which carries that
fixture; see the PR description.
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>
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>
Every pass backed up the display rect (hr colour + stencil and raw mirror
blits, CPU rows) although the previous pass of the same frame had just
restored exactly that backup. On Apple's OpenGL each blit ends the render
pass and submits a command buffer, which was about half of a pass's begin
time. A pass now reuses the previous pass's backup when render_pass.c
vouches that no guest code ran since its restore (same plan, guest clock and
store count unchanged) and the rect, scale and native-wide source match; the
first pass of every frame still takes a fresh backup.
R4 Grand Prix race, 240 Hz interpolated: average pass begin 0.28 -> 0.16 ms.
render_pass_stats reports backups_reused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Manual fast-forward presents every second VBlank, so the plan still saw
presents and kept rendering passes, spending host time the fast-forward
wants, although the API said turbo refuses them. main.cpp now records when
presentation is sped up (manual fast-forward, turbo-through-loads, FMV
auto-skip, TCP turbo) and the plan and passes refuse meanwhile.
psx_mod_render_pass_status() tells a plugin why passes cannot run, the
host-time budget aside: NO_PRESENTER, BACKEND (the renderer declines passes
in its current mode), DISABLED (after repeated faults), SESSION, FAST_FORWARD
or BUSY. The GL backend reports its part through
gl_renderer_pass_unavailable(), where a renderer mode that cannot host
passes reports BACKEND. psx_mod_set_frame_interpolation_blend() now also
takes effect when called from a hook after activation, so a plugin can show a
crossfade while passes are unavailable and return to HOLD afterwards.
Debug: PSX_RENDER_PASS_REFUSE=1 or TCP render_pass_refuse on=1 makes the
backend decline passes, to test a plugin's fallback; render_pass_stats
reports the status.
Default off: nothing reads the new flag or status unless a plugin uses
render passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pass_slot_cap() limits a generation to what two generations fit in 256 MiB,
but pass_gen_textures() allocated all 17 slots of each generation at
internal-resolution size as soon as passes ran, whatever the cap: 34
textures, about 167 MB at 4x and 1.5 GB at 12x for at most two used slots.
Slot textures are now made on first use, never past the cap, so the budget
holds at any internal scale; a size change frees the old set, and context
teardown frees every pass texture, backup and journal FBO and forgets their
names (a new context no longer reuses dead ones). render_pass_stats reports
image_textures and image_bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At high output rates presenting took most of the host time the budget was
learnt from, so 200-240 FPS rendered almost no passes and looked like 30 Hz.
A shed present is only coalesced while a shed pass is a missing motion
sample, so the budget now counts present time beyond two presents per game
frame as available. interp_present goes through the same accounted pair
presenter (identical GL calls).
Measured on an M4 under load (R4, 1x): 240 FPS went from ~20 to ~74
passes/s at 153 presents/s, still 59.9 guest VBlanks/s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>