Commit Graph

363 Commits

Author SHA1 Message Date
Shokunin 2dc4e2d591 Test: pass interp_capture's origin and redrawn arguments in the GL fixture
The merge of #427 (58bdf987) kept the fixture's 8-argument
interp_capture(...) call, but #424 had already grown the function to 11
parameters (origin_x, origin_y, redrawn). The fixture is compiled when the
test runs, so CMake configures and builds cleanly and
gl_scale_invariance_test then fails in about 0.2 s with "too few arguments
to function call, expected 11, have 8".

Pass 0, 0, 1 (origin at the capture rectangle, frame redrawn), as the
pre-merge integration of #424 and #427 did. With it the test passes on
macOS arm64 (Metal GL 4.1): lines, masks, captures and render passes at
1x-18x, the window mode and both clamps, 0 failures.

Test-only; no runtime change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:04:53 -07:00
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 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 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 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 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 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
Shokunin bb2864bc60 Settings: an older launcher's Supersampling pick wins over a preset
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>
2026-09-28 20:52:23 -07:00
Shokunin ecda594996 Settings: never persist the PSX_INTERNAL_RESOLUTION override
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>
2026-09-28 20:52:23 -07:00
Shokunin c47a9059b9 GL: batch line quads above 1x with the flat triangles
Found in the R4 integration build: widescreen 21:9 (native-wide) at 4K ran
a Grand Prix race at 22-30 guest frames/s instead of 60 (26 with the
debug build's GL timer queries off). A CPU sample put
~57% of the emulation thread in glb_draw_line -> gpu_geometry ->
glDrawArrays -> the driver ending a render pass: every line was drawn on
its own, flushing the textured batch and, with native-wide, binding the
wide surface and the hr surface again for its mirror. R4 draws hundreds of
lines per race frame; at 9x each of those render-pass switches stores and
reloads a 9216x4608 surface.

Above 1x (full-VRAM surface, s_hr_scale > 1, not windowed) a line is
already a quad of two triangles (line_to_quad), drawn with the GEO program
like a gouraud triangle. It now joins the flat batch: same vertices, same
program, same painter order and batch keys (semi, mask). A line the
backdrop-stretch gate would widen keeps the immediate path, since the flat
batch mirrors unstretched. 1x keeps GL_LINES unchanged and the windowed
high-resolution mode keeps its own path.

- run_gl_scale_invariance (real GL; its fixture draws horizontal,
  diagonal, steep and side-by-side lines): passes, and all 48 digest lines
  of its 14 probe runs are identical to the build without this change.
- R4 21:9 at 4K from one race savestate (debug build, GL timer queries
  off, widescreen only): 26.1 -> 59.9 guest frames/s over 4-10 s; 4:3 at
  4K stays 59.9.
- test_gl_internal_scale_guards pins the gate and that the batched path
  issues no draw of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:23 -07:00
Shokunin 84dbc2982a GL: keep side-by-side buffers at full resolution; bound copy staging
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>
2026-09-28 20:52:23 -07:00
Shokunin f34f91cc25 Settings: Internal resolution presets (Native ... 8K, Match display)
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>
2026-09-28 20:52:23 -07:00