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>
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>
docs/WIDESCREEN.md said every explicit screen-X cull kind rejects a wrong
opcode at build time, but only slti, slti_lower, bgez and clip-edge sites
did. A bltz_sites entry was used only when the word was bltz, and
branch_keep_sites wrapped whatever branch was there, so a mistyped address
generated silently and the edge stayed narrow.
One check, CodeGenerator::check_explicit_branch_site, now covers the three
branch kinds in the main EXE: bltz_sites needs bltz (REGIMM rt=0),
bgez_sites bgez (REGIMM rt=1), branch_keep_sites a conditional branch
(REGIMM, beq, bne, blez, bgtz). It runs in generate_branch_condition, in
translate_instruction (non-branch words) and at the block exit, which also
catches a jump at a listed address (jumps never reached either guard, for
bgez_sites too). psxrecomp-game prints the site and exits 1; overlay
variants stay vanilla as before.
ws_cull_edge_codegen_test and recompiler_error_exit cover each kind on a
wrong opcode, a non-branch and a jump, valid sites, and overlay variants;
both fail against the previous emitter. The codegen test also pins that a
matching overlay instruction emits the helpers (the preamble defines them).
Docs list the required instruction per kind.
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>
The pass point is a function-entry hook. Say how to register it with
psx_mod_register_function_entry_plugin() under the manifest [[plugin]] id
(it then runs only while the mod plan activates the plugin), to register
once because a repeated id and address returns 0, and that entry hooks
also fire for guest functions a pass calls.
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 (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>
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>
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>
docs/RENDER_PASSES.md; FRAME_RATE.md gains HOLD and a link; TCP_COMMANDS.md
describes gl_interp, render_pass_stats and render_pass_dump and regenerates
the command index (which also picks up three commands master had not
indexed yet).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
A host built against a recomp-ui without the Internal resolution row
(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION undefined) still seeded
internal_resolution on first boot and applied it after the launcher.
From a fresh start the seed was "native", so a Supersampling pick of 3x
was reset to 1x and "native" was saved; on the netplay soft-return the
sticky preset was re-applied at session_reboot. The legacy row stopped
working.
Both launcher exits now go through two pure helpers in
internal_resolution.h:
- psx_ir_launcher_seed_supersampling: with the row, the factor as before;
without it, the factor itself when no preset is configured (the
historical round trip), else the preset's scale capped at the row's 4x.
- psx_ir_adopt_launcher: with the row, the row's preset as before;
without it, a pick in the Supersampling row drops the preset, and an
untouched row keeps a preset the player cannot see (game.toml or a
hand-edited settings.toml). It also returns what settings.toml saves:
no internal_resolution key unless a preset is kept.
internal_resolution_test drives seed -> launcher -> adopt for both
launcher kinds, first boot and soft-return, against a model of the
legacy row (clamp to 1..4, cycle); keeping the old sticky preset fails
it. gl_internal_scale_guards pins both exits to the helpers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The environment override was written into g_video_internal_res, the
configured preset. On a netplay soft-return the launcher was seeded from
it and saved it to settings.toml, so one local peer started with
PSX_INTERNAL_RESOLUTION=8k left 8k in the settings.toml the other peer
shares: the case the override exists for.
Keep the override in g_video_internal_res_env. It still wins for the run
(scale, Match display, HiDPI window, video_info), but the launcher only
shows, and settings.toml only saves, the configured preset.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A preset id or a line count in PSX_INTERNAL_RESOLUTION overrides every
config layer, like the other validation env overrides (PSX_SCANLINES,
PSX_GEOMETRY_CORRECTION). Two local netplay peers share one settings.toml,
so this is how one runs at 4K beside another at Native. Also say
"measured when the game window opens" for Match display before the window
exists instead of logging a 1x request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windowed high-resolution mode kept one window over the union of displayed
columns. Two 512-wide buffers side by side need 18432 px at 18x, past a
16384 GPU limit, so the second buffer was refused and every other frame
presented at 1x.
- The window is now up to four tiles, each its own surface over a column
range. A new display first tries one surface over the union of all
displays (the old behaviour, so R4's 320-wide window is unchanged), then
a merge with the tiles it overlaps, then a tile of its own. Every
mirrored write (queued draws, fills, uploads, copies, the depth24 clear,
stencil rebuilds) goes to each tile it touches; present, captures,
screenshot_hires and the native-wide centre read the tile holding the
display. A display no tile can hold within the GPU limit, the memory
budget (all tiles together) or the tile cap still presents at 1x, logged.
- A copy into the window stages its S-scaled source in a scratch of its
own, in column chunks that fit the GPU limit, walked in memmove order so
an overlapping copy still reads pre-copy pixels. The shared scratch no
longer grows past the hr surface for it.
- Staging textures commit a new size only after the driver accepted the
storage; a size past the limit is refused with a log line and the
texture keeps its old storage (scratch_ensure used to record the size
before a glTexImage2D that could fail).
- PSX_GL_MAX_DIM lowers the GPU limit the backend plans with (testing a
layout for a smaller GPU). video_info adds display_x/display_y,
hires_window_tiles and hires_window_mib.
- gl_scale_invariance_test: side-by-side runs flip two 512-wide buffers
and require one surface at 5x and two tiles at 9x (PSX_GL_MAX_DIM=8192)
to equal the full-VRAM surface, including copies between the buffers
and 1000-column copies; at 18x both buffers must read back at S. A
scratch request past the limit must be refused and leave the texture
and its recorded size intact.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a player-facing internal resolution on top of the raised GL ceiling.
A preset is a target height; the runtime renders at the integer scale
S = ceil(target / reference_lines), reference 240 by default: 720p 3x,
1080p 5x (area-resolved to 1080), 1440p 6x, 4K 9x, 5K 12x, 8K 18x. The
backend then clamps S to what it can allocate.
- runtime/include/internal_resolution.h: the preset table, stable ids,
the value encoding shared with recomp-ui (0 unset, 1 native, -1 match
display, N target lines) and the pure resolver.
- config: [video] internal_resolution (a preset id or a number of lines)
and resolution_reference_lines in game.toml; internal_resolution in
settings.toml, written as a stable id beside supersampling = min(S, 4)
for older runtimes. Precedence: game default < player's legacy factor <
player's preset. settings.toml window_width now accepts up to 7680.
- main.cpp: resolve the preset before gpu_init; Match display re-resolves
from the game window's monitor (SDL3 mode height x pixel density) before
the GL context allocates. The launcher gets the vocabulary (with a
synthesized "Nx (L lines)" entry for a legacy factor no preset matches)
behind RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION, on first boot and on the
netplay soft-return.
- The OpenGL game window gets SDL_WINDOW_HIGH_PIXEL_DENSITY (SDL2:
ALLOW_HIGHDPI) only when the player chose more than native, so the
drawable is in pixels on Retina; native keeps the window as it was.
- debug server: video_info (preset, requested and effective scale, GL
limit and clamp, hr surface, window points vs pixels), screenshot_hires
reads the GL hr FBO at full size, screenshot_wide_hires captures the
native-wide band at internal resolution.
- docs: config_schema (keys, precedence), NETPLAY (a per-peer presentation
setting), TCP_COMMANDS (index regenerated), ENHANCEMENTS IR1.
Default-off: with no internal_resolution anywhere the scale, the window
flags and the present path are unchanged.
Tests: internal_resolution_test (presets x reference 240/480 x backend
maxima, legacy mapping round trip), video_enhancement_settings_test (game
and settings keys, stable-id round trip, legacy key still written),
gl_internal_scale_guards (HiDPI only when opted in, launcher fields behind
the capability macro).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mirroring every batch into the window as it was flushed switched
framebuffers twice per batch. On Apple's GL (Metal underneath) each switch
ends a render pass, about 80 us: 10 ms of CPU per R4 frame at any window
scale, and the 8K preset ran at 21 fps (frame_perf total 47 ms,
cpu_flush 17.4 ms).
The window's copies of textured, flat and line draws are now queued
(vertices plus the state they were drawn with: blend key, set-mask,
mask-check, texture window, filter, sampled texture, draw area) and
replayed into the window in one pass at the next sync point: before the
raw mirror changes (pack, upload, depth24 clear), before any immediate
write to the window (fill, copy, upload, stencil rebuild), and before every
read of it (present, capture, growth, hires readback). Order within the
window is kept, so it equals an immediate mirror wherever it is observed.
tex_batch_draw_passes and mask_stencil take their mask state as
parameters (tex_draw_passes_ex, mask_stencil_ex) so the replay uses the
recorded state; the canonical pass calls them with the live state, the
same GL call sequence as before (native and 2x/4x digests unchanged).
Measured on an M4 in a race: 8K total 16.68 ms per present (60 Hz
cadence), cpu_flush 0.62 ms; forced window at 4K likewise. The invariance
test still finds the window's frame byte-identical to the full-VRAM
surface at 2/3/5/9x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8K is 18x at a 240-line reference, and a full-VRAM surface at 18x is
18432 px wide: over the 16384 limit of Apple's GL, Intel and MoltenVK. A
title only needs its displayed frame at 18x, so past the full-VRAM limit
the GL backend now splits the work:
- The authoritative VRAM stays the ordinary hr surface at 1x, i.e. the
native renderer unchanged: pack, CPU readback, render-to-texture and VRAM
copies are exactly the native results (s_hr_scale; s_scale stays the
presented scale gr_scale reports).
- A presentation-only surface at S covers the displayed columns W =
[x0, x1) x all 512 rows (R4: 0..319, 5760x9216 at 18x, 405 MiB). Every
GPU write touching W is mirrored into it: textured and flat batches,
lines (quads), fills, uploads, the depth24 clear, and VRAM copies (hi to
hi when the source is in W, otherwise the 1x source upscaled; both staged
before drawing so overlapping copies read pre-copy pixels). It keeps its
own stencil. A separate blit program carries the window projection, so
the canonical shaders are untouched.
- W grows (64-column aligned) the first time a display rectangle outside it
is presented, seeded by upscaling the 1x content. Present, hold-last,
interpolation capture, screenshot_hires and the native-wide centre read
it; a display it cannot hold presents at 1x.
It engages only when the full-VRAM surface cannot hold the request (driver
limit or budget), sized so a 320-wide window fits both; up to 16x on the
M4 nothing changes. PSX_GL_HIRES_WINDOW=0/1 disables or forces it. The
launcher note and video_info (hr_scale, windowed, hires_window_*, hires_fbo)
report it.
Default-off: at 1x and whenever the full-VRAM surface fits, the paths are
the previous ones (same native-VRAM and present digests on a fixed scene at
1x and 2x).
Tests: gl_scale_invariance_test now also forces the window at 2/3/5/9x and
requires its frame at internal resolution to be byte-identical to the
full-VRAM surface at the same scale (copies inside, into and across the
window edge, fills/uploads across it, mask, all blend modes) with native
VRAM identical to 1x, and runs it at 18x; gl_scale_limits_test covers the
window clamp; guards pin that it only engages past the full-VRAM limit.
Verified in R4 on an M4: the 8K preset renders 5760x4320 in a race (rear-
view mirror present) and 5760x8640 on a 480i menu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The OpenGL backend capped supersampling at 4, the software mirror's limit,
although its hr surface lives on the GPU. Raise the compile-time ceiling to
32 (gpu_render.h) and decide the real scale at context init:
- Query GL_MAX_TEXTURE_SIZE, GL_MAX_RENDERBUFFER_SIZE and
GL_MAX_VIEWPORT_DIMS and clamp S so the 1024*S x 512*S surface fits them
and a 2 GiB memory budget (PSX_GL_VRAM_BUDGET_MB overrides it). Apple's
GL on Metal reports 16384, so 16x is the most a full-VRAM surface can be.
The clamp is logged with the limit that applied.
- If the surface still fails to allocate, retry one scale lower inside GL.
Only a 1x failure drops the backend to software, as before.
- Draw lines above 1x as quads one native pixel thick covering both
endpoints. glLineWidth(S) is capped at 1 on core profiles, so every line
was one hr pixel thick (1/9 of native at 9x).
- Present a supersampled source with an area resolve when it is more than
1.25x the output (1200 lines into a 1080 window, 480i menus), and inset the
UV rect by half a texel instead of half a native pixel, which cropped and
zoomed the image by (S-1)/2 texels per edge.
- Size the copy scratch to the largest copy (copies stage at its origin) and
rebuild the mask stencil in tiles, only over the primitive bbox union, so a
large S does not cost another full-size surface.
- Refuse a native-wide surface wider than the driver limit (uncapped Fit at
a high scale) with one log line; the frame presents 4:3.
- main.cpp: the offline and dual-raster clamps no longer cap GL at the
software 4x. The CPU present path presents GL frames at 1x (its readout is
native) and its staging buffer stays sized for at most 4x.
- [video] supersampling accepts 1..32 in game.toml and settings.toml; the
runtime clamps per backend.
Every change is gated on S > 1: at 1x the renderer is byte-identical (same
native VRAM and presented framebuffer digests as the previous source on a
fixed GP0 scene).
Tests: gl_scale_limits_test (clamp arithmetic), gl_internal_scale_guards
(source guards + runner parsing), and gl_scale_invariance_test, a hidden
real-GL run at 1,2,3,5,9x that requires identical guest-visible VRAM at
every scale, one-native-pixel lines at internal resolution, and a 32x
request that stays on GL at the driver's clamp (16x on Apple).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/WIDESCREEN.md gains an "Explicit screen-X cull sites" section (slti,
slti_lower, bltz, bgez, clip_edge_x_load, branch_keep: semantics, pairing,
guards, interpreter cap, cache identity) and replaces the stale launcher
"Adaptive" description: offer/offer_ultrawide/adaptive_view are parsed but
inert on PSX, and widescreen belongs to a title's mod catalog through
psx_mod_set_fixed_display_aspect / psx_mod_set_adaptive_display_aspect.
docs/config_schema.md lists the new keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session reset clears g_bezel_path, which only stops the next load.
gl_renderer_shutdown() deleted the context but kept s_bezel_tex, and
present_bezel() draws whenever that name is nonzero, so a lobby rematch
would bind the previous session's texture name in its new context.
Shutdown now drops it through gl_renderer_set_bezel(NULL, 0, 0) while
the context is still current. Only a soft return to the lobby and
process exit shut the renderer down.
The source guard checks the shutdown call and set_bezel's NULL path.
MOD_PACKAGES.md says what each half of the bezel reset does and gives
the exact reset values for the adaptive cap and the native pacing rate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frame_fingerprint's wr column is an ordered hash over main-RAM writes.
DMA and device writes (MDEC-out, CD, SPU, GPU->RAM) are recorded in host
device-service order. Native shards flush cycles at every store barrier,
while batched interpreted and static code services devices after the
block. So identical guest writes gave a different wr forever after.
- ws: an order-independent sum of a splitmix64 term per (addr, val) over
main RAM. Together with wc it is the main-RAM judge column. wr and pc
stay as ordered locators.
- qc: fingerprint-eligible writes that FMV-quiet kept out. If a straddled
write falls into a quiet frame in one run only, the cumulative columns
never agree again, and qc is where that shows.
- The accumulator moves to runtime/include/frame_fingerprint.h so the
semantics are unit-tested (frame_fingerprint_test).
- The JSON buffer is sized from the real maximum entry length (284 B),
which the old 224 B per entry could overflow.
docs/TCP_COMMANDS.md documents the column roles, the one-frame snapshot
straddle and the FMV-quiet caveat (set PSX_DEBUG_FMV_QUIET=0 for A/B
identity runs). Existing columns keep their meaning. Nothing outside
debug-tools builds changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MOD_PACKAGES.md: which mod-owned host state the runtime resets at each
session start and when (every session, or later sessions only), what is
exempt and why (the renderer and fixed aspect are launcher controls;
guest/GPU-DMA memory, texture-packet arenas and banks live for the
process), and what the lobby rematch, the one in-process second session,
does not re-run: activation and the controller, load and disc-speed
resets. Function-entry hooks are described through the existing
plan-gated contract (commit and netplay clear empty the table; only
activation rebuilds it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New projects describe themselves to community lists (recomp.board et al.)
via .recomp.json at the repo root: game, system PS1, type recomp,
toolchain psxrecomp, status exploring, and original region + serial.
write_recomp_json.py takes the serial from disc_probe.json and derives
region from the Sony serial prefix, falling back to --region (which
defaults to USA and would mislabel PAL discs). It only fills missing
fields, so re-running it never clobbers a maintainer's edits.
Both gitignore templates ignored /*.json at the root, which would have
silently dropped the file from the initial commit; un-ignore it.
Closes#406
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>