Native-wide mirrors now ride the window queue on the full-VRAM surface too
(wide_queue_live), share the queue tail (hiw_flush_tail) and scissor per
command; the guards check those forms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gl_internal_scale_guards checks the hr-window handling where it now lives
(present_vram_impl, present_wide_fbo_impl, the float present_target_quad);
dyn_apply refuses outright in the windowed high-resolution mode, which
dyn_eligible already excludes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
The high-resolution window made the GL backend's single scale two: the
scale of s_hr_fbo (1 in windowed mode) and the scale presented (up to
32). The previous commit kept s_scale for the presented one, so code
written against the old contract (s_hr_fbo at s_scale) still compiled
after a merge and read the 1024x512 surface at 18x coordinates in
windowed 8K. The frame-rate branches add exactly such reads (render-pass
capture, backup, journal and verify).
Rename the presented scale to s_out_scale, keep s_hr_scale, document the
contract at the declarations, and #pragma GCC poison s_scale, so every
use of the old name fails to build and must pick one: s_hr_scale for
s_hr_fbo and whatever backs it up or mirrors it, s_out_scale for the
window, the wide surfaces and captures of the presented image. A VRAM
transaction must also back up and restore the window's rows while
hiw_on(), or refuse to run.
No behaviour change: a mechanical rename. gl_scale_invariance_test
(real GL, window forced at 2/3/5/9/18x) passes; new guards check the
poison and that a function binding s_hr_fbo at s_out_scale also handles
the window.
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>
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>
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>
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>