Commit Graph

373 Commits

Author SHA1 Message Date
Matthew Stanley 4683e923ea fix(overlays): retain executed dispatch demands after host rejection 2026-09-12 10:45:50 -07:00
Matthew Stanley 9576c6323f Refresh combined BIOS and dispatch validation against latest master 2026-09-11 23:39:47 -07:00
Matthew Stanley 2bcc7ae0ba Integrate original-file tagged relocation into AOT profiles 2026-09-11 23:03:37 -07:00
Matthew Stanley 860bd6a237 Add verified two-bit tagged MIPS relocation reader 2026-09-11 22:59:03 -07:00
Matthew Stanley a1e7db08e7 Preserve verified resident helper markers in AOT packages 2026-09-11 22:56:16 -07:00
Matthew Stanley c5390e4265 Merge kernel patch ranges PR #346 for combined validation 2026-09-11 22:53:07 -07:00
Matthew Stanley 736c002a1a Refuse AOT releases whose packaged runtime disables native overlays 2026-09-11 22:40:33 -07:00
Matthew Stanley 06a93dc8aa Forward widescreen hooks in native overlays and verify fallback intervals 2026-09-11 22:28:03 -07:00
Matthew Stanley fdd0bd8eeb Decode aligned LZSS banks for original-disc AOT coverage 2026-09-11 22:08:36 -07:00
Matthew Stanley 070c92c647 Add declarative original-disc structural inventory inspection 2026-09-11 21:27:53 -07:00
Matthew Stanley 0e06446310 Extract archive-relative sector extents for original-disc AOT 2026-09-11 21:11:25 -07:00
Matthew Stanley 52dd4dffbc Add verified packed sector-table AOT overlay method 2026-09-11 20:37:37 -07:00
Matthew Stanley 3a174fab9f Accept exact fragment recovery after an empty primary overlay scan 2026-09-11 19:33:31 -07:00
Matthew Stanley 51ea5559f2 Add method-based original-disc AOT release pipeline 2026-09-11 19:24:46 -07:00
Matthew Stanley c3daaaa7f0 Document disc-derived AOT sharding and preserve full producer extents 2026-09-11 16:28:01 -07:00
Kevin baca0a8a42 tools: find install-slot ranges and A/B what they buy, for any title
The install-slot range work is only trustworthy if a second title can
reproduce it, and the two tools that produced its evidence lived in one
game repo with that game's paths baked in. Moved into the framework and
generalised: every path defaults off --project-root (cwd) and the BIOS
profile's out_stem, and the exe is found by globbing the build tree,
because its name is derived from the window title and so differs per
title. Run either from a game project root.

kernel_patch_diff.py boots headless and diffs live kernel RAM against
the ROM copy it came from, joining each differing word to the compiled
kernel bodies. It reads the emitted PsxKernelPatchRange table, so it
separates a DECLARED patch (the verifier skips it, the body still runs
native) from one that genuinely unblesses a body. Without that split its
verdict line calls every declared range "these interpret", which is
exactly backwards once the ranges work. This is the tool you use to find
ranges for a new image or a new Psy-Q release.

kernel_patch_ab.py answers "what did declaring them buy". It boots the
same binary twice to the same frame, once with the ranges live and once
with PSX_KERNEL_PATCH_RANGES=0, which drops them and restores the
whole-body bless memcmp. Holding the binary constant holds the overlays,
the codegen and the disc constant too, so only the bless verdict moves.
Headless runs uncapped, so it normalises per frame rather than comparing
raw totals at different frame counts.

Two things it gets right that cost real time to learn:

- It does NOT discount the A0/B0/C0 vectors from the headline. An earlier
  version did, reasoning that they interpret on both sides and would mask
  the result. True on one title, false in general: on Mega Man X6 with
  OpenBIOS the B0 vector burns 2,361,470 instructions over 1,180,735
  entries with the ranges off and vanishes entirely with them on, because
  a clean bless verdict lets the vector body dispatch native. Discounting
  it hid a 130 insn/frame win. Kernel and all-interp are the headline;
  the bodies/vectors split is printed underneath as diagnosis.
- It fails fast on a wedge, detected as a frame counter that stops
  advancing while the debug server still answers, instead of burning the
  whole timeout. That is how a dispatch key inside a declared range was
  caught spinning the dispatch loop at frame 0.

debug_client.query grew an optional timeout so these can use it instead
of carrying their own transport; the 10 s default is too short for
dirty_ram_stats and a whole-window read_ram.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:57:01 -04:00
Matthew Stanley fe2c0046ba Recognize bounded MIPS switches with scheduled table constants 2026-09-11 13:38:06 -07:00
Matthew Stanley 2113defcfc Fix overlay Clang exports, PGO, and scaffold cache 2026-09-10 17:23:03 -07:00
Matt Stanley 3d10366fba Merge pull request #341 from RetroPortingToolKit/chore/retro-rename
Rename the product to Retro, and follow recomp-ui to the org
2026-09-10 13:52:01 -07:00
Matthew Stanley 2a909c6017 Ship the third-party notices with releases; add the maintainer notice
libchdr (BSD-3-Clause) and toml11 (MIT) are statically linked into the
runtime and both require the notice in binary redistributions;
runtime/licenses/ existed but nothing copied it into a package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 14:55:17 -07:00
Alex Vanderveen 922e8c5a07 Rename the product from RetComM to Retro
The launcher and studio ship as Retro Launcher and Retro Studio now,
from the RetroPortingToolKit org, so the old product name is gone from
display text, documentation and release asset filenames.

The split is by case, and it is not cosmetic. CamelCase RetComM was
always the product; lowercase retcomm and RETCOMM_ are identifiers --
the C++ namespace, the binaries, the data directories, the CMake and
environment variables, the Cloudflare service names, the readme markers.
Renaming those would orphan existing installs for no benefit, so they
are untouched: 1101 lowercase retcomm and 205 RETCOMM_ still stand in
the launcher alone.

Four things are deliberately still spelled RetComM, and three of them
would have been real breakage:

- RetComM-Data is CamelCase but it is a directory on disk --
  <exe_dir>\RetComM-Data\{runtime,config,data} for a portable install.
  Renaming it strands every portable user's config, toolchains and
  catalog cache.

- TechnicallyComputers/RetComM-Launcher in readme_has_launcher() is the
  historical half of a both-slugs test. Renaming it makes it a duplicate
  of the line above and sends every port built before the transfer back
  to reporting its launcher section missing.

- _LEGACY_LAUNCHER_RE matched "^## RetComM Launcher". A sweep renamed it,
  which would have silently stopped it matching every README already out
  there; it now accepts both headings.

- The portable-stub lookup gained the pre-rename filenames back as
  fallbacks, because old zips stay on the releases page and a rollback
  downloads one.

Release assets become Retro-Launcher-* / Retro-Studio-*. That is safe
for installed clients: pick_launcher_asset() gates on *linux*/*windows*/
*macos* plus extension and arch, not on the product name, and the
Windows portable stub is identified by its RCM1 trailer rather than its
filename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013DpaQqVguHFpBuCmdoFYWY
2026-09-09 17:32:19 -04:00
Alex Vanderveen 3b5bf48305 Follow the RetroPortingToolKit transfer in submodule URLs and tooling
recomp-net, retcomm-rbengine (now rbengine), retcomm-toolchains (now
RetroPorting-Toolchains), the catalog and the launcher moved from
TechnicallyComputers to the RetroPortingToolKit org. GitHub still
redirects the old repo URLs, so nothing is broken today — but this repo
is what every port inherits its .gitmodules from, so the old owner
propagates into each new clone until it is fixed here.

.gitmodules now names the new URLs. The toolchain repo is updated in the
CI action default, the codegen host, fetch_toolchain.sh, toolchain_pack
and package_setup_host, and the scaffolder's recomp-net / rbengine
defaults follow.

Two things the mechanical sweep would have missed:

- render_launcher_block() held a second copy of the launcher slug split
  across two string literals, so a search for the full slug did not see
  it and every regenerated README kept the dead owner. It is now derived
  from LAUNCHER_REPO, which is the only place the slug lives.

- readme_has_launcher() matched one exact slug. Pointed at the new one
  alone it would report every port built before the transfer as missing
  its launcher section, and append a second copy to any README carrying
  neither the markers nor a "## RetComM Launcher" heading. It now
  accepts both.

The scaffolder's default GitHub owner for new ports becomes
RetroPortingToolKit. Existing ports are unaffected — they carry their
own owner in the README and remote.

psxrecomp, snesrecomp and recomp-ui did not move and still point at
mstan. Submodule paths (lib/recomp-net, lib/retcomm-rbengine) are
unchanged: they are pinned by every port and are not repo slugs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xfKkiEkh87zL1EpE5Ty55
2026-09-09 00:39:14 -04:00
Matthew Stanley 912c44c75e Support native-wide HUD and tiled backgrounds with guarded culling 2026-09-07 22:40:23 -07:00
Alexandros Mandravillis 406451cb92 Check resolved paths for every staged BIOS asset 2026-09-07 17:17:30 +03:00
Alexandros Mandravillis 94f03775c0 Check BIOS assets selected by the setup recipe 2026-09-07 16:11:21 +03:00
Alex Vanderveen 155e269ba5 tools: DirectWrite/Direct2D/WIC are system DLLs, not payload
recomp-ui's Win32 emoji and font path imports DWrite.dll, d2d1.dll and the
WIC/D3D family. None was in the system list, so the bundler went looking
for them in the MinGW sysroot, did not find them (they only exist on
Windows) and failed the packaging step — measured on the SNES side, where
the same recomp-ui pulled the same imports and killed the release job.

Brings the list to parity with snesrecomp's bundle_runtime_libs.sh, whose
entries this one was missing as well (KERNELBASE, GDIPLUS, SHCORE, DWMAPI,
UXTHEME, HID, XINPUT*, AVRT, MF*, EXT-MS-*, and the rest).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DYwgPRbwbXz8FhpVzDM7tA
2026-09-06 04:04:37 -04:00
Alex Vanderveen 3d9930c5d3 release: Authenticode-sign Windows binaries when a certificate secret exists
Smart App Control blocks unsigned executables outright. The packager now
runs tools/ci/sign_windows.sh over the staged tree (host exe, DLLs,
emitters) with SHA-256 + RFC 3161 timestamp; no certificate in the
environment means a notice and an unsigned package as before, while a
configured certificate that fails to sign is fatal. The workflow template
passes WINDOWS_SIGN_PFX_BASE64 / WINDOWS_SIGN_PFX_PASSWORD to the package
step. Docs cover the secrets and the limit: the game exe the player builds
locally after Generate is unsigned by nature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1dT6h1ZshMVUUVKTPxsCD
2026-09-06 03:31:38 -04:00
Matt Stanley b213858c4c Merge pull request #323 from Alexbeav/codex/sbi-setup
Preserve SBI companions and validate them before launch
2026-09-05 22:45:15 -07:00
Matt Stanley d485a751d8 Merge pull request #325 from kerokline/fix/static-fragments-per-variant
compile_overlays --static: isolated fragments per (entry, image), pooled, memoized
2026-09-05 22:42:16 -07:00
Alex Vanderveen d371d74421 tools: generate_ci -- the release workflow for an existing project, and nothing else
sh psxrecomp/tools/generate_ci.sh            # in the project
    powershell -File psxrecomp\tools\generate_ci.ps1
    ... [root] [--check] [--force] [--dry-run] [--zip-prefix X] [--title X]

Project Studio already knows how to emit the setup-host release workflow
(emit_ci_workflow, behind `apply` and `git install-ci`), but reaching it
meant a migration plan or a commit-and-push. This is the single-purpose
face of that operation: it resolves the template from the project's own
psxrecomp submodule, takes the zip prefix from
scripts/package_setup_release.sh so CI uploads the zips the packager
built, writes .github/workflows/release.yml, and stops.

--check reports missing / placeholder / stale (step names the template
has and the file lacks) with exit 1, so a fleet can be audited without
writing anything; only --force overwrites. The .sh and .ps1 are thin
launchers over the one Python implementation.

Verified read-only against two ports: ApeEscapeRecomp reports stale
(four emitter/ccache steps missing), TombaRecomp reports current.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173EhspvAdRQc9DZdDr97Hg
2026-09-05 18:53:24 -04:00
Kevin 2fa3472a78 compile_overlays --static: isolated fragments per (entry, image), pooled, memoized
The static isolated-fragment pass resolved demands by bare entry address across
every capture: once any part had a variant at an address, that address counted
as served for every other capture of the band, and static_entry_sources kept
only the last capture's bytes. The dispatcher validates each variant against
the exact bytes it was compiled from, so in a band with N occupants exactly one
occupant ever received fragments (BoF3: BOSS055 35/35, PLP678 19/19, SCENA19
20/20; the 128-occupant and 181-occupant bands got none). 23,728 (entry, image)
demands were unserved; at the title screen the resident SCENA16 ran every
observed entry on the interpreter while every spanning piece came from
SCENA19/04/06.

- Demands are keyed (entry, image crc); parts carry image_crc;
  select_static_fragment_demands / served_static_variant_keys are the pure
  helpers (unit-tested).
- Fragment compiles run on the --jobs pool; the image table is sent once per
  worker (initializer) instead of once per demand; results merge in demand
  order and duplicates already served by an earlier-merged part are dropped,
  so the output equals the sequential loop's.
- generate_interior_fragment_static returns (part, reason). Deterministic
  verdicts on the bytes (generated-C audit, requested-entry audit, and a walk
  that runs off the image, which the recompiler reports by throwing) are
  memoized in <out-dir>/interior_fail_memo.txt and counted as skipped, never
  as shard failures; --force-interior retries an entry. Only a
  non-deterministic recompiler failure is a shard failure.
- Fragment parts are grouped one translation unit per image (BoF3: 779 units
  instead of 20,635 files).

BoF3 full run: 20,250 fragments built, 3,570 memoized rejections, 238 s cold,
nothing lost from the previous dispatcher (99,778 -> 138,148 served keys).
Title screen: 12 interpreted SCENARIO-band entries -> 2, both unharvested PCs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 18:35:16 -04:00
Alexandros Mandravillis 2d92d67592 Validate and import SBI companions in the native launcher 2026-09-05 21:34:14 +03:00
Alexandros Mandravillis 1c60d340dc Preserve and verify SBI companions during disc setup 2026-09-05 20:51:14 +03:00
Matthew Stanley 240cff542e Merge remote-tracking branch 'origin/pr/312' into integrate/303-312
# Conflicts:
#	tools/compile_overlays.py
2026-09-03 11:41:22 -07:00
Matthew Stanley 254a14f3d7 Merge remote-tracking branch 'origin/pr/303' into integrate/303-312
# Conflicts:
#	tools/compile_overlays.py
2026-09-03 11:37:13 -07:00
Matthew Stanley ea247cbbd4 chore: drop tools/_capture_button_press.py, orphaned by the scratch-tool purge
Its only referrer was one of the 204 scratch probes removed in 8e16acd8; after
that commit a word-boundary grep over every tracked file returns zero hits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 07:39:07 -07:00
Matthew Stanley e6ce5006b4 docs: move root and stale docs into docs/ and docs/internal/
The repository root carried seven markdown files that were not onboarding
material, plus two that had been superseded by the code they described. This
sorts them: reader-facing docs to docs/, working notes to docs/internal/, and
the two superseded ones merged into the docs that replaced them.

Moved to docs/:            WIDESCREEN.md, ENHANCEMENTS.md, TCP_COMMANDS.md
Moved to docs/internal/:   PRINCIPLES.md, ISSUES.md, DEBUG.md, GTE_LM_FIX.md,
                           and from docs/: bios-hardening.md, overlay-status.md,
                           overlay-plan.md, overlay-recompilation-design.md,
                           overlay-discovery.md, TOMBA2_EXC_CONSULT_RESPONSE.md,
                           CASE_A_AOT_GAP.md, audit_inventory.md
Moved to docs/internal/upstream/: UPSTREAM_2026-08-20.md

The root keeps only what belongs there: README.md, CONTRIBUTING.md, LICENSE,
CLAUDE.md, THIRD_PARTY_ATTRIBUTION.md, RELEASE_NOTES.md, the dotfiles and
psxrecomp_cli.py.

MERGES

SPLITGEN_MIGRATION.md folded into docs/BUILDING.md as "Split generated C". Its
migration instructions were stale in a way that mattered: it told every game
repo to hand-edit its CMakeLists and add a file(GLOB ...) over
<serial>_full*.c, but runtime/runtime.cmake now does that itself. GEN_FULL_GLOB
is a multi-value argument of psxrecomp_add_game_runtime(); the loop at
runtime.cmake:1985 makes each pattern absolute against CMAKE_CURRENT_SOURCE_DIR,
expands it with file(GLOB), and concatenates the hits into
GAME_GENERATED_FULL_C. So a title names the pattern and gets however many shards
a regeneration produced. The rewritten section describes that, the two-step
fallback when the globs match nothing (GEN_FULL_FALLBACK, else <serial>_full.c
derived from the dispatch marker by substituting _dispatch.c), and the one
caveat the code actually has: that file(GLOB) is plain, not CONFIGURE_DEPENDS,
so a regeneration which changes the shard COUNT needs a re-configure to be seen.

psxrecomp-toml-readme.md folded into docs/GAME_PROJECT_SETUP.md as "EXE-only
autofill (psxrecomp-toml)", placed after "Disc autofill (probe_disc.py)" and
before "Project Studio" so the two autofill paths sit together. Its documented
flags were checked against the usage text in recompiler/src/main_toml.cpp:28-42
and against the argument parser at :150-165 -- all seven options match with no
drift, so the flag list is reproduced as-is. Added a pointer that probe_disc.py
is preferable when a cue exists, since that tool does the same EXE-header work
plus disc identity and netplay gates.

FUNCTIONAL FIXES

tools/gen_tcp_commands.py wrote to REPO/"TCP_COMMANDS.md". After the move that
path does not exist, so the generator would have created a new stray root file
and --check would have failed against it forever. Retargeted to
REPO/"docs"/"TCP_COMMANDS.md", along with the seven docstring and message
strings that name the path.

Running it surfaced two further problems, both fixed here:

  * The doc was genuinely stale -- pre-existing drift, not introduced by this
    commit (the moved file is byte-identical to origin/master, and no
    debug-server command table is touched here). Four commands registered since
    the last regeneration were missing from the index -- cdrom_timing_dump,
    savestate_status, ws_ui_groups, xprobe_watch -- and scanline had gained a
    backend. Regenerated: 307 -> 311 commands, and the change is confined to the
    AUTOGEN block (the hand-written head and tail are byte-identical to master).
    tools/gen_tcp_commands.py --check now exits 0.
  * DOC.write_text() has no newline argument, so Python translated every newline
    to os.linesep. On Windows that rewrote all 585 lines of the file to CRLF and
    turned a four-line index update into a whole-file diff -- measured: 589 CRLF
    / 0 LF after a run, against 0 CRLF / 585 LF in the repo. Now written through
    an explicit newline="" handle, verified to leave the file at 0 CRLF.

runtime/include/debug_server.h:9 and runtime/src/debug_server.c:8 cited "TCP.md
and DEBUG.md". TCP.md has never existed in this repo under that name; both now
name docs/TCP_COMMANDS.md and docs/internal/DEBUG.md.

LINK AND REFERENCE FIXES

Every inbound reference to a moved file was located by grepping each basename
across the whole tracked tree, not just the markdown. Markdown hrefs in
README.md, CONTRIBUTING.md, docs/ARCHITECTURE.md, docs/COMPILING_OVERLAYS.md,
docs/AOT_OVERLAY_PLAN.md, docs/RELOCATION_MANIFEST_FORMAT.md,
docs/config_schema.md, docs/OVERLAY_XREF.md, docs/TCP_COMMANDS.md and several
docs/internal/ files now point at the new locations; where two files became
siblings under docs/internal/ the path was shortened rather than lengthened
(bios-hardening.md's reference to overlay-discovery.md, for instance).
docs/COMPILING_OVERLAYS.md's "in this folder" phrasing was wrong once two of the
three files it listed moved, so it now names internal/ explicitly.

24 source-comment mentions of ENHANCEMENTS.md sections G1.x across 19 files
(recompiler, runtime, cmake) were retargeted to docs/ENHANCEMENTS.md with the
section numbers untouched. docs/SHADOW_ENHANCEMENTS.md is a DIFFERENT file and
was deliberately excluded.

20 rationale strings citing docs/CASE_A_AOT_GAP.md across the four
recompiler/seeds/*.json files were updated. Done as a byte-level substring
replacement rather than a JSON round-trip: two of those files are stored with
CRLF endings and two with LF, and re-serializing them reformatted 5,700 lines.
The byte-level version changes exactly 20 lines and each file still parses.

References that look similar but point elsewhere were deliberately left alone,
and are worth naming so a future pass does not "fix" them:

  * "recomp-template PRINCIPLES.md" / "NES recomp template PRINCIPLES.md" in
    CLAUDE.md:43, docs/SHADOW_ENHANCEMENTS.md:28,
    docs/internal/HLE_SCHEDULER_CARVEOUT_PLAN.md:5,
    runtime/src/debug_server.c:4934 and tools/parity_diff.py:8 are a different
    repository's file. Only the five that mean this repo's PRINCIPLES.md were
    updated.
  * Every remaining bare ISSUES.md mention is a GAME repo's tracker
    (MegaManX6Recomp 2/7/8/9, TombaRecomp 6), not this repo's.
  * docs/disc-speed.md:4,54,56 and docs/internal/bios-hardening.md:5 name
    overlay-discovery as a BRANCH, not a file, so no path applies.

INDEXES

README.md's "New here?" table gains rows for docs/WIDESCREEN.md,
docs/ENHANCEMENTS.md and docs/TCP_COMMANDS.md. docs/internal/README.md gains
sections indexing all fourteen documents that moved in, plus an "Upstream
contributor reviews" section describing upstream/ and the new sweep file, plus a
"Removed" bullet for CYCLE_TIMING_ARCH.md (deleted in the previous commit)
pointing at FAITHFUL_TIMING_PLAN.md, CYCLE_MODEL_BEETLE.md and
PRECISE_IRQ_SLICE.md -- following the SLJIT_PERSIST_CACHE.md precedent already
in that file.

docs/ENHANCEMENTS.md:7 claimed framework bugs live in "game repos' ISSUES.md
until a framework tracker exists". A framework tracker does exist; it now says
so and names the Beads epic.

VERIFICATION

A link checker was written to scan every tracked *.md for relative links,
resolving each against the tree being scanned. Baseline at origin/master: 129
files, 137 relative links, 3 broken. After both commits: 127 files, 159 relative
links, 3 broken -- the SAME three, all pre-existing and untouched here:

    docs/DATA_SHARDS.md -> INSTANT_LOADS_PARKED.md   (never existed)
    docs/DATA_SHARDS.md -> INSTANT_LOADS_SPIKE2.md   (never existed)
    docs/NETPLAY.md     -> ../lib/recomp-net/README.md  (inside a submodule)

Zero new broken links. The checker's own coverage was checked against a raw grep
for markdown link openers: the 3-link difference is C++ lambdas inside code
blocks, correctly not treated as links.

Also verified: py_compile over all 113 tracked tools/*.py plus psxrecomp_cli.py;
all four seed JSONs still parse; the test-registration guard still reports 123
test files all registered; recompiler/tests/test_coverage_vault.py still passes;
and no file in this commit had its line endings flipped (checked mechanically
across all 50 modified files, after two files were caught and redone).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 07:39:07 -07:00
Matthew Stanley f55c731c5f chore: remove dead scratch tooling and stale root files
Removes 204 tools/ scripts (203 .py/.sh plus tools/_missing_funcs.json) that
no tracked file references. Verified mechanically, not by eye: for each of the
204 stems, a word-boundary grep across every tracked text file in the repo,
excluding files that are themselves being deleted, returns zero hits. So
nothing kept imports, execs, or documents any of them. Most are the
underscore-prefixed one-shot probes the .gitignore convention already treats
as scratch (tools/_*.py is ignored; these predate that rule and were force-
added), plus a long tail of session-specific dump/decode/poll scripts with
hardcoded local paths and addresses.

Also removed at the root:

  * card2.mcd.bak.before_beetle_copy -- a stray backup of a memcard.
  * .mcp.json -- local MCP server wiring, not repo configuration.
  * SCPH1001.toml -- a root duplicate of bios/SCPH1001.toml, still on the
    pre-refactor [[recompiler.bios_vectors]] schema while the bios/ copy has
    moved to [recompiler.address_model]. Every reference in the tree already
    names bios/SCPH1001.toml, so the root copy was a stale trap: editing it
    would have had no effect on any build.
  * docs/CYCLE_TIMING_ARCH.md -- superseded by
    docs/internal/FAITHFUL_TIMING_PLAN.md, CYCLE_MODEL_BEETLE.md and
    PRECISE_IRQ_SLICE.md; nothing linked to it.

Memcard fixtures move out of the repo root into
runtime/tests/fixtures/memcards/ with a README recording what each of the
four images actually contains (directory entries read out of the images, not
guessed). Nothing in the build read them from a fixed path -- the four
hardcoded consumers (tools/check_card.py, inspect_card.py, peek_cards.py,
verify_card_buffer.py) are all in the deletion above.

.gitignore is corrected to match: runtime memcards a session creates stay
ignored (*.mcr anywhere, /*.mcd at the root, which is where the runtime drops
card1.mcd/card2.mcd when no path is configured), while the tracked fixtures
are re-included. The old !dummy.*.mcr negation existed only to keep the two
root oracle cards tracked and is replaced by
!runtime/tests/fixtures/memcards/*. Verified with git check-ignore --no-index
on each moved file, on the pre-existing runtime/tests/test_card_synth.mcd and
runtime/tests/test_dir/card1.mcd fixtures (all not-ignored), and on
hypothetical root card1.mcd / card2.mcd / saves/card1.mcr (all ignored).

beetle_libretro.cpp's warn-only memcard sanity check keeps its behaviour; its
WARNING now names where populated fixtures can be copied from. The cards are
opened relative to the process's working directory because the libretro
GET_SAVE_DIRECTORY environment callback answers "." (same file, line 419), so
the message says to copy them into the CWD rather than implying a repo path.

BIOS profiles SCPH101.toml and SCPH5552.toml move from the root into bios/,
joining OpenBIOS.toml and SCPH1001.toml so that bios/*.toml is the whole set.
Behaviour-preserving: config paths inside a profile resolve against
find_project_root() (config_loader.cpp:821), which walks up from the profile's
directory for .gitignore/.git/CMakeLists.txt -- bios/ has none of those, so it
lands on the same repo root as before, and bios/SCPH1001.toml already relied
on exactly that. tools/build_cli.py's staging map is updated to the new
sources; it already copied them to framework/bios/, so the packaged layout is
unchanged. recompiler/src/main_cli.cpp's bios/-first lookup keeps its root
fallback (harmless, and it is what makes older layouts still resolve).

Fixes a real latent break while in bios/SCPH5552.toml: seeds pointed at
"seeds/phase2_ghidra_seeds_SCPH5552.json", but there is no seeds/ directory at
the repo root -- the file lives at
recompiler/seeds/phase2_ghidra_seeds_SCPH5552.json, which is what SCPH101.toml
already named. Porting these two profiles to the address_model schema is
deliberately NOT done here; it is a separate tracked follow-up.

Test files that no build system knew about:

  * tools/test_coverage_vault.py PASSES (8/8). Moved to
    recompiler/tests/test_coverage_vault.py and registered as the ctest
    coverage_vault, which puts it inside runtime/check_test_registration.cmake's
    glob so it can never silently rot again. Its ROOT is adjusted from
    parents[1] to parents[2] for the new depth, matching every other python
    test in that directory. Re-run from the new location and from an unrelated
    CWD: 8/8. The registration guard was run standalone before and after, and
    with the add_test() deliberately broken as a negative control -- it does
    name recompiler/tests/test_coverage_vault.py as an orphan when
    unregistered, so the registration is load-bearing rather than decorative.
  * tools/test_compile_overlays_additive.py FAILS, 10 of 14 tests erroring
    against an API that has moved on, so it is removed rather than wired up.
    It calls four module attributes that no longer exist in
    tools/compile_overlays.py -- load_additive_captures,
    validated_cached_bundle_ids, cached_bundle_pairs, compile_and_publish_dll
    (zero occurrences each) -- and two signatures have changed under it:
    parse_overlay_func_ids() no longer takes require_stored_crc, and
    load_region_coverage() now takes 2-3 positional arguments where the test
    passes 5. Reviving it means rewriting it against the current additive
    capture API, which is a change to overlay-cache test coverage and needs its
    own commit and its own evidence, not a rename.

Verification: py_compile over all 113 remaining tracked tools/*.py plus
psxrecomp_cli.py passes; bash -n over every remaining tracked tools/*.sh
passes; importing sdk_progress, pad_delivery and cursor_writers from tools/
succeeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 07:39:06 -07:00
Matt Stanley d08d84a3df Merge pull request #296 from kerokline/perf/static-overlay-parallel
overlays: parallel static compile, linear resume-wrapper pass, split output
2026-09-03 07:30:43 -07:00
theanch92 71a4db4cf5 compile_overlays: support --target-os and auto-detect mingw cross-compiler 2026-09-03 14:29:29 +02:00
Matt Stanley a7459cddbe Make packaged overlay toolchain self-contained (#311) 2026-09-02 22:13:06 -07:00
Matt Stanley 6cb7a6b8c2 Stage overlay dispatch preamble with toolchain (#310)
Co-authored-by: Codex <codex@openai.com>
2026-09-02 22:08:45 -07:00
Matt Stanley c6aa071a47 Merge pull request #297 from mstan/tools/cpu-throttle-harness
tools: throttle a live title's CPU to reproduce a weaker host
2026-09-02 20:02:00 -07:00
Matthew Stanley 36e57fbb8a release: stop overstating the staged toolchain size by following symlinks
os.path.getsize() follows symlinks. A relocatable CPython is dense with them --
the staged Linux overlay toolchain carries over a thousand -- so the size line
counted every link target a second time and reported ~110 MB for a tree that
is 80 MB on disk, a 38% overstatement in a release log.

lstat, and skip links. The line now also reports the file and symlink counts,
because "80 MB in 4103 files + 1051 symlinks" is a number someone can compare
against an extracted artifact, which is how everything else here is verified.

Log output only: no staged byte changes, and an artifact built before and after
this commit is identical apart from mtimes.

Bead: beads-eio.3.102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 19:12:42 -07:00
Matthew Stanley 37b32bdd0c release: refuse to derive a release tag from an unbuilt runtime include tree
compile_overlays.codegen_hash() returns 0 when runtime/include/
overlay_codegen_hash.h is absent. That fallback is correct for its purpose and
should stay: overlay_api.h has the same __has_include fallback, so a runtime
built without the header and a compiler run without the header agree on
cg<N>_00000000_..., and the cache still loads.

It is never correct for a RELEASE. The header is generated by the runtime
BUILD, in runtime.cmake's "Hashing recompiler codegen" step, so a packager that
reads a tree which has not been built yet silently produces a tag with a zeroed
codegen hash: a plausible-looking string naming a namespace the shipped binary
does not read.

MEASURED 2026-09-02 while building the Tomba 2 Linux cache. Deriving the tag
before the runtime build produced

    cg10_00000000_gc530dff78_f0

where the correct tag for that same tree, after the build, is

    cg10_ecd487f7_gc530dff78_f0

Note what it looks like from the outside: a well-formed tag, the right number
of fields, the right config hash, the right flavor. A packager would happily
stage a whole cache under it and report a shard count. The runtime would ignore
every one of them. Nothing in a build, a configure, a link or a log would say
so -- which is the exact failure shape this bead exists to remove, one field
over from the field that started it.

So `release_stage.py cg-tag` now fails when the codegen hash reads as 0, and
says which header is missing and that the runtime target has to be built first
(the same build that publishes the flavor file the tag also needs). There is no
override: for a release the condition has no correct outcome.

test_release_stage_cache.py gains CodegenHashGuardTest -- refused on an unbuilt
tree, accepted on a built one. 18 cases, all passing.

Bead: beads-eio.3.102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 19:00:03 -07:00
Matthew Stanley d655e47168 release: never stage a producer-internal cache file, even one ending in .so
Found by looking at a REAL cache tree instead of a synthetic one. A live Tomba 2
Linux cache build, sampled mid-run 2026-09-02, holds this beside its 184
finished shards:

  184  *.so.pair-lock                  producer concurrency locks
  185  *.c                             generated C the shard was compiled from
    1  .overlay-candidate-capacity.lock
    1  .overlay-candidate-capacity.lock.invocation
    2  .00096000_407B1780.so.tmp.ryt4jqca.so
       .00096000_407B1780.so.tmp.ryt4jqca.ranges

The last pair is the problem. compile_overlays writes each shard to a hidden
temp name and renames it into place, so a cache directory that is being written
-- or that was written by an INTERRUPTED run and still holds leftovers --
contains files that end in .so and .ranges and are not shards.

A suffix allowlist alone therefore ships them. So did the shell packagers'
`find ... -name '*.so' -o -name '*.ranges'`, which is a latent defect in all
three forks that nobody had hit yet. It is not loud when it happens: an extra
dlopen-able file with a hidden name lands in the release, the loader tries it,
and the outcome depends on how far the interrupted compile got.

FIX: the forbidden-artifact denylist is checked BEFORE the suffix allowlist, and
gains two rules -- a leading dot, and `.tmp.` anywhere in the name. Every
producer-internal file observed in a real cache is hidden and no shippable
artifact ever is, so the leading dot is the reliable discriminator; the `.tmp.`
rule is belt and braces for a future producer that stops hiding them.

Exclusions are now reported as a grouped tally rather than one line each. A real
cache holds one .pair-lock and one .c PER SHARD, so per-file notes would bury
the shard count under ~370 lines in every release log -- the same "the important
line scrolled past" failure this bead is about, one level down.

test_release_stage_cache.py gains test_real_producer_artifacts_never_ship, which
models that exact file set verbatim, including the temp pair, and asserts the
staged directory contains exactly the 11 shippable files and nothing hidden.
16 cases, all passing.

Also corrects the ApeEscapeRecomp line count in the header comment: 416 on
origin/master, not 403. 403 is what its parked working tree reports, and reading
a working tree instead of origin/master is the mistake that produced the wrong
first version of this bead.

Bead: beads-eio.3.102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:33:46 -07:00
Matthew Stanley 94e3ef610d release: ONE staging implementation for both platforms (2 titles had no Linux release path)
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>
2026-09-02 18:28:24 -07:00
Matthew Stanley 57648745a3 mods: the framework stages the title's catalog too (5 titles could not package)
4cc04be3 ("rebump") renamed the staged mod catalog from <exe>/mods/packages to
<exe>/mods/bundled so that a rebuild could wipe build output without deleting
the player's own installed mods. runtime.cmake followed the rename. The shared
release module followed it (release_overlay_stage.ps1's Add-ModCatalog reads
mods/bundled). docs/MOD_PACKAGES.md already described the destination as "the
framework's mods/builtin/packages PLUS the title's mods/preloaded/packages".

No title CMakeLists followed, because the second half of that sentence was
implemented five times, once per repository, as a hand-written

    add_custom_command(TARGET psx-runtime POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_directory
            "${<TITLE>_PRELOADED_MODS}" "$<TARGET_FILE_DIR:psx-runtime>/mods")

in ApeEscapeRecomp, Tomba2Recomp and MegaManX4/5/6Recomp. That names the
destination as a STRING. There is no symbol, no header, no link dependency --
so renaming a staged output directory breaks nothing a configure, compile or
link step can see, and the diffstat of the commit that does it looks cosmetic.
The five titles went on staging into a directory neither the launcher nor any
packager reads, and discovery was deferred to whichever title next ran a
release packager, in a different repository, on a later day.

MEASURED, on real build output. Framework origin/master 22fbbfca plus
Tomba2Recomp origin/master cdf00af, both unmodified, both built:

  build-dir mods/
    bundled/psx.enhancement.cd-speed          <- framework, correct
    bundled/psx.enhancement.fast-loading
    bundled/psx.enhancement.pgxp
    bundled/psx.presentation.bezel
    packages/tomba2.debug.debug-menu          <- title, stranded
    packages/tomba2.enhancement.skip-fmvs
    packages/tomba2.enhancement.widescreen
    packages/tomba2.experimental.interpolated-frame-rate

and Add-ModCatalog run against that tree, from this commit's parent:

  Mod catalog is missing package(s) the sources define:
  tomba2.debug.debug-menu, tomba2.enhancement.skip-fmvs,
  tomba2.enhancement.widescreen, tomba2.experimental.interpolated-frame-rate.
  They exist in the source tree but did not reach ...\mods\bundled, so the
  release would ship a Mods page the dev build does not have.

Shipped releases are NOT affected. Tomba 2 v0.0.9 pins 04d9184b, which is an
ancestor of 4cc04be3 and contains zero occurrences of migrate_legacy_root and
bundled_root; its artifact is a self-consistent mods/packages layout. This is a
forward break only.

THE FIX PUTS THE LAYOUT IN ONE PLACE.

psxrecomp_add_runtime_target() gains a one-value keyword PRELOADED_MODS_DIR.
A title declares WHERE its packages live and never HOW they are laid out:

    set(APE_PRELOADED_MODS "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded")
    psxrecomp_add_runtime_target(psx-runtime
        ...
        PRELOADED_MODS_DIR "${APE_PRELOADED_MODS}")

A keyword argument rather than a positional one or a variable read out of the
caller's scope, for three reasons. It is what the other fourteen one-value
arguments already are, so it needs no new convention and reads the same as the
LAUNCHER_BOXART line next to it. A pre-set variable would be invisible at the
call site and would silently do nothing if set after the call -- the same
"fails quietly in the caller's repository" shape as the bug being fixed. And it
is PER TARGET, which a global variable cannot be: Tomba 2 has two runtime
targets needing two different catalogs (mods/preloaded, mods/preloaded_ita).
psxrecomp_v4_add_runtime_target and psxrecomp_add_game_runtime both forward
their remaining arguments, so it reaches through both wrappers unchanged.

_psxrt_stage_mod_catalog() now emits, in one POST_BUILD command sequence per
runtime target: wipe mods/bundled; remove exactly the ids it is about to stage
from any pre-existing mods/packages; copy the framework builtins; copy the
title's packages; copy <dir>/README.md to mods/README.md; verify. Consequences
worth stating:

  * mods/README.md previously reached mods/ only as a side effect of copying
    the whole mods/preloaded tree. Now that only packages/ is staged it is
    copied on purpose, or it would silently disappear. Checked per title: all
    five carry mods/preloaded/README.md and none carries a root mods/README.md,
    so mods/preloaded/README.md is the only source there ever was.
  * a title may still OVERRIDE a framework builtin at the same id and version,
    because the framework copy runs first and the title copy overwrites it.
    Tomba 2's Italian catalog relies on exactly this, shipping localized psx.*
    manifests. The id LIST is deduplicated for the assertion and the reported
    count while the copies keep both, or the build claims to stage 11 packages
    for a catalog that holds 7.
  * COSIM targets stage nothing. They have no launcher and no Mods page, and
    they land in the same output directory as the real runtime, so letting one
    stage had it wipe and re-stage the runtime's catalog with only the
    framework half whenever -DPSX_BUILD_COSIM=ON built it last.

TWO GUARDS, because the failure mode being closed is silence.

Configure time: a project with packages under mods/preloaded/packages that does
not declare PRELOADED_MODS_DIR is a FATAL_ERROR naming the packages and the
argument to add. That is exactly the missed-title state and it is detectable
before any build runs. An empty packages/ is fine (the scaffold creates one),
and PRELOADED_MODS_DIR NONE is the explicit opt-out.

Build time: runtime/psx_check_mod_catalog.cmake fails the build if a declared
package did not reach mods/bundled, or if any package this build stages turned
up under mods/packages -- which after this change can only mean a CMakeLists
still stages its own catalog by hand. It runs as the LAST POST_BUILD step, via
cmake_language(DEFER), because POST_BUILD commands execute in registration
order and the framework block registers BEFORE anything a title adds after its
psxrecomp_add_runtime_target() call. Verified by inspecting build.ninja: the
deferred command lands after a title-registered POST_BUILD, not before it.

The deferred pass takes NO arguments and reads its work list out of global
properties. cmake_language(DEFER CALL <fn> "${var}") does not carry a
function-local variable through -- measured with cmake 4.2.2, the callee
receives an empty argument and an add_custom_command built from it fails at
directory end with "CMakeLists.txt:DEFERRED". Recorded so the next reader does
not re-derive it.

Ids the build does NOT stage are deliberately left alone in mods/packages. On a
self-compiling setup release that directory can hold packages the player
installed under the pre-split layout, and mod_packages.cpp's
migrate_legacy_root() relocates those into mods/installed on the next launch.
Removing only the ids this build stages is also what lets a build directory
made before the split be migrated silently, so the guard's "an id turned up in
mods/packages" report has no false positives.

The same script is registered as the ctest psx_staged_mod_catalog_test in every
project that stages a catalog, with REQUIRE_STAGED=0 so a tree where the
runtime was never built reports a skip rather than a spurious failure. Where
two runtime targets share an output directory it accepts either target's
complete catalog, since after a full build mods/bundled holds whichever linked
last -- which is correct for the packager, as it builds exactly one target per
variant, and each target's own POST_BUILD guard already verified its catalog at
stage time.

TESTS. runtime/tests/test_mod_catalog_layout.py exercises the guard against
on-disk fixtures: a reproduction of the broken state as it actually appeared
(bundled/ holding the four psx.* ids, packages/ holding the four game ids), a
declared package simply absent, a package directory with no manifest.toml, a
FOREIGN legacy package that must NOT fail the build, the require-staged split
between the two callers, and the shared-output-directory sibling case. It also
pins two source invariants in runtime.cmake so the layout cannot drift back
into per-title copies. Registered in runtime/CMakeLists.txt; the
test-registration guard reports 123 test file(s), all registered.

Framework ctest: 59/59 pass, 2 pre-existing DISABLED.

Also updated so the next title does not repeat this: the project scaffold
template now passes PRELOADED_MODS_DIR (it staged no catalog at all before, so
a scaffolded project with mods/preloaded/packages shipped none), and
docs/MOD_PACKAGES.md gains a "How bundled/ gets staged" section written at
titles.

Bead beads-eio.3.101. The five title-side patches must land AFTER this commit:
they reference PRELOADED_MODS_DIR, which does not exist before it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:37:19 -07:00
Matthew Stanley 80ff96780a recompiler: the overlay guard word is a delay-slot source, never a block leader
The overlay capture format DELIBERATELY appends one coherent guard instruction
past the end of a dirty-page run (overlay_capture.c write_json_window, `size
+= 4u`, pinned by runtime/tests/test_interpreter_perf_guards.py:47): a MIPS
branch sitting at the run's final word (...FFC) always executes its delay slot
in the next page (...000), and the shard must be able to emit and hash that
word. So a capture whose size is a page multiple PLUS 4 is a CORRECT capture --
N bytes of overlay plus one guard word. Measured: 36 of 36 Ape Escape
(SCUS-94423) vault records and 846 of 946 Tomba 2 (SCUS-94454) records are that
shape.

The recompiler was never taught the asymmetry: the guard word is a legal
DELAY-SLOT SOURCE but an ILLEGAL BLOCK LEADER, because the word after it does
not exist in the image. It had exactly one bound -- end_address() -- used both
to read words and to decide what is code.

MEASURED on Ape Escape, its own single-source vault at framework
v0.3.2-alpha-139-g1bf70960, 36 variants:

  PSX_SHARD_RESULT ok=50 failed=17 skipped=32 capacity_fastpath=0

  ! [recompiler] overlay 0x80136000 crc 4BAB23C9: what():  cannot emit control
    flow at 0x80138000: mandatory delay slot 0x80138004 is outside the input
    image
  + 16 x [fragment] interior 0x801360.. @region 0x00136000, identical message

One root cause, seventeen shards. The whole region then falls back to the
dirty-RAM interpreter forever, and nothing at runtime says so.

Causal chain, every step read in source:

  1. overlay_capture.c:349-361 appends the guard word; region 0x80136000 gets
     size 0x2004, so the image is [0x80136000, 0x80138004).
  2. compile_overlays.py make_psxexe wraps those bytes verbatim with text size
     = len(data), so PS1Executable::end_address() = 0x80138004.
  3. function_analysis.cpp in_exe / in_function admit 0x80138000 as ordinary
     code, and the walk reaches it by fall-through from 0x80137FFC
     (0x24022000, addiu -- not control flow), so func.end_addr becomes the
     image end.
  4. control_flow.cpp makes the final block end_addr = func.end_addr - 4 =
     0x80138000: the block LEADER is the guard word.
  5. control_flow.cpp's "Also check last instruction itself" promotion finds
     0x80137FFC is not control flow and therefore promotes read_word(end_addr)
     -- the guard word 0x1062005A, a beq -- to block.exit_instr, with
     has_delay_slot = true and address == end_addr. THIS IS THE DEFECT.
  6. code_generator.cpp computes delay_slot_addr = 0x80138004, read_word
     returns nullopt, and it throws.

THE THROW AT STEP 6 IS CORRECT AND IS PRESERVED. Emitting a control transfer
without its architectural delay slot silently corrupts guest state, which is
what the code did before that throw was added. The gap is step 5.

Fix: give the image view two distinct bounds.

  end_address()           READ bound -- every byte the image supplies, so a
                          mandatory delay slot living at a guard word resolves.
  analysis_end_address()  ANALYSIS bound -- one past the last byte that may be
                          discovered, made a block leader, or made a block's
                          exit instruction.

read_word() keeps the READ bound. Discovery and block construction move to the
ANALYSIS bound: function_analysis.cpp in_exe / in_exe_p3 / add_forced_entry /
every hard_cap that used to be the image end / the jr-table producer_hi
defaults / the static jr-ra and JAL sweeps; control_flow.cpp gains
analysis_walk_hi(func) and uses it for cfg.function_end, alias entries,
add_boundary, the scan loop, the final block's end_addr and in_func;
code_generator.cpp's mid-function split pre-pass; main_psx.cpp seed admission
and the data-scan pointer promotion; analysis_db.cpp, main_toml.cpp,
main_cli.cpp target/seed admission. The clamp is applied only when it leaves a
non-empty walk, so a degenerate function starting at or past the bound keeps
failing closed rather than silently producing an empty CFG.

HOW THE BOUND IS DERIVED -- the producer STATES it; the recompiler is told.

The alternative was for the recompiler to infer the count from `size % 4096 ==
4`. That fits every capture the current writer has ever produced, but it is a
consumer guessing at a producer's intent, and this tree already has the
counter-example: 52 of the 946 SCUS-94454 records have residues from 112 to
3996 bytes (one not even 4-byte aligned) because they come from the STATIC
extractor, which records an exact file extent and appends no guard word. A rule
keyed to the page-run shape is a rule that shifts the moment a producer
changes.

So the count travels with the image, in a magic-tagged field in the otherwise
zero-filled tail of the 2048-byte PS-EXE header (exe_tag in ps1_exe_parser.h:
"PSXRGRD1" at 0x7E0, uint32 count at 0x7E8). Putting it in the header rather
than on the command line means every consumer gets it automatically and no
future invocation path can forget to pass it -- compile_overlays has three
make_psxexe sites and five recompiler spawn sites. An absent or malformed tag
means ZERO guard bytes, which is exactly what every genuine PS-X EXE is: the
BIOS and every retail main executable have no guard word, their analysis bound
equals their read bound, and their analysis is bit-for-bit unchanged.

Producer side:
  overlay_capture.c    writes "guard_bytes": 4|0 per region -- the writer
                       stating its own intent, additive to the v2 schema and
                       preserved through coverage_vault.py compaction.
  compile_overlays.py  capture_guard_bytes(): prefer that field; for captures
                       recorded before it existed, reconstruct the single
                       shape write_json_window can produce (page multiple + 4)
                       and return 0 for everything else, which is the
                       fail-closed answer -- an unrecognised guard word is
                       analysed as code and the step-6 throw still fires
                       loudly at generation time.
  make_psxexe          guard_bytes is keyword-only with NO default, so a new
                       call site is a TypeError, not a silently mis-analysed
                       shard. The count is threaded through
                       compile_fragment_batch / compile_interior_fragment /
                       generate_interior_fragment_static and into the fragment
                       fail-memo key (bumped to v3, so a memo recorded under
                       the old analysis cannot suppress a retry under the
                       corrected one).

SECOND-ORDER DEFECT FIXED IN THE SAME PASS. generate_ranges_manifest extends a
range by +4 for any block whose exit instruction IS its last word, so the
cloned delay slot joins the candidate CRC and the page-generation watch. That
extension was unclamped. Reachable TODAY, independent of the guard word: a
branch-likely opcode (0x14-0x17, RESERVED on the R3000A) as the image's very
last word is short-circuited by translate_basic_block into an inline RI raise
BEFORE any delay slot is read, so generation succeeds and the manifest claims 4
bytes past the image end. Measured on origin/master with a 28-byte synthetic
image: `R 80010000 20` for an image ending at 0x8001001C. The cache would then
validate the shard against bytes the shard never saw. Now clamped to the READ
bound -- not the analysis bound, because a guard word genuinely IS compiled in
when it serves as a delay slot and must participate in the CRC.

DEAD CODE, documented rather than trusted: basic_block.cpp's BasicBlockAnalyzer
bounds its walk correctly but nothing in the tree constructs it. It is still
compiled and still hashed into the codegen tag, so a change there invalidates
caches without moving an emitted byte. Its bounds move to the analysis bound
too -- wiring it up must not reintroduce this defect -- and a header comment now
says where the live walker actually is.

TESTS. Both registered, so ctest runs them and the configure-time
registration guard is satisfied.

recompiler/tests/test_overlay_guard_word_analysis_bound.py drives
psxrecomp-game over five synthetic images:
  A  tagged, 2 pages + guard word, last real word not control flow, guard word
     IS a beq -- the measured Ape shape. Must generate; the guard word must not
     be emitted as an instruction; the block must publish 0x80012000 for the
     runtime to dispatch; no range may claim it.
  B  byte-for-byte the same payload with NO tag. Must still fail closed with
     the mandatory-delay-slot diagnostic naming 0x80012000. A and B differ only
     in the header tag, which proves the tag is what changed behaviour and that
     the throw was not weakened.
  B2 a misaligned declaration (6). Must be rejected and behave like B.
  C  tagged, with a real branch at the last analysable word. The guard word
     must be emitted AS the delay slot and must be covered by the manifest.
  D  branch-likely as the final word. Generation must succeed and no range may
     exceed the image end.

Against origin/master's psxrecomp-game the same file reports exactly the two
things this commit changes, and passes B, B2 and C:
  FAIL: tagged guard word still fails generation: what():  cannot emit control
        flow at 0x80012000: mandatory delay slot 0x80012004 is outside the
        input image
  FAIL: range [0x80010000,0x80010020) claims 4 byte(s) past the image end
        0x8001001C

tools/tests/test_overlay_guard_bytes_declaration.py pins the producer half:
field precedence, the reconstruction table, rejection of nonsense
declarations, the tag's byte offsets, that make_psxexe with guard_bytes=0 is
byte-identical to the pre-fix wrapper, and that overlay_capture.c both appends
the guard word and now declares it.

MEASURED OUTCOME, Ape Escape, same vault, same flags, fresh cache:
  before  PSX_SHARD_RESULT ok=50 failed=17 skipped=32 capacity_fastpath=0
  after   PSX_SHARD_RESULT ok=31 failed=0  skipped=57 capacity_fastpath=0

ok falls because 19 isolated island fragment shards at 0x00136000 are no longer
needed once the region variant compiles as one shard. Coverage is what matters,
and it is measured from the DLL exports: 349 -> 361 unique func_XXXXXXXX
entries, ZERO lost, 12 gained -- 0x80136140, 0x8013614C, 0x80136154,
0x80136238, 0x801362D4, 0x801362DC, 0x801362F0, 0x80136324, 0x80136370,
0x80136400, 0x80136480, 0x80137950, all from the failure list. The other four
failing PCs were already served by a sibling variant's shard.

INERTNESS.
  BIOS (SCPH1001, psxrecomp-bios --config bios/SCPH1001.toml): all four
  generated artifacts BYTE-IDENTICAL, verified by sha256 rather than by exit
  code, because tools/regen_bios.sh exits 0 on failure.
  Tomba 2 main EXE (psxrecomp-game --config game.toml): all nine generated
  artifacts BYTE-IDENTICAL, and the console log is identical too.
  Tomba 2 overlays, 82 variants over 6 regions from its own vault: identical
  result line (ok=98 failed=0 skipped=103) and identical shard identity set,
  4901 unique func exports on both sides -- zero gained, zero lost. 97 of 98
  range manifests are byte-identical. The one that differs is region
  0x8010A000 variant A32DB188, size 0x2004, whose last function's range goes
  from 0x218 to 0x214 bytes: its guard word (0x24840008, addiu) had been
  emitted as an ordinary instruction and hashed, and now is not. That is
  precisely the intended change and nothing else moved.

Bead beads-eio.3.100.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:35:24 -07:00
Matthew Stanley aa0a285c86 tools: compute the affinity mask by shifting, not by Pow on a double
[math]::Pow(2, $n) - 1 builds the processor-affinity mask in DOUBLE
precision, and a double carries only a 53-bit mantissa. From n = 54 the
subtraction is lost to rounding: 2^n - 1 rounds straight back up to 2^n, so
the mask comes out with exactly ONE bit set.

Measured across the range, old math vs shifting:

  n = 50..53   both select n CPUs                      ok
  n = 54..62   old selects 1 CPU of n                  SILENTLY WRONG
  n = 63..64   old throws converting to Int64          visible failure

The silent band is by far the worse half, and it is worst precisely in this
tool. On a 54-to-62-processor host, -Off would pin the title to a single
logical CPU while printing "affinity restored to all N processors", and
-Cores N would pin it to one CPU while reporting N. A throttling tool that
reports the opposite of what it did does not just fail, it invalidates every
measurement taken through it -- which is the exact failure this tool exists
to prevent.

Get-AffinityMask shifts a uint64 instead, and special-cases a full 64
because .NET masks a 64-bit shift count to 63 (shifting by 64 quietly yields
1, not 0). Verified equivalent to the old expression for every n where the
old expression was correct (1..53), correct by popcount for 1..64, and
saturating above 64. ProcessorAffinity addresses a single processor group, so
a host with more than 64 processors is inherently limited to the group the
process already runs in; the mask now saturates there rather than throwing.

No behaviour change on any host where the tool previously worked -- this box
has 16 processors, where old and new masks are byte-identical (0xFFFF).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:42:13 -07:00