tools/release_overlay_stage.ps1 exists because a packager that re-derived the
overlay cache tag in PowerShell went stale the moment the framework appended an
_f<flavor> field to it: the packager's filter matched nothing, a perfectly
regenerated cache staged ZERO shards, and the release shipped with no native
overlays (beads-eio.2.6). The fix was correct -- expose cache_tag() and route
the packager through it -- and it fixed Windows only.
Linux was never consolidated at all. Three title repositories each carry a
forked tools/package_appimage.sh, and each hand-builds the same tag string,
inside a python heredoc that has just imported the module that owns it:
spec = importlib.util.spec_from_file_location('co', mod_path)
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
print(<the tag format, reproduced by hand> % (m.codegen_ver(inc),
m.codegen_hash(inc), m.overlay_config_hash(exe, gt)))
MEASURED 2026-09-02, reading each repository's origin/master (not a working
tree -- the Ape checkout is parked on codex/ape-issue3-diagnostic-prerelease,
behind master, and reading it gives the wrong answer, including a wrong line
count):
repo lines calls cache_tag() composes _f stages toolchain
ApeEscapeRecomp 416 no yes no
Tomba2Recomp 436 no NO no
MegaManX6Recomp 408 no NO no
ApeEscapeRecomp 4a17272 (2026-08-28, "release: stage flavored Linux overlay
cache") hand-appended the field to ONE of the three forks. It did not adopt
cache_tag(); it wrote cache_tag="${cg_tag}_f${overlay_flavor}" and switched the
filters to it. So Ape is correct today and one character from the same failure
on the next tag-shape change, and the other two are broken right now:
find -path "*/$cg_tag/*" requires a separator immediately after the tag, the
real directory is named ..._f0, so the pattern cannot match. shards evaluates
to 0 and the packager exits 1.
That is not hypothetical. Tomba 2 v0.0.9, released 2026-09-02, has exactly two
assets: Tomba2Recomp-v0.0.9-windows-x64.zip and its .sha256. There is no
AppImage in that release at all, which is what a packager that exits 1 looks
like from the outside.
Two implementations, one per platform, have to be kept in step by review. This
bug is the proof that review does not hold: the identical defect had ALREADY
been fixed on the other platform, and the reason cache_tag() exists is that
fix. So there is now ONE implementation.
tools/release_stage.py the implementation (new)
tools/release_overlay_stage.ps1 thin wrapper, surface UNCHANGED
tools/release_overlay_stage.sh thin wrapper, new (Linux had none)
Both wrappers are argument marshalling. Neither contains a tag format string, a
shard filter, an extension list, an arch-abi segment or a catalog rule. Every
sh function forwards "$@" verbatim, so a new option needs no edit there at all.
No title's package_release.ps1 needs a change: every parameter, switch and
return value of Get-OverlayCgTag / Add-OverlayCache / Add-OverlayToolchain /
Add-ModCatalog / Get-PinnedArchive is preserved, verified by dot-sourcing the
module in PowerShell 5.1 and exercising all five.
WHAT THE SHARED IMPLEMENTATION DOES DIFFERENTLY
* The tag is cache_tag(). Not "a call that produces the same string" -- the
call. release_stage.py does not know the tag's shape and must never learn it.
It also takes the arch-abi and the shared-library suffix from
compile_overlays.cache_arch_abi()/overlay_ext() rather than naming them, so
".so under linux-x64" and ".dll under win-x64" are one decision, made in the
module the loader is kept in lockstep with, not two.
* Shards are selected by the tag DIRECTORY'S IDENTITY, not by a path substring.
-path "*/<tag>/*" is simultaneously too loose (it accepts a foreign arch-abi)
and too strict (it rejected the real _f0 directory and staged nothing). The
staged layout is then asserted -- exactly one tag directory, and it must be
this build's -- because the loader scans cache/<id>/<tier>/<arch-abi>/<tag>/
literally, and a shard anywhere else is worth exactly as much as no shard,
which no count can distinguish.
* Selection is an ALLOWLIST of {<ext>, .ranges, .resident}, so a new artifact
kind in a producer's cache is excluded by default rather than shipped by
default, and the staged tree is re-scanned afterwards to prove that
.abi_<tag>.ok, *.c and *.pair-lock did not ship. The sweep memo is the one
that matters: it tells a player's first launch that an ABI sweep it never ran
already passed, so a stale shard gets loaded instead of rejected.
* --allow-no-cache is gone from the Linux surface entirely. It is the
relaxation that let a packager staging nothing read as a successful build.
What remains is --ship-without-overlay-cache-because '<reason>', which is
unwieldy on purpose, prints the reason, and is not the documented path in any
recipe. PowerShell's -AllowNoCache is kept (unmigrated title packagers still
pass it) and maps onto that flag, so even the escape hatch is one
implementation.
* The mod catalog is verified against the manifest the BUILD published.
runtime.cmake's _psxrt_stage_mod_catalog already writes
psx_mod_catalog_<target>.txt -- the sorted, deduplicated id list that build
staged -- which is a strictly better authority than re-globbing source trees,
and it handles Tomba 2's Italian catalog overriding framework psx.* packages
at the same ids without a per-variant number.
LINUX GETS AN OVERLAY TOOLCHAIN, AND THE RUNTIME CAN NOW SEE IT
No AppImage packager has ever staged one (grep -c overlay_toolchain = 0 in all
three forks), so on Linux the shipped AOT cache was all a player ever got: the
capture-and-compile fail-safe could not fire, and an overlay outside the cache
was interpreted forever.
Staging one alone would not have worked. runtime/src/main.cpp gated autocompile
on overlay_toolchain/python/python.exe, and built the command from
psxrecomp-game.exe and tcc/tcc.exe -- hardcoded Windows spellings, so on Linux
the gate could never be true no matter what a packager staged. A bundled Linux
toolchain would have been dead weight the runtime never looked at. Those three
names are now per-platform; the layout is identical, only the spellings differ,
and they are kept in lockstep with release_stage.py's TOOLCHAIN_PY_REL /
TOOLCHAIN_RECOMPILER.
The Linux interpreter pin is a python-build-standalone CPython 3.13.1 --
deliberately the same version as the Windows embeddable-zip pin -- fetched and
SHA256-verified on every use by the same code path as the Windows one, so the
platforms cannot drift on retry, cache or verification behaviour either. The
_stripped asset (21 MB download, 78 MB unpacked) is used; compile_overlays.py
needs only the stdlib, verified by import. tinycc publishes no prebuilt Linux
binary, so there is no pinned tcc there and the bundle drives a gcc from PATH;
main.cpp already says so on stdout rather than silently doing nothing. The
SHA256 was measured by downloading the asset and matching the publisher's own
.sha256 sidecar, not copied from a listing.
THE FLAVOR IS NO LONGER GUESSED
PSX_OVERLAY_FLAVOR is the high half of overlay_abi() and the _f<n> field of the
tag. It is a property of the BINARY, decided in runtime.cmake (0 base, 2 for a
PGXP target), and it is NOT platform-dependent -- Windows and Linux builds of
the same target share it, so nobody should ever add a platform branch for it.
Every packager simply assumed 0, and that assumption is invisible when wrong: a
PGXP runtime reads ..._f2/ while the packager stages ..._f0/, the package ships
a cache the binary ignores completely, and nothing fails.
runtime.cmake now publishes psxrecomp_overlay_flavor-<target>.txt next to the
exe-name publication it already writes, for the same stated reason: so nothing
downstream re-derives it. release_stage.py cg-tag takes either --flavor or
--flavor-from-build, and there is no default -- a packager either states the
value or reads what the build published.
TESTS
runtime/tests/test_packagers_never_format_cache_tag.py asserts the rule the two
fixes above were supposed to establish: the tag format appears only in
tools/compile_overlays.py and runtime/src/overlay_loader.c (measured: 5
occurrences, all inside those two files -- note overlay_loader.c has four, at
1745, 1773, 1924 and 1930, the last two being the actual read path) and nowhere
else, and a release packager that touches the overlay cache must reference the
shared staging surface. It carries a positive control -- each owner must
actually contain the pattern -- because the investigation of this very bug once
reported "no cache present" from a probe that could not have found a cache at
all. It is also the SHARED check a title runs against its own tree
(--root <repo>), so the rule about not duplicating logic is not itself
duplicated per repository.
Run against Tomba2Recomp origin/master it reports, pre-fix:
tools\package_appimage.sh:292 formats the overlay cache tag itself
tools\package_appimage.sh is a release packager but does not reference the
framework shared staging surface
runtime/tests/test_release_stage_cache.py pins the behaviour, hermetically, on
synthetic cache trees -- no recompiler, no captures, no build, which is the
point: this is the assertion that could have run in CI on the day the tag grew
a field. 15 cases, including that a tag one field short selects nothing AND
raises rather than warns, that a foreign tag namespace / arch-abi / the removed
sljit tier are not shipped, that the three forbidden artifact kinds never ship,
and that developer state (mods/installed, mods/state.toml) never ships.
Both are registered in runtime/CMakeLists.txt, so the configure-time
test-registration guard covers them and ctest runs them.
NOT DONE, deliberately: tools/package_release.ps1,
tools/package_release_macos.sh and tools/package_setup_host.sh each carry their
own hand-written mod-catalog staging. That is the same duplication defect one
layer over and they should route through stage-mods too, but it is a separate
change needing separate proof (macOS cannot be built or verified here), so the
new test scopes its packager assertion to packagers that touch the overlay
cache rather than pretending to cover them.
Bead: beads-eio.3.102
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turbo was keyboard-only ([KeyMap] Turbo, Tab). Add a third host shortcut the
launcher can bind to a pad button or chord, next to Rewind and Save states:
- main.cpp: PSX_ASSIST_BIND_FAST_FORWARD + "Fast-forward" label; default
chord select+L1 (PSX_HOTKEY_PAD_SELECT_L1 = 1528); the manual fast-forward
block now runs while either the keyboard key or the pad binding is held,
through the same hotkey_pad_binding_down() matcher the other two use.
Threaded through every settings hop (settings.toml -> globals -> launcher
seed -> LauncherSettings -> back), mirroring save_state_menu_pad.
- config_loader: [hotkeys] fast_forward_pad read/write, same pad_bind_value_ok
bound as the siblings.
- test_rewind_toggle_combo.py guards the new sites; README documents the chord.
Launcher side: recomp-ui draws every advertised host shortcut instead of the
first two (companion PR).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Slicing the GPU linked-list DMA across guest cycles (8af48ae9, PR #277) also
changed what a walk costs: 8 clocks for every node header plus 5 setup clocks
per non-empty node, where the pre-slicing path charged one clock per word via
schedule_delayed_complete(2, actual_words, DMA_GPU_CYCLES_PER_WORD).
An ordering table is mostly EMPTY nodes -- Final Fantasy VII's runs to
ot_max 4094 -- and each empty slot still paid the full 8-cycle header for a
packet that does not exist. Measured on FF7 (SCUS-94163):
4,807 nodes / 12,757 words 52,384 cycles vs 17,564 (3.0x)
44,010 nodes / 117,507 words 499,187 cycles vs 161,517 (3.1x)
An NTSC frame is 564,480 cycles, so a single walk reached 88% of a frame.
FF7's libgpu polls CHCR bit 24, finds the channel still busy after 790-890
reads, and force-clears the start bit at 0x800484F0 -- the documented
stuck-channel path. cancel_async_transfer() then abandons the walk partway
through, and the rest of the frame's geometry is never sent to GP0. In a
battle 12.6% of ordering tables were aborted this way; models rendered with
missing or shattered limbs.
The guest is behaving correctly: it inspected the busy bit before clearing it.
The slicing is also correct and worth keeping -- a guest really can change a
packet after starting DMA and before DMA reaches it, and only a sliced walker
models that. Only the cost was wrong.
Header is one clock, setup is free, so a walk costs exactly (nodes + words).
When setup is free the payload boundary is the same service as the header, so
the header phase falls through instead of returning; cycles_to_event() never
reports zero, and returning would spend an unaccounted scheduler cycle per
non-empty node. The packet is still read at its own boundary, so #277's
mid-walk mutation behaviour is unchanged and its test still passes.
(nodes + words) is not a new assertion -- it is the cost this runtime shipped
and was validated against. A non-zero per-node overhead may well be real on
hardware, but it needs a measurement behind it, not a substituted constant.
Observability, because none of the above was visible:
dma_state gains a gpu_ot block -- starts, completes, cancels, starts_dropped,
nodes/words/cycles of the last walk and the max, plus guest reads of CHCR(2)
and a ring of the last 8 aborts carrying the guest PC, CHCR value, how far
the walk got, and how many times the guest polled before giving up. The
silent early return in start_async_gpu_linked_list() is now counted as
starts_dropped rather than losing an ordering table with nothing said.
PSX_GPU_LL_SYNC=1 drains a walk at start. Diagnostic A/B lever only: it
completes immediately as well, so it is not a faithful restoration of the
pre-slicing timing and must not be used as a fix.
The timing test now asserts the cost model rather than describing it: a table
of empty nodes must cost exactly one cycle per node. That is the check that
would have caught this.
Verified on FF7 SCUS-94163 from a save state: cycles_max 58,933 -> 16,972,
a 144-node/490-word walk costs exactly 490 cycles, and battle geometry renders
correctly with the walk still sliced.
Not fixed here: the same commit also broke gpu_frame_dump's func attribution.
GP0 writes now issue from dma_advance() on the cycle scheduler, so every
primitive is stamped with whatever the scheduler interrupted -- all 1,803
primitives in a captured battle frame attributed to BIOS 0x1FC02B50 instead of
the issuing game function. That disables gpu_frame_diff's "a function stopped
drawing" verdict and gpu_frame_layers entirely. Stamping the DMA initiator was
tried and does not work: g_debug_current_func_addr reads 0 at kick time. The
ra column still carries real game addresses as a fallback. Left as a separate
issue rather than shipped as a fix that does not fix it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9v7e6AVZe3eRP8VXey9Pz
Ape Escape declares `default_mode = "analog"` + `lock_mode = true`
(ApeEscapeRecomp/game.toml:208-209) and shipped with the left stick acting as
camera and the right stick doing nothing -- i.e. the pad booted DIGITAL on a
dual-analog title, and because lock_mode hides the pad-mode selector there was
no UI with which to fix it.
The root cause is in recomp-ui and is fixed in the companion PR (see below).
This is the host side of the same defect: three separate places in main() where
a locked title's declared pad mode could be lost or where a keyboard seat's
configured mode was destroyed on its way past the launcher.
WHAT WENT WRONG, END TO END
---------------------------
1. recomp-ui launcher_model.c honours the lock at line 545-546
(`if (!pad_mode_selectable) pad_mode[p] = locked_pad_mode`) and then
clobbers it 15 lines later with an ungated keyboard coercion. Player 1's
device defaults to "keyboard" in a release build (main.cpp:11164), so
pad_mode[0] came back as 2 (D-Pad) from every fresh install.
2. `launcher_model_set_pad_mode` and the default-for-source helper both
early-return when the mode is not selectable -- correctly, since the mode
is locked -- so once the value was 2 nothing could move it back. Attaching
a real DualShock did not help.
3. Here, the launcher-exit readback took `player_mode[i] = ls.pad_mode[i]`
verbatim and then persisted it into settings.toml, so DIGITAL beat the
lock and kept beating it on every later launch.
4. `sio_set_pad_analog(slot, 0, ...)` then took the 4-byte pad_response_len
branch in runtime/src/sio.c: the pad reports no analog bytes, so the right
stick is dead and the left stick is folded onto the D-pad. Exactly the
reported symptom.
The clamp at main.cpp:11869 already existed and already covered two holes
(launcher-less builds, and a settings.toml written before the game declared
lock_mode). It runs BEFORE the launcher, which is why the launcher round-trip
was a third hole it could not see.
WHAT THIS CHANGES
-----------------
runtime/include/launcher_device.h gains
`resolve_player_mode_after_launcher(launcher_mode, lock_mode, locked_mode,
mod_override_mode)`: one pure function that fixes the precedence of the three
sources of truth for a seat's pad mode -- an active mod controller-mode
override, then game.toml lock_mode, then whatever the launcher returned. Both
launcher-exit readback loops call it, and both call it before their persistence
write, so an unsupported mode can no longer be latched into settings.toml.
The mod-override arm is not decoration. A bare `if (ctrl_lock_mode)
player_mode[i] = ctrl_locked_mode[i];` -- which is the obvious fix and was the
first version of this change -- breaks mod controller-mode overrides on locked
titles after a lobby soft return:
* `g_mod_controller_mode_override` is applied at main.cpp:13084-13085,
* the `session_reboot:` label is BELOW it at 13159,
* `goto session_reboot` (14914) therefore never re-runs the override block,
* but the soft-return readback loop does run.
Before this change an override survived a rematch only because it round-tripped
through `ls.pad_mode[i]`, seeded at the soft-return entry from the post-override
`player_mode[i]`. An unconditional clamp cuts that path and silently drops the
override. `psx_mod_set_controller_mode_override` is public plugin API
(runtime/include/mod_plugins.h:229) with no shipped caller today, so this is
latent rather than live -- fixed anyway, because the next caller would have had
no way to tell why their override stopped applying to a locked title after a
rematch.
Three destructive coercions are also removed, all of the same shape -- a
keyboard seat having its configured mode overwritten with DIGITAL:
* the two launcher-entry seeds (`ls.pad_mode[i] = (src == 1) ? DIGITAL :
player_mode[i]`) told the launcher a lie about what the seat is configured
for, and the launcher handed the lie straight back to be persisted;
* the launcher-exit readback did the same in the other direction.
None of them was load-bearing: `effective_player_mode()` (main.cpp:4359-4362)
already returns DIGITAL for any seat whose kind is keyboard, whatever the
seat's stored mode says, and every consumer of a seat mode goes through it or
through `effective_player_mode_for_sio()` -- `refresh_player_devices`,
`capture_pad_slot`, and the netplay sample path were all checked. So a keyboard
seat still presents as a plain SCPH-1080 while the keyboard is driving it; the
difference is that its stored mode is no longer corrupted for the pad that
arrives later.
Note the unlocked case was never as bad, and this is worth writing down because
it is what made the bug look title-specific: for a selectable title,
`launcher_model_set_source` -> `apply_default_pad_mode_for_source` re-defaults a
gamepad seat to Analog, so picking a device in the launcher repairs the value.
That is precisely the repair a locked title cannot perform, because both of
those functions refuse to touch a locked mode.
TESTS
-----
There was zero coverage for any of this. (ApeEscapeRecomp's own
`ape_stick_response_test` covers the stick RESPONSE CURVE, not mode or axis
routing, and lives in the title repo -- it cannot see this.)
* runtime/tests/test_launcher_pad_mode_resolve.cpp -- behaviour of the
resolver: a locked title yields its declared mode for every launcher value
including out-of-range ones (so persistence can never receive an
unsupported mode), an active override outranks the lock, and the "no
override" sentinel stays negative to match the direct apply site.
Registered as launcher_pad_mode_resolve_test.
* runtime/tests/test_launcher_pad_mode_wiring.py -- how it is WIRED: exactly
two readback sites call the resolver, the resolution precedes each
persistence write, the pre-launcher clamp still runs first, and
`player_mode[i] = PSXRecompV4::PAD_MODE_DIGITAL;` appears nowhere. Verified
to go red when the coercion is re-introduced at either site, not just when
the helper is deleted. Registered as launcher_pad_mode_wiring alongside the
other source-invariant guards in recompiler/CMakeLists.txt.
Both call sites were confirmed to be live code, not preprocessed-out, by
injecting a bad symbol name and reading the compiler errors back: main.cpp:12701
compiles under `-DRECOMP_LAUNCHER` and main.cpp:14665 additionally needs
`PSX_HAS_LOBBY_CLIENT` (i.e. `-DPSX_NETPLAY=ON`), so the second site is only
exercised in a netplay build and was verified in one.
Verified: launcher_pad_mode_resolve_test, launcher_device_roundtrip_test,
host_keymap_test, keyboard_pad_chord_test, sio_card_protocol_test all pass
(sio_dualshock_rumble_test is registered DISABLED); launcher_pad_mode_wiring and
every other python guard that reads main.cpp pass, except two that were already
red before this branch -- runtime_perf_diag_guards (registered DISABLED) and
launcher_vulkan_option, which dies in `Path.read_text()` with no encoding on a
cp1252 console. That last one is a real latent break in 13 guard scripts and
wants its own change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The emitter emits a direct psx_game_text_native_ok() stale-static guard at block
entry (code_generator.cpp:115) and before every direct jal (:2402). That function
is DEFINED IN THE GENERATED DISPATCH SHARD (main_psx.cpp:1620), which links into
the executable. An overlay DLL is a separate module and cannot resolve it, and
overlay_dispatch_preamble.c.inc had no shim for it.
Found by bumping Tomba 2 from a 254-commit-old pin to master and regenerating its
shard cache against the identical vault:
before bump : built OK 2799, FAILED 1
after bump : built OK 2233, FAILED 109
107 of the 109 were the same error:
ld.exe: undefined reference to `psx_game_text_native_ok'
collect2.exe: error: ld returned 1 exit status
This is the fifth instance of one class, and overlay_api.h's own version history
records the previous four:
v9 faithful-timing callbacks overlay code failed to link
v10 gte_read_ctrl GTE-heavy overlays failed to LINK without shims,
silently blocking new coverage
v12 rfe_mark_escape without the shim any rfe-containing overlay
fails to LINK
v14 gte_precision_store_word the rfe / gte_read_ctrl class again
Every time the shape is identical: an emitter gains a runtime call, the overlay
ABI does not gain the shim, and the only symptom is shards that quietly fail to
build. The affected code then runs interpreted forever and nothing at runtime
says so. Severity is coverage, not correctness -- but that is exactly the defect
that let Ape Escape ship 100% interpreted without anyone noticing.
Fix follows the v10/v12/v14 pattern exactly:
- overlay_api.h: append game_text_native_ok to OverlayCallbacks, bump
PSX_OVERLAY_ABI_VERSION 21 -> 22, add the version-history note
- overlay_dispatch_preamble.c.inc: shim forwarding to g_cbs
- overlay_loader.c init_callbacks: host populates it from the dispatch shard
The NULL guard deliberately returns 0, not 1. Returning 0 routes the guest
through the dispatcher -- slower, never wrong. Returning 1 would let an overlay
run native code the guard was explicitly trying to reject.
Follow-up worth doing, tracked as beads-eio.3.97: five instances of one class
means the process is the bug, not the symbol. The emitter knows every helper name
it can emit and the preamble enumerates every shim; a test comparing those two
sets would have caught v9, v10, v12, v14 and this one at authoring time instead
of at some future title's link step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
psx_spu_sample_event_service() and psx_spu_sample_event_cycles_to_next()
built a full SpuGlobalState snapshot (memset + ~40 register reads + three
24-voice loops) on every device-service call and every deadline recompute,
just to test one bit (SPUCNT & 0x40, SPU IRQ9 enable). Under an
MMIO-polling guest loop — every PS1 wait loop, loudest on the BoF3 Capcom
FMV whose LOGO overlay polls per tiny basic block — the snapshot was ~40%
of emu-thread wall time (gdb stack-sampled, clean boot, live FMV).
Add spu_ctrl_read() (one array load) and gate on it. Semantics identical:
same register, same bit.
Measured (BoF3 Capcom FMV, clean boot, headless vblank raises/s):
Debug build: 28.5 -> ~52
RelWithDebInfo build: 97.6 uncapped (~1.6x realtime; paced holds 60)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Threads Settings.scanlines / scanline_strength_pct from the recomp-ui launcher
Display card through the same paths as screen_kind: seed the launcher from the
current state, apply the player's choice back to the video globals (pushing it
to the GL renderer immediately), and persist it to settings.toml. All launcher
struct accesses are gated on RECOMP_LAUNCHER_HAS_SCANLINES so the runtime still
builds against an older recomp-ui.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an optional scanline effect to the GL present path: a soft sinusoidal
beam pitched to the PS1 display-line grid (one dark line per native scanline),
applied in both the present and interpolation shaders and gated by output
scale so it never shimmers below ~2x. Orthogonal to the crt_filter colour LUT
(the two compose).
Controls: [video] scanlines / scanline_strength (game.toml + settings.toml),
PSX_SCANLINES / PSX_SCANLINE_STRENGTH env overrides, an F6 hotkey
(HOST_KEYMAP_SCANLINES, rebindable via [KeyMap] Scanlines), and a `scanline`
TCP command for live A/B. Launcher Display-card wiring follows separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The static overlay path had no residency signal. overlay_page_gen only
advances for pages set in overlay_watch_bitmap, and the sole callers of
overlay_watch_set_range were the DLL loader's cand_register() and
rebuild_lazy_manifest_index() -- both inert for an AOT-static title.
So for a game dispatching through generated/overlays_static.c, every code
range was unwatched, overlay_watch_pagegen_sum returned a constant, and the
generation fast path in psx_overlay_static_code_matches answered every
dispatch after the first from its cached result. The CRC gate was therefore
consulted once per variant per process, not once per code change: the only
thing that ever cleared the cache was a savestate restore. A band that
swapped occupants kept dispatching the previously-validated variant.
Arm the watch over a variant's ranges on the cold path, before hashing them.
CD DMA reaches RAM word-by-word through psx_write_word, so an overlay load
now advances the generation of every page it touches and the next dispatch
re-hashes. Arming only sets bitmap bits and does not advance a generation,
so the gen_sum already computed for the cache write stays valid.
This is also the prerequisite for memoizing the resident variant per band:
a generation change is the load event, and until now that event never fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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