Commit Graph

1370 Commits

Author SHA1 Message Date
Matthew Stanley 58bdf987d9 Merge master into feat/internal-resolution: combine with #422/#423/#424
Keep #422's 1x GL_LINES batch (gated s_hr_scale == 1 && !s_hiw) beside the >1x quad batch; FLIP capture uses hiw_capture_size; render passes use s_hr_scale and are refused under s_hiw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:23:28 -07:00
Matthew Stanley 2bf4739f44 Merge master into feat/internal-resolution-settings: regenerate TCP_COMMANDS.md
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:22:53 -07:00
Matt Stanley f8a6f4f185 Merge pull request #425 from RetroPortingToolKit/feat/internal-resolution-gl
Raise the GL internal-resolution ceiling to the driver's limits
2026-09-29 11:22:28 -07:00
Matthew Stanley 914d8cb305 Merge master into feat/frame-rate: reset_on_load onto #420's fingerprint struct
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:22:02 -07:00
Matt Stanley d5e41d7b7d Merge pull request #423 from RetroPortingToolKit/feat/frame-rate-blend
Add a flip-aware frame-interpolation source (opt-in)
2026-09-29 11:21:11 -07:00
Matt Stanley b2f72d7cce Merge pull request #422 from RetroPortingToolKit/feat/adaptive-widescreen
Add [widescreen.cull] bgez_sites and clip_edge_x_load_sites
2026-09-29 11:20:58 -07:00
Matt Stanley df4e905ee8 Merge pull request #428 from RetroPortingToolKit/fix/rematch-session-start
Run the full session start on the lobby rematch
2026-09-29 11:20:45 -07:00
Matt Stanley 4ea39cf942 Merge pull request #421 from RetroPortingToolKit/fix/mod-session-hygiene
Reset mod-owned presentation state at every session start
2026-09-29 11:20:12 -07:00
Matt Stanley c896ec5e31 Merge pull request #420 from RetroPortingToolKit/fix/fingerprint-guest-facts
frame_fingerprint: judge on guest-visible facts; overlay shards set the runtime's store PC (ABI v24)
2026-09-29 11:20:04 -07:00
Matt Stanley f9ad37d25f Merge pull request #418 from RetroPortingToolKit/fix/muldiv-deferred-deadlines
runtime: publish deferred CPU work before mult/div deadline checks
2026-09-29 11:19:57 -07:00
Matt Stanley 3a7ea5e890 Merge pull request #417 from RetroPortingToolKit/fix/overlay-segment-alias
overlay: run shards only for the segment they were compiled for
2026-09-29 11:19:48 -07:00
Matt Stanley d6dc76e149 Merge pull request #415 from RetroPortingToolKit/fix/portable-mod-catalog-staging-20260928
cmake: stage mod catalogs portably without relinking games
2026-09-29 11:19:41 -07:00
Matthew Stanley 16cec81f1e psx_ram_runtime_map_test: link doubles for the MMIO readers memory.c now keeps
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:15:54 -07:00
Shokunin bd65c814f7 Run the full session start on the lobby rematch
The lobby rematch re-enters the emulator through goto session_reboot,
below the first-boot session block. After its commit (offline) or
netplay clear it reset mod-owned presentation state, but it never ran
mod_runtime_activate_plugins() or the controller, load-acceleration and
disc-speed resets. So an offline rematch with mods enabled applied the
plan's main-EXE and disc patches and ran its VBlank callbacks, but no
activation callback ran, and the function-entry hook table, which the
commit empties and only activation rebuilds, stayed empty. It also
mounted the stock disc even when the plan had built a derived disc
image.

start_mod_session() now holds the first-boot sequence, unchanged:
clear the controller-mode overrides and presentation policies, load
acceleration and disc speed; run reset_mod_owned_presentation();
activate; then apply the netplay local viewport, the controller-mode
overrides and load acceleration. The first boot and the rematch both
call it after their commit or netplay clear, and both then take the
mounted disc from session_disc_path(), which returns the plan's derived
image when there is one and otherwise the stock disc. Activation still
precedes renderer and window creation on the rematch, because those
happen after session_reboot on every session. The rematch re-applies
its 4:3 Settings clamp before the session start, so a plugin's
activation or the netplay local viewport can still set the aspect.

A netplay rematch clears the plan, so activating it runs nothing and
the match stays vanilla. The first boot's behaviour is unchanged.

Tests: test_mod_owned_display_controls.py now requires activation and
the mod-owned reset to happen only inside start_mod_session, in the
order reset, activate, apply. It requires both paths to call it after
their commit and before the disc mount, and the rematch 4:3 clamp to
come before it. mod_runtime_test adds the lobby rematch sequence:
activating after a netplay clear runs no callback, VBlank plugin or
hook; the offline rematch's commit and activation re-run the
activation callback and VBlank plugin and arm exactly the plan's hook.
test_mod_load_acceleration.py follows the reset block into the lambda body.
Two comments that said a soft return never re-applies the controller-mode
override are corrected: test_launcher_pad_mode_wiring.py and
test_launcher_pad_mode_resolve.cpp. MOD_PACKAGES.md now describes the
rematch as a full session start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:42:17 -07:00
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
Shokunin 314de7c03c ws_cull_edge_interp_test: capture stderr through a named file
tmpfile() on MSYS2 MinGW64 (msvcrt) creates its file in the root of the
current drive and fails for a normal account; the test then ran without a
capture and failed its overflow-log check. It now writes to a file named
by argv[1] (ctest passes one in the build directory), fails with a clear
message if that cannot be opened, and removes it afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:05:03 -07:00
Shokunin f00117769f Native-wide line batch: gate on the live mirror, drain before state changes
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>
2026-09-28 22:05:03 -07:00
Shokunin 709e04d228 Overlay shards: define psx_ws_cull_bgez and psx_ws_clip_edge_x
The emitter writes both helpers at a listed bgez_sites or
clip_edge_x_load_sites address whose instruction matches in overlay code
too, but overlay_dispatch_preamble.c.inc did not define them, so such a
shard failed to link (macOS, Windows) or to load (Linux dlopen RTLD_NOW)
and its overlay stayed interpreted.

The preamble now defines both over psx_ws_x_margin(), with the same pure
math gpu.c uses (ws_cull_edge.h, stdint-only, staged with the other
runtime headers). ws_cull_edge.h joins the shard compile surface in
codegen_hash_sources.cmake. overlay_shim_compile_contract links a shim
that calls both with the emitter's extern declarations, and
overlay_widescreen_callbacks runs them in an executable: identity with no
margin callback, widened at margin 120, interior clip edges unchanged.
Both fail without the definitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:00:17 -07:00
Shokunin 4c00b9dc03 Native-wide line batching: 1x only
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>
2026-09-28 22:00:17 -07:00
Shokunin 5b0c519fe3 native_wide_line_batching guard: allow extra conditions in the gate
A branch that also batches line quads above 1x (feat/internal-resolution)
adds a windowed-mode exclusion to the same condition when the two meet; the
guard keeps requiring the native-wide and backdrop-stretch parts only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:00:17 -07:00
Shokunin 6e2eadacd9 GL: batch native-wide lines instead of drawing each one twice
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>
2026-09-28 22:00:17 -07:00
Shokunin 29a49eb4d5 Render passes: one pass per plan until the cost is measured at this size
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>
2026-09-28 21:02:48 -07:00
Shokunin 3d705f73c3 Render passes: hold a late frame's newest image until the flip
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>
2026-09-28 21:02:48 -07:00
Shokunin 0c0d375673 GL readback runner: link render_pass_plan.c into the fixture
The MinGW fixture compiles gpu_gl_renderer.c (through
test_gl_readback_region.c) with only gpu_sw_renderer.c beside it.
gl_renderer_shutdown() now releases the render-pass resources, including
the VRAM journal's CPU rows, so the PE link left render_pass_journal_free
unresolved and the runner returned 2. render_pass_plan.c is self-contained
(libc only), so link it rather than stub each journal and policy entry
point.

Checked with a macOS clang stand-in for gcc.exe/g++.exe (-flto,
dead_strip): the branch before this left render_pass_journal_free
unresolved; now it links, and the probe then behaves exactly as on master
(macOS gives the fixture a GL 2.1 context, so both stop at shader compile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:48 -07:00
Shokunin 1decf2574d render_pass_abort_test: set its environment with _putenv_s on Windows
setenv() is POSIX-only; mingw-w64 declares none, and GCC 14+ reject the
implicit declaration, so a MinGW configure with BUILD_TESTING failed to
build the target. Use _putenv_s under _WIN32, as test_hd_texture_pack and
test_text_xlate_fix_only do. zig cc -target x86_64-windows-gnu now builds
all four render-pass tests and frame_interpolation_schedule_test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:48 -07:00
Shokunin 4961e810e0 Render passes: hash RAM by words in verify mode
PSX_RENDER_PASS_VERIFY hashes the guest-visible state before and after
every pass, RAM included. Byte-wise FNV made that the bulk of a verify
pass's begin cost in R4: 4.0 ms with 2 MiB (Grand Prix) and 10.6 ms with
the 8 MB RAM map (attract demo), so with 8 MiB live the budget shed
nearly every plan and only 10 passes were checked in 30 s.

Hash 64-bit words instead; each step is still one bijective FNV step, so
a change in one word always changes the hash. Begin cost is now 0.8 ms
(2 MiB) and 2.5 ms (8 MiB); the same 8 MiB run checked 755 passes, 0
mismatches. Verify mode only: the hash is compared within one pass and
never stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:48 -07:00
Shokunin d300319b7e Render passes: follow the live 2/8 MiB RAM geometry
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>
2026-09-28 21:02:48 -07:00
Shokunin b3cbe617c8 Render passes: leave first-use allocations out of the pass-cost average
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>
2026-09-28 21:02:48 -07:00
Shokunin ab2ddbc351 Render passes: journal out-of-rect writes under native-wide too
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>
2026-09-28 21:02:48 -07:00
Shokunin 3e262c128d Render passes: unit-test the sandbox (store drops, frozen devices, journal)
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>
2026-09-28 21:02:48 -07:00
Shokunin 673e75ec86 Render passes: reuse the VRAM backup for later passes of a frame
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>
2026-09-28 21:02:48 -07:00
Shokunin 69e6b6b552 Render passes: refuse during fast-forward; say why passes are unavailable
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>
2026-09-28 21:02:48 -07:00
Shokunin 856cbabffe Render passes: make pass image textures as slots fill
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>
2026-09-28 21:02:48 -07:00
Shokunin ea1e67a423 Render passes: PSX_RENDER_PASS_WATCHDOG to exercise the rollback path
A debug override that lowers the guest-cycle watchdog (1000 up to the 8 M
default), so a title's real passes can be driven through the abort path and
checked with fingerprints against a run without passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:48 -07:00
Shokunin 1a8f382085 Render passes: put host nesting back after a watchdog abort
The watchdog leaves a runaway pass by longjmp from inside guest code, which
skips the exits of every frame it leaves: generated functions' bb_defer
cleanup, overlay_loader_call_native's unit depth, the native-shard stack and
cycle-flush hook, the DMA kick's exec depth, and the dirty-RAM interpreter's
active/phase/precise flags, resume latch and pending load. The checkpoint
now saves all of it and the restore puts it back (bb_defer with the cycle
freeze), as the scheduler and exception landings do for theirs. An overlay
shard's unpublished pass cycles are flushed into the frozen clock at the
landing so they never reach the live one.

PSX_RENDER_PASS_VERIFY also checks that a pass which returned normally left
the nesting balanced, since the restore would hide that. render_pass_stats
gains nesting_repairs and the fault log names what the landing undid.

render_pass_abort_test aborts a pass from five nested frames (real
render_pass.c and psx_cycles.c, mock devices and presenter) and checks every
value, the clock and RAM; it fails without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:47 -07:00
Shokunin eb6b8e49b9 Render passes: publish overlay cycles first; test the store policy; verify devices
- 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>
2026-09-28 21:02:47 -07:00
Shokunin 82a835b20b frame_fingerprint reset_on_load for A/B runs from one savestate
frame_fingerprint reset_on_load=1 restarts the rolling hashes and the ring
when the next savestate load completes, so two runs that load the same state
(for example with and without a presentation mod) compare frame by frame
from the same guest point. Debug tooling only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:47 -07:00
Shokunin 1a45230e77 Render passes: trade surplus presents for passes in the budget
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>
2026-09-28 21:02:47 -07:00
Shokunin d22ebac9a4 Host-timed render passes for true in-between frames
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>
2026-09-28 21:02:47 -07:00
Shokunin fb832a9563 FLIP source: test the new-frame decision; guard its wiring
interp_capture's FLIP decision moves into frame_flip_is_new_frame()
(frame_interpolation.c), unit-tested in frame_interpolation_schedule_test.
A new source guard, frame_interpolation_flip_source (recompiler ctest),
pins what no unit test reaches: main.cpp hands the session's source to the
renderer at every session start, right after its interpolation settings;
interp_capture feeds the origin and the redraw test to the decision and the
tracker and returns before the copy on a duplicate; both present paths pass
the display origin and present_dirty_test; a source change restarts the
history. Deleting the hand-off, or never returning on a duplicate, now fails
the guard (both passed every test before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:43 -07:00
Shokunin 5dfb9a91eb GL readback fixture: stub frame_flip_tracker_reset
test_gl_readback_region.c includes gpu_gl_renderer.c and stubs each
external the renderer reaches, including frame_interpolation_schedule_reset
from interp_reset_history_unlocked(). The flip-aware source resets its
tracker in that same function, so the MinGW link (-flto, no unresolved
symbols in a PE) would fail on frame_flip_tracker_reset. Stub it beside the
schedule reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:43 -07:00
Shokunin 5ffd57d7f3 Flip-aware frame-interpolation source
psx_mod_set_frame_interpolation_source(PSX_MOD_FRAME_SOURCE_FLIP) makes the
OpenGL presenter rotate its blend history only when the guest really flips
(the displayed VRAM origin moves or the displayed rect is redrawn) and spread
one crossfade over the measured flip period. A game that flips every second
VBlank (R4 races) used to blend for one VBlank and hold for the next; it now
blends across its whole frame.

VBLANK stays the default. reset_mod_owned_presentation() puts it back at
every session start (first boot and the lobby rematch), next to the blend
mode, so titles that never call the setter keep today's code path and a
netplay session never inherits it. begin() is begin_phase(0, 1) and stays
bit-identical (test). gl_interp reports the source, the flip period,
captures and duplicate VBlanks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:02:43 -07:00
Shokunin bc2106f7c7 Test: the window mode refuses render passes
Once the frame-rate stack's render passes land, a pass backs up and
restores only s_hr_fbo, at s_hr_scale. In the 8K window mode the presented
surfaces are the tiles at s_out_scale, which a pass would not restore, and
the queued native-wide replay has no pass clamp, so the backend must refuse
passes there (gl_renderer_pass_unavailable: s_hiw returns
PSX_MOD_RENDER_PASS_BACKEND). Nothing enforced that but prose.

test_gl_internal_scale_guards: where the renderer defines
gl_renderer_pass_unavailable, its s_hiw refusal must return
PSX_MOD_RENDER_PASS_BACKEND and pass_begin and pass_plan must go through
gl_renderer_pass_ready; skipped before render passes exist. The GL fixture
gains a passes mode, built only when the renderer has render passes: with
the flip-aware blend ready, the full-VRAM surface at 9x offers passes and
the window mode at 9x and 18x refuses them and pass_begin opens nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:24 -07:00
Shokunin 6e3562ecac GL: draw queued batches before the mask-check bit changes
The flat and textured batches apply the mask check that is current when
they are drawn (mask_stencil reads s_mask_check), and glb_set_mask_bits
flushed only the textured batch, only when the check turned on. A draw
queued under one GP0(E6) check bit was then drawn under the next one:
masked pixels overwritten, or unmasked ones skipped, in guest-readable
VRAM. Master already did this to flat triangles and opaque textured
prims at every scale; batching lines above 1x (and in the 8K window mode)
extended it to lines.

Flush both batches whenever the check bit changes. Only draws that were
wrong change: the GL fixture's own scene drew its mask-checked rect over
the masked one (its digests change with this), and a new mode, mask, draws
a line, a flat triangle and an opaque textured rect across a mask-set rect
with the check changed before their batch is drawn, at 1x, 4x, 9x and in
the window mode at 9x and 18x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:24 -07:00
Shokunin 83c1e07956 GL: blend and hold-last captures at the presented size in window mode
In the windowed high-resolution mode (8K past a 16384 texture limit) the
displayed band is up to 15372x4320 (32:9). With temporal frame blending on,
every game frame copied all of it into the blend history and again into
the hold-last snapshot, about 1 GB of GPU copies per frame, only for the
presenter to draw it at the window's letterbox size. With R4's widescreen
at 32:9 and 8K, Frame blend at 60 or 240 presents/s ran the guest at 19-30
frames/s against 33-45 without it.

There, both captures are now taken at the letterbox size (one filtered
blit, nearest when the present is unfiltered), and the presenter draws them
1:1; the scanline pitch stays the source band's rows. Outside the window
mode nothing changes: the captures stay at the source scale.

gl_scale_invariance_test gains mode "capture" (full 9x, window 9x and 18x):
size at the source scale outside the window mode, at the letterbox size in
it, and each texel of both captures the source pixel under its centre with
nearest filtering (up to 0.1% may differ for driver rounding; none do on an
M4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:24 -07:00
Shokunin 9805a00a7e GL: queue the native-wide mirror with the high-resolution window's draws
With lines batched, 8K widescreen still switched to the wide surface and
back for every textured or flat batch that reaches the margins (~29 per
frame at 21:9 in an R4 race; 32:9 flushes ~4x as many textured batches).
At 18x those surfaces are 10080x9216 and 15372x9216, and each switch ends a
render pass: with the mirror drawn into the hr surface instead (no switch,
gl_ws_ablate 3) 32:9 at 8K ran at 57 guest frames/s instead of 12.

In windowed mode a draw's native-wide mirror now rides in its window queue
entry (target surface, x shift, backdrop stretch) and is replayed in the
queue's flush, in order, one pass per wide surface (hiw_replay_wide). The
queue's sync points already cover what the mirror samples (the raw mirror)
and the stencil rebuild; every other write to a wide surface (the clears,
the full-screen overlay rect, reallocation) and every read of one
(present, capture, dumps) now flushes the queue first. Outside windowed
mode, and with the mirror ablated, the immediate mirror runs as before.

- gl_scale_invariance_test: the window runs' native-wide surfaces (the
  scene's and the new lines runs') stay byte-identical to the full-VRAM
  runs, and the lines runs check that the mirrors were queued. Mutations
  fail it: no queueing (the queue check), a wrong x shift or no replay
  (the wide digests).
- R4, same setup as the previous commit: 21:9 at 8K 34.2-37.7 -> 56.1-56.6
  guest frames/s (4:3 at 8K: 59.4), 32:9 12.6-13.8 -> 45.1-45.9; mirror
  passes per frame 29 -> 0.5. With the debug server's per-frame readback on
  (the default debug build): 21:9 43.7, 32:9 40.0.
- test_gl_internal_scale_guards pins the queued path, the replay state and
  the sync points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:24 -07:00
Shokunin de8f4ef148 Test: link the GL probe strictly, with the renderer's other sources
run_gl_scale_invariance.py linked the probe with -undefined dynamic_lookup
(--unresolved-symbols=ignore-all on Linux), so a renderer call into a
source the probe did not build bound to NULL and crashed the run (-11)
instead of failing the link. Once the frame-rate render passes land, the
renderer calls render_pass_plan.c and frame_interpolation.c, and the
probe crashed on every run.

Link frame_interpolation.c (the fixture's stub for it goes) and
render_pass_plan.c whenever it exists, and drop the unresolved-symbol
flags: unused renderer functions are still removed (-dead_strip, or
per-function sections with --gc-sections), and anything left unresolved
is a link error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:23 -07:00
Shokunin 472f9c7ed0 Debug: screenshot_wide_hires reaches the lower buffer's band at 8K
The capture dumps the wide surface from its top row into a buffer capped at
8192x8192 pixels, then cuts the displayed band out of it. A 21:9 surface at
18x is 10080 wide, so the cap held 6657 of its 9216 rows: with R4's lower
double buffer displayed (y 240, band rows 4320..8639) the capture came back
10080x2337 instead of 10080x4320. Size the buffer to the end of the displayed
band at the native-wide width (up to 256M pixels); smaller captures keep the
old budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:23 -07:00
Shokunin 624fadc14f GL: batch lines in the high-resolution window mode too
In windowed mode (8K past a 16384 texture limit: VRAM at 1x plus the
high-resolution window at S) lines stayed immediate. Each one drew GL_LINES
on the 1x hr surface and then, with native-wide, switched to the wide
surface (10080x9216 at 21:9 and 18x, 15372x9216 at 32:9) and back for its
mirror. In an R4 Grand Prix race that was ~130 mirror passes per frame:
21:9 at 8K ran at 15 guest frames/s and 32:9 at 5.5.

A batched line now carries two vertex sets. Its one-native-pixel quad joins
the flat batch with the triangles, as above 1x, and is what the window and
the wide surfaces draw. Its two GL_LINES vertices go in s_fbl, with the
batch position of its quad in s_fbl_at. The 1x hr draw
(flat_batch_draw_hr_lines) walks the batch in order, drawing triangle runs
from s_fb and line runs as GL_LINES from the copy uploaded after the
batch's vertices, so painter order and the 1x pixels are unchanged. A batch
without windowed lines keeps its single draw; outside windowed mode nothing
changes (s_out_scale > 1 is the old gate there).

- gl_scale_invariance_test: new "lines" runs (lines interleaved with
  triangles that overlap them, over a native-wide surface) at 1x and 9x on
  the full-VRAM surface and 9x and 18x in windowed mode. The windowed runs'
  native frame equals the 1x run's, and at 9x their frame and wide surface
  equal the full-VRAM run's; above 1x the lines and the triangle must share
  one flat batch, windowed lines with their GL_LINES vertices. Every
  existing digest line is unchanged, and the lines runs give the same
  digests against the renderer without this change (immediate lines).
  Mutations fail it: drawing all triangles before all lines, or the quads
  on the 1x surface.
- R4 (scratch merge with feat/adaptive-widescreen c042434c and the R4
  widescreen mod, one race savestate, debug build, GL timer queries and the
  debug server's per-frame VRAM readback off): 21:9 at 8K 15.0 -> 34.2-37.7
  guest frames/s, 32:9 5.5 -> 12.6-13.8; mirror passes per frame 132 -> 29.
- test_gl_internal_scale_guards pins the gate, the second vertex set and
  the ordered 1x draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:23 -07:00
Shokunin ec7bc14ad9 GL: Fit to Window stays within the native-wide surface limit
A native-wide surface is g_wide_w*S wide and the driver limit bounds it
(1024 native columns at 16x on a 16384 limit, about 38:9 at a 320-px
display). An uncapped Fit to Window aspect could ask for more; wide_fbo_for
then refused the surface and the frame fell back to a 1x CPU present with
no native-wide margins.

update_adaptive_widescreen now narrows the Fit aspect to the widest the
surface holds (gl_renderer_fit_wide_aspect: psx_gl_max_wide_width at the
context's scale, psx_gl_fit_wide_aspect at the current display width,
gpu_ws_display_width). It changes nothing unless the surface would be
refused. The refusal stays for a fixed aspect on a wide display mode; its
comment now gives the right limit and what the frame falls back to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:23 -07:00