The GL renderer may skip SDL_GL_SwapWindow when the displayed rect is
unchanged, so a double-buffered 30 Hz game does not swap an identical
front buffer on the intervening vblank. The frontend calls present once
per guest vblank, so eliding a swap also elides whatever that swap was
blocking on.
That is only safe while the wall-clock pacer owns cadence, because the
pacer runs per present regardless of what the renderer does. When driver
vsync owns cadence instead (~60 Hz panel, present_vsync_owns_cadence()),
the pacer is deliberately not run -- the two are XOR to avoid
double-blocking -- and the swap block becomes the only throttle. An
elided frame then blocks on nothing, and a game presenting every other
vblank advances two guest vblanks per block: exactly 2x real time.
Measured on BreathOfFire3Recomp (SLPS-00990, 60 Hz panel, OpenGL):
guest vblank counter sampled against wall clock ran 120.4 fps / 2.01x
before, 59.8 fps / 0.997x after. Swaps per second are unchanged at 60
either way -- the fix removes a free-running guest frame, not a swap --
and per-frame GPU cost is unchanged (present_gpu_ms_avg 12.2 -> 12.6,
scene_gpu_ms_avg 4.5 -> 4.1).
Gate both skip sites (VRAM and wide-FBO paths) on the cadence owner,
alongside the existing OSD and interpolation-cadence guards. Only the GL
renderer has this optimisation; the software and Vulkan paths always
present and were never affected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
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