dirty_ram_text_native_ok_ranges_from clipped validation to
[exec_pc, end), assuming a continuation never fetches bytes behind its
resume PC. False for any entry with a backward edge: a post-call
continuation re-enters mid-function and loops back into the clipped
region, executing stale static code. CMR2's overlay engine carries a
second copy of the vertex-transform kernel at 0x80016B34 that forked
exactly this way (r16 +0xA0 static vs +0x100 live, the same 0x60
delta as the first locus) after the inter-piece transfer guards
closed the fallthrough edge.
Validation now covers every emitted range of the entry regardless of
resume PC. A genuinely patched prologue blocks the whole entry to the
dirty-RAM interpreter -- correct, merely slower.
A game that overlays its own text at runtime (CMR2 streams transform-
loop variants with patched projection constants over the boot EXE)
kept executing the stale static translation through inter-piece host
transfers: dispatch validates an entry's emitted ranges, but split
function pieces chain via direct host calls (fallthrough to split
piece / next function, legacy non-CPS split branch/jump/continuation
transfers, non-CPS direct jal) with no revalidation. A clean entry
piece falls through natively into a stale sibling whose live bytes an
overlay replaced -- the CMR2 attract/gameplay wedge corruption
(first divergence: one ADDI immediate, screen-center 160 static vs
256 live, at 0x800199D4, retirement 3,203,596,289, frame ~11963).
Emit at every such transfer:
if (!psx_game_text_native_ok(T)) { cpu->pc = T; return; }
publishing the PC and unwinding to the trampoline, whose dispatch
takes the sanctioned dirty-RAM-interpreter fallback over live bytes.
The non-CPS direct jal falls back to call_by_address instead.
dirty_ram_text_native_ok_ranges_from gains a page-clean fast path
(skip the memcmp when no touched page is guard-modified or runtime-
dirty) so the guards are near-free on pristine pages.
cosim.py: print full A/B cpu dumps in cpudiff mode (values, not just
diffs).
The cycle-keyed design hashed+parked at the first cosim_instr boundary
AFTER the stride cycle was crossed; the two backends can reach that
crossing at different retirement positions, so the oracle compared
different architectural states (observed as a +5-cycle park skew and
phantom CPU-register divergences mid-CPS).
Checkpoints are now taken inside cosim_instr, keyed on the retirement
count itself (a branch and its delay slot retire as one event in both
backends). Checkpoint k = state after k*stride retirements in BOTH
instances by construction. The guest-cycle clock stays inside the state
hash, so real cycle-accounting drift is still detected -- now pinned to
an exact instruction window instead of corrupting the alignment.
cosim.py: stride/--max/--start-cycle now in retirement units; the cycle
skew warning is now reported as a real timing divergence. launch() gives
instance B its own exe (the mods root is exe-dir-relative, so a shared
exe races on mods/state.toml publish regardless of cwd separation) and
refuses a stale COSIM_CWD_B exe.
Gates on CMR2 (SLUS-01222): compiled/compiled and interp/interp both
clean over 100M retirements with identical cross-pair chains; injected
1-bit RAM fault detected at the next checkpoint.
A savestate is whole-machine state. One taken on disc 2 and restored while
disc 1 is mounted resumes a guest that still believes it is reading disc 2,
and the slot list gives no hint: every disc of a set shares one entry_pc, and
entry_pc is the key the slot filenames already use. So the same slot number
meant a different machine on every disc, silently.
Savestates were already isolated per BIOS, as a directory. The disc joins that
as a FILENAME token inside it:
<saves>/<bios>/state_<entry>_disc<N>_slot<NN>.pst
The BIOS stays a directory because a state is invalid across BIOS images
whatever disc it came from. The disc only has to not collide within one, and a
token does that without another level to browse, back up and migrate.
An empty token leaves the historical name byte-for-byte, so single-disc titles
keep every existing state -- the scope is set only when the roster has more
than one disc.
States saved before this predate the naming and are not listed. They are left
alone rather than migrated: nothing in a .pst says which disc it was taken on,
so placing them would be a guess, and guessing wrong reintroduces exactly the
mix-up this prevents. Startup names them and the directory so they can be
renamed deliberately.
Verified: slot 1 saved on disc 2 and on disc 3 produced disc2/disc3 files side
by side with no collision; booting disc 1 and loading slot 1 rejected with
"missing state_800110C0_disc1_slot01.pst" rather than restoring another disc's
machine; the pre-existing untagged slot 0 was untouched throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194TYJAqgyG9a9PJe9uUcgQ
Launching from an external frontend with disc 2 or 3 chosen opened the game
on disc 1 every time, and the launcher preselected disc 1 with it.
settings.toml carried the right answer:
[disc]
path = ".../Final Fantasy VII (USA) (Disc 2).cue"
with no `selected` key. The frontend writes the index only when it can work
out the roster position, and it compares its stored choice path to the disc
path exactly -- a .cue/.bin or normalisation difference misses, so it writes
`path` alone and leaves the index out.
The resolver then treated a MISSING key as selected = 1 and did what it was
designed to do for a stale path: overrode disc 2 back to disc 1. `path` alone
selected a disc correctly before the index existed, so this was a regression
introduced with the index, not a pre-existing gap. A new field may add a way
to choose a disc; it must not take away the old one.
Now the index decides only when it is present. Absent it, the path decides and
the index is derived from it (stem-compared, because everything downstream has
been through normalize_disc_path_for_launch, which swaps a .cue for its .bin
-- exactly the drift that made the frontend's own match miss). An off-roster
path is not evidence for any disc and leaves the default alone.
Verified all three ways against the real install's state: path=disc2 with no
index boots SCUS-94164 and the launcher preselects Disc 2; path=disc2 with
selected=3 still boots SCUS-94165, so an explicit index keeps overriding a
stale path; the roster-derived index keeps a later save writing a consistent
pair.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194TYJAqgyG9a9PJe9uUcgQ
Folds the two runtime fixes that were also open as their own PR (#260,
fix/host-frame-pacing) into this branch:
- runtime: don't fast-forward while the window lacks input focus
- runtime: pace the frame even when driver vsync should own the cadence
Both branches already shared this branch's base (d14ca691, c481b6cd,
10f443eb), so the merge adds only those two. They are patch-identical to
#260's commits (verified by git patch-id), so #260 is superseded rather
than dropped and can be closed without losing work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1TpXRJve6WvwyZbSJAbCL
This fix changes the value of `vblank_cycles` and `g_frame_period_ms` at startup to match what is expected if it detects the game is using a PAL disc image.
This change replaces the constant `VBLANK_CYCLES` with a global variable that can be edited at runtime. This allows the timing to be changed by other code, which is needed for PAL support.
In `main()` in the game runtime, `cdrom_set_disc_scex()` is never called due to an empty serial/game ID being passed to `identify_disc()`. This fix passes the correct ID. It also passes the correct checksum for good measure.
present_should_wall_pace() skipped the wall-clock pacer entirely whenever
present_vsync_owns_cadence() was true, trusting the GL swap to block. A swap
that does not block then leaves the guest with NO speed limit at all and the
game simply free-runs -- indistinguishable, to a player, from a stuck
fast-forward.
Measured on FF7 (NVIDIA GL under a compositing WM), 45 s, nothing held,
telemetry off:
driver vsync "owns cadence" 4771 frames ~106 fps 1.77x
PSX_VSYNC=0 (pacer forced on) 2651 frames ~58.9 fps 1.00x
this change, default settings 2643 frames ~58.7 fps 0.98x
Running the pacer underneath vsync is safe because frame_pacer_wait() is
deadline-based rather than a fixed sleep: when the swap has already consumed
the period, now >= next_deadline and it returns immediately after advancing
the deadline. So it costs nothing where vsync works -- and in particular adds
no latency to the healthy path, which is why vsync was given sole ownership
in the first place -- while supplying the cap where vsync does not block.
The frame-interpolation exclusion is kept: that path subdivides this same
interval and would genuinely double-pace.
The startup line is corrected with it. "wall-clock pacer skipped" is how a
bring-up session decides pacing is accounted for, and it is what sent this
investigation hunting a phantom stuck fast-forward instead of an unpaced
present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194TYJAqgyG9a9PJe9uUcgQ
Hold-to-turbo could run with no key held, indefinitely, and the game would
sit at the fast-forward multiplier until something disturbed it.
SDL_GetKeyboardState() returns a CACHED array that SDL updates from events.
A key whose key-UP is delivered to a different window stays latched there
with nothing to clear it -- and the canonical way to move focus away is
Alt+TAB, where TAB is exactly the fast-forward bind. Regaining focus makes
SDL reconcile the array, which is why the symptom appears to stop as soon as
you click back into the window and press anything (the report that surfaced
this blamed the FPS-readout key for "stopping the turbo"; it was the focus
change that came with reaching for it).
Gate the held-key read on real input focus. This is independently correct:
hold-to-turbo must not run while the player is typing in another
application. Edge-triggered hotkeys are unaffected -- they go through
host_keymap_match() on real key EVENTS, which are only delivered to the
focused window in the first place.
Measured on FF7, 45 s of gameplay with nothing held, telemetry off:
9678 guest frames before, 4771 after. Attribution was confirmed by changing
PSX_FAST_FORWARD_SPEED, which only manual_fast_forward_multiplier() reads and
which is only reached inside this branch -- the speed tracked it, so the
branch was demonstrably executing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194TYJAqgyG9a9PJe9uUcgQ
recomp-ui takes a host-supplied icon path (GameInfo.window_icon_path) so the
launcher window carries the same art as the game window. Publish the path
psx_apply_window_icon() already resolves, rather than letting the launcher
repeat the search — repeating it is what would let the two drift apart the
next time the candidate list changes.
Resolved once and cached: both the game window and the launcher ask, and the
answer cannot change within a run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194TYJAqgyG9a9PJe9uUcgQ
A fresh Final Fantasy 7 scaffold failed to build with SDL3/SDL.h file not
found on every translation unit that includes psx_sdl.h, even though CMake
had reported "using prebuilt/system SDL3".
cmake-clang-v1 compiles against its own sysroot and never searches the host
/usr/include:
#include <...> search starts here:
.../v1.0.14/sysroot/usr/include/...
End of search list.
so a host package can be found and still be unusable. Two paths hit that.
SDL3: the toolchain ships a compatible SDL3 under deps/lib/cmake/SDL3, and
runtime.cmake already prefers it — but only via _PSX_TOOLCHAIN_PREFIX_HINTS,
which was built purely from RETCOMM_TOOLCHAIN_DIR and friends. The setup
wizard passes -DCMAKE_C_COMPILER=<toolchain>/bin/cc without exporting any of
them, so the hint list was empty and find_package fell through to
/usr/lib/cmake/SDL3. The compiler's own location is the hint that cannot be
forgotten, so it is now derived from CMAKE_C_COMPILER as well.
Vulkan: find_path landed on /usr/include, and CMake drops that directory from
include lists (adding it explicitly would disturb the system header order), so
the -I never reached the compiler at all. The block already knows how to build
gpu_vk_renderer.c as an inert stub when Vulkan is absent; it just never got
there, because "found" was decided by a path existing rather than by the header
compiling.
Both now verify before they believe, via a _psx_header_compiles() helper that
actually compiles the header. It takes LIBRARIES for an imported target and
INCLUDES for a bare directory, and the distinction is load-bearing: the first
attempt read INTERFACE_INCLUDE_DIRECTORIES off SDL3::SDL3, which is NOTFOUND
because SDL3 carries its headers on a transitive SDL3::Headers target, so the
check rejected a perfectly good SDL3. Asking through CMAKE_REQUIRED_LIBRARIES
resolves the interface transitively and discriminates correctly: the
toolchain's SDL3 passes, the host's SDL3 under this compiler fails.
Vulkan falls back to the stub it already had. SDL3 fails loudly instead,
naming the toolchain copy to point SDL3_DIR at — silently switching to
FetchContent there would collide with the SDL3::SDL3 target find_package has
already defined, and a wrong SDL is worse than a stopped configure.
Verified on the Final Fantasy 7 project that surfaced this: from a clean build
directory, with no overrides and with every toolchain environment variable
unset, configure selects the toolchain's SDL3, reports the Vulkan stub with the
reason, and psx-runtime builds and links (321/321).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AwqKPQFnK3x7xQbK4PQKj
The runtime's filename was derived three times from three copies of the same
window title. runtime.cmake sets OUTPUT_NAME from
MAKE_C_IDENTIFIER(WINDOW_TITLE); psxrecomp_cli.py rebuilt it from --exe-name;
and the in-runtime self-compiler rebuilt it again from
codegen_setup.exe_basename. The rule is identical in all three, so they agree
right up until a game is renamed and only one copy is refreshed. After that the
build links <new>.exe while both consumers look for <old>.exe, and the failure
is reported as
build succeeded but binary missing: ...\<old>.exe
Build failed. Fix the errors above, then rebuild manually.
on a build that had no errors at all, with the executable sitting in the build
directory under the name CMake chose. Seen on Revelations: Persona, where CMake
emitted Revelations__Persona_Recompiled.exe and the setup host wanted
Revelations_Persona__Recompiled.exe — same algorithm, colon in a different
place in each side's copy of the title.
runtime.cmake now writes the OUTPUT_NAME it actually set to
<build-dir>/psxrecomp_exe_name-<target>.txt, and both consumers prefer that
over their own derivation. Written at configure time with file(WRITE) rather
than file(GENERATE): _psxrt_exe_name is a plain variable carrying no generator
expression and does not vary per configuration, so one fixed filename is
correct on multi-config generators too. The marker holds the name only, never a
path — the consumers already know their build directory and only ever got the
basename wrong.
A build tree without the marker still falls back to exe_basename, so older
trees and foreign build directories behave exactly as before.
The error messages were misleading in both directions and are now specific: a
name disagreement says the build succeeded and names both sides, while a
genuinely absent binary lists the executables that are present. The PGO
training path resolved the binary the same guessing way and now shares the
resolver.
Adds test_runtime_exe_name.py, which pins all four behaviours against the real
Revelations: Persona names, and relaxes the GAME_PROJECT_SETUP.md note that
previously just warned people to keep the two values in sync by hand.
Verified: the resolver against a real Ninja build tree whose OUTPUT_NAME and
marker were produced by the same two lines runtime.cmake uses, including the
exact stale-name case that failed in the wild; test_cli_project_packaging still
passes; the self-compiler change compiles clean. The runtime.cmake edit itself
was exercised in an isolated reproduction rather than a full runtime configure
— this checkout has no BIOS dump or generated game C to build one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AwqKPQFnK3x7xQbK4PQKj
Rewind was on for everyone and could only be turned off with PSX_REWIND=0 in
the environment. It now defaults off and is enabled from settings.toml
([video] rewind) or the launcher's Display card.
The cost it was charging silently: each snapshot is a whole machine — 2 MB main
RAM + 1 MB VRAM + 512 KB SPU RAM — serialized uncompressed into a buffer
malloc'd at a fixed 5 MiB cap that is never shrunk to the actual length, and one
is captured every rewind_interval frames (15 by default, tightening toward 4
while an FMV runs), each with a 128x96 thumbnail built pixel by pixel. At the
default depth of 50 a full ring is roughly 250 MB resident; at the maximum 200
it approaches 1 GB. That is a lot to hand a low-end host for a feature a session
may never open.
Turning it off genuinely avoids all of it: psx_rewind_configure() returns before
any allocation when rewind is not wanted, and both capture paths are guarded by
psx_rewind_enabled(). The only residue is a 450 KB BSS overlay panel whose pages
stay untouched.
The toggle applies live rather than at next launch — freeing the ring is the
whole point, so waiting for a restart would miss it. psx_rewind_shutdown()
already resets the overlay and drops a pending load, so tearing down mid-session
is safe.
PSX_REWIND still outranks the setting in both directions and now accepts 1 as
well as 0. Netplay rollback is a separate subsystem with its own ring and is
unaffected; rewind was already suppressed during netplay.
Because off is now the common case, psx_rewind_toggle() says so on the OSD
instead of leaving F8 / Select+R3 looking broken. It stays silent only in a
-DPSX_REWIND=OFF build, where there is nothing to enable.
Adds rewind_settings_test (default off, explicit off distinguishable from
absent, save/load round trip, tuning keys independent of the enable) and the
first documentation the feature has had, in config_schema.md.
Verified: recompiler tests build and pass; main.cpp type-checks against the new
recomp-ui ABI field; psx_rewind.c compiles both with and without
PSX_HAS_RBENGINE_SNAP; the existing test_rewind_toggle_combo.py still passes.
Not run end to end — this checkout has no BIOS dump to boot.
Needs recomp-ui a31ecbc for Settings.rewind_enabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AwqKPQFnK3x7xQbK4PQKj
Game projects currently immediately crash upon entering main() due to 0 being passed for `size` when calling `std::setvbuf`. This is allowed by GCC, but MSVC explicitly requires this value to be between 2 and `INT_MAX` (https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/setvbuf?view=msvc-170). Changing this 0 to BUFSIZ fixes the crash while retaining compatibility with GCC.
Port the launcher-facing hidden feature plumbing from rbengine-bak PR #207 without resurrecting the removed 8 MB RAM manifest on master.
Source commit: 45253daf9a
Port the backend-invariant function-entry hook behavior from rbengine PR #156 without pulling the broader coupled exact-NCLIP/clamp-rescue commit back into master wholesale.
Source commit: 249057f93f
ws_ui_prepass_add admitted only the TEXTURED rectangle/sprite families, and
only the textured draw paths carried a widescreen transform. Every flat
-coloured HUD mark was therefore left at its raw 4:3 X while the textured
primitives of the same widget were squashed toward their anchor -- so at a
wide aspect it is stranded in open screen, to the left of the cluster it
belongs to. In 4:3 it sits on its neighbour's edge and reads as part of it,
which is why this is invisible until the frame widens.
Admit the whole GP0 rectangle range instead, decoding size from bits 4-3 and
textured from bit 2, and wire the matching transform into gp0_exec_mono_rect,
gp0_exec_mono_dot, gp0_exec_mono_8x8 and the inline mono 16x16. A GP0
rectangle is always screen-space and never carries GTE output, so this cannot
reach world geometry. The polygon families are deliberately NOT admitted here:
a mono or gouraud quad CAN be projected world geometry.
ws_ui_groups also now reports the geometry of the primitives the max_rank
filter discards, not just how many. That distinguishes "stray world geometry,
fine to drop" from "HUD mark whose cluster is being squashed without it". In
race at 32:9 the drops are all small far-rank scenery (op 2c/3c, 2-9 px, ranks
0-576 against a max of 1073), which rules the filter out as a source of
stranded HUD marks.
Confirmed in-race at 32:9: HUD unchanged at 7 runs with the same anchors, so
admitting the untextured families disturbs no existing grouping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 2cecfccb58)
Three changes, one fix and two pieces of visibility.
1. ws_ui_group_assign gains a third join: consecutive in the ordering-table
walk AND touching. WipEout 3's speed/shield readout ends at x=288 and the
meter bar it belongs to starts at x=306 with a different CLUT, so neither
shared columns nor a matching key could join them. The readout stood alone,
its bbox midpoint 256.5 landed in the middle third against a screen centre of
254, and it stayed pinned near centre while its own meters went to the right
edge -- a 2 px gap in 4:3 opening to 159 framebuffer pixels at 32:9.
No anchor threshold can fix that: the genuinely centred "pit ... check"
message sits FARTHER RIGHT (midpoint 300.5) than the mis-anchored readout, so
any threshold reaching the readout drags the message with it. Submission order
is the only signal that separates them, and the array already carries it.
Simulated against seven captures spanning three aspects and two code states,
this forms three unions and changes exactly one anchor: [225,288] centre
becomes [225,486] right. The other two unions ([208,240]+[240,361],
[364,447]+[450,473]) are within an anchor. Confirmed in-race at 32:9: 7 runs,
lap readout left, speed readout and meters together on the right.
2. ws_ui_groups reports why primitives were NOT admitted to the partition.
Each gate in ws_ui_prepass_add is a silent return, so a primitive left at its
raw 4:3 X while its neighbours are squashed -- which is how a HUD mark ends up
stranded mid-screen -- was indistinguishable from one never drawn. In-race at
32:9: opcode 367, not_axis 410, everything else 0.
3. geom_correction reports perspective-UV arming with a real denominator.
perspective_triangles could previously only be compared against gp0_draw,
which counts untextured primitives that are correctly never armed, so it read
as a coverage figure without being one. texcorr.attempts counts exactly the
textured triangles reaching the predicate. In-race at 32:9: 199599 submitted,
96.15% armed, 3.85% rejected for a missing per-vertex Z, 0% for correction
disabled or a missing packet address.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 572b9210d8)
The thirds anchor is chosen per union-find run, and runs only joined when the
group key matched within JOIN_GAP. That key folds CLUT, texpage, a 24px Y band
and the poly-vs-rect family together, so a digit and the box it is drawn on
could never land in the same run. They got independent anchors and were
squashed about different points, which pulls one piece of a readout toward a
screen edge and leaves the rest behind -- worse the wider the frame.
On WipEout 3's race HUD at 32:9 the lap readout draws its digits at
y=[219,227] x=[137,202] and the box they label at y=[228,244] x=[138,202].
The digits anchored left, the box anchored centre, and the box landed about
500 screen pixels from the digits it belongs to.
Join on being one visual element as well as on the key: share screen columns
and touch vertically within STACK_GAP. Both halves are needed. Column overlap
alone merged the top-left lap counter with the bottom-left lap timer 200
scanlines below, and since union is transitive that chained all 71 HUD
primitives into one run spanning the full width, which anchors centre and
drags the corners inward. A strict row intersection missed the stacked case
above, whose seam is one pixel.
WsUiGroupItem carries y/height for this; the anchor stays horizontal. After:
9 runs, bottom-left readout whole and anchored left, timer and energy bar
right, speed centre -- matching the 4:3 layout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 72196d3dc9)
auto_ui_squash squashes each spatial run of UI primitives about that run's own
anchor, so a HUD element split across two runs gets two anchors and comes apart
as the frame widens -- elements drifting toward opposite edges, glyphs sliding
off the background box they sit on.
Diagnosing that required knowing which run each primitive landed in, and nothing
exposed it. The two things an observer could reach were both insufficient:
group.key is a hash of CLUT/texpage/Y-band/family, so unequal keys say two prims
differ without saying WHICH component differed; and the anchor takes only three
values (left/centre/right thirds), so equal anchors do not prove two prims
actually co-grouped rather than coincidentally landing on the same third.
ws_ui_groups dumps, for the last prepass, each primitive's op / key / raw key
inputs (y, h, and the derived 24px band and poly-vs-rect family) / union-find
root / final anchor, plus the frame-level active, squash, dense, rank, disp_x,
disp_w and join_gap. Root plus raw inputs together answer "did these two merge,
and if not, what split them".
Carries two small support changes:
- WsUiGroupItem gains a diagnostic `root`, written by ws_ui_group_assign in all
three paths (dense, alloc-failure fallback, and the union-find path). Never
read by the transform path.
- WsUiPrepassItem retains y/h/op, which the key otherwise folds away.
Read-only and query-driven, per the ring-buffer rule: capture is continuous and
the observer asks for a window later.
(cherry picked from commit 6de1ad4a05)
screenshot_hires passed the pixel width as out_pitch, but the resolves
(sw_render_display / sw_render_display_hires) step rows in BYTES, as the
present path does (main.cpp: sw * sizeof(uint32_t)). Each row advanced a
quarter of a row, tiling the frame 4x across and leaving the bottom three
quarters black. Verified on WipEout 3: content rows 65/256 -> 256/256.
The same handler could also emit untouched allocation: gr_scale() reports
the GL backend's internal scale, but sw_render_display_hires falls back to
the native resolve when the CPU hi-res mirror does not exist, filling w*h
of an ow*oh buffer while still returning non-zero. The buffer is now
calloc'd and the resolve must report FULL cover or the capture redoes
itself honestly at native size.
Add present_shot / present_shot_seq: a capture of the COMPOSED present
surface. Every existing capture resolves the display buffer BEFORE the
backend fits it to the window, so on a 508x256 display in a 4:3 window
they answer 508x256 while the player sees 640x480. That is correct for
faithfulness work but wrong for anything aspect-shaped -- a widescreen
change moves the GTE squash and the present fit, which is exactly the
stage those captures skip.
Fulfilled by whichever backend owns the present: SDL_RenderReadPixels on
the software path, glReadPixels before SwapWindow on GL (with the
bottom-up flip). Notes on the contract:
- GL fulfilment can run on the frame-interpolation thread (interp_present
-> gl_swap_with_osd), not just the emu thread, so the path buffer and
pending flag are mutex-guarded and the counters are atomic.
present_shot_take() claims the request under the lock, so two present
paths cannot fulfil the same shot.
- A staged shot is OVERWRITTEN by a later request rather than refused,
as savestate's request_save_inner does. A present that never arrives
therefore cannot wedge the command.
- Vulkan presents through its own swapchain and has no readback hook, so
the request is refused there instead of being accepted and never
fulfilled (CLAUDE.md rule 15).
- present_shot_seq advances on success AND failure so a polling client
always terminates; its `wrote` field reports whether a PNG landed.
- The SDL2 branch sizes its buffer from SDL_GetRendererOutputSize rather
than a viewport*scale reconstruction, which could be smaller than the
region SDL_RenderReadPixels fills.
Also: debug_client gains positional-path screenshot_file/present_shot and
turbo/turbo_state mappings (they previously fell through to the raw
key=value handler); TCP_COMMANDS.md marks screenshot_hires as native (it
is registered on the native server and the blank N column read as
Beetle-only), documents the two new commands, and the generated index is
regenerated via tools/gen_tcp_commands.py.
Tested on WipEout 3 SE (PAL, OpenGL, RelWithDebInfo + PSX_DEBUG_TOOLS=ON):
capture matches the on-screen window at 1280x960; seq/wrote reports 1 on
success and 0 for an unwritable path; back-to-back staging is accepted.
The SDL software path, the SDL2 branches and Vulkan refusal are
compile-verified only.
(cherry picked from commit 8fee64f178)