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>
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>
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>
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>
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>
The runtime mounts a .chd natively and the launcher lists it in the disc
picker, so a player could select one and see a valid disc identity, then have
first-run setup fail: verify-disc, prepare_disc.py and probe_disc.py hash and
parse raw 2352-byte track data, which a CHD stores compressed. Nothing on the
Python side could read it, and telling players to install MAME for chdman is
not an answer.
libchdr is already vendored and compiled for the runtime and the native media
CLI. Build it once more as a shared library in recompiler/ (CMake target chdr,
output next to the emitters, static CRT on Windows so a plain python.exe can
load it) and read the CHD from Python through ctypes. tools/psx_chd.py mirrors
the layout the runtime's iso_reader.cpp uses -- 2448-byte frames, 4-frame
track padding, stored versus virtual pregaps, big-endian audio -- and
reproduces the .bin files a Redump dump of the same disc contains, in either
of Redump's two layouts (one bin per track, or one bin for the disc).
verify-disc hashes those reconstructed track bytes against [prepare_disc]
instead of the container. prepare_disc.py writes the tracks into out_dir in
whichever layout the recorded digests match and then continues down its
normal cue path, so the data track is written once, SBI companions are looked
up next to the CHD, and the receipt names the CHD the player chose.
probe_disc.py extracts to a scratch directory and probes the cue. The CLI
builds target chdr on demand for a kit whose emitters predate it, and falls
back to the extract-it-first message when it cannot.
Verified against two discs from a Redump library converted with chdman: a
single-track MODE2 disc and a four-track disc with stored audio pregaps both
reproduce their catalogued track-1 size, MD5 and SHA-1 exactly; verify-disc
and prepare_disc.py accept the four-track one end to end. Tests build a
synthetic uncompressed v5 CHD around a cooked ISO image, so no retail data is
needed; the decode tests skip with a reason when no libchdr is built.