Files
psxrecomp/docs/OVERLAY_XREF.md
T
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

8.1 KiB
Raw Blame History

Overlay cross-reference tooling — tools/overlay_xref.py

Static analysis of an overlay-heavy PS1 title has a hole in it: the code you care about does not exist in the boot EXE. Titles like Tomba 2 ship a ~165 KB loader and stream the entire game in as code overlays, so a Ghidra import of SCUS_944.54 covers 0x80010000–0x80038800 and nothing else. Every [widescreen.cull] site this project has ever needed lives outside that range.

tools/overlay_xref.py closes the hole by reading the runtime's overlay capture set. It is game-agnostic — point it at any title's capture directory.


Why the capture set is a legitimate source of truth

The runtime writes one JSON per distinct overlay body it has ever mapped to <exe_dir>/overlay_captures.json.d/<contenthash>.json (schema psxrecomp overlay capture v2):

field meaning
load_addr virtual base the body was mapped at
size body length in bytes
bytes_b64 the actual bytes that were in guest RAM
executed_pcs PCs observed executing inside this body

The set is content-hashed and additive across every run, so it is an always-on ring buffer that needs no arming — it already contains everything the game has ever loaded, including bodies from sessions that ended months ago. This satisfies the project's ring-buffer rule: you query it for the window of interest rather than arming a trace and hoping to catch the event.

For scale, Tomba 2's probe capture dir holds 30 769 captures across 116 distinct load bases, with ~34 M executed-PC records.


The problem it exists to solve

bias_sites, range_sites, screen_x_sites and depth_sites are matched by address plus opcode class only. Overlays genuinely map different code to the same virtual address, so an address-only record can silently rewrite an unrelated instruction.

This is not theoretical. Measured on Tomba 2's shipped config:

address finding
0x80110A08 (committed bias_site) 3 distinct words: lw $s1,20($sp) / addiu $a1,$v1,0x80 / addu $v1,$zr,$zr. The opcode guard saves this one.
0x8013F138 (committed bias_site) 5 variants, all addiu $v0,$zr,imm, immediates 0x155 / 0x124 / 0x208 / 0x320 / 0x224. Same opcode, so one address-only record widens all five.
0x8010E2FC two entirely different overlay bodies at the same VA (bases 0x8010C000 and 0x8010A000).

Run word <VA> on every candidate site before committing it. If more than one word appears, use a full expected-word guarded record rather than a bare address.


Commands

overlay_xref.py -d <captures.json.d> <command> [args]
command purpose
index per-base summary: variant count, sizes, how many captures executed
word VA... every distinct word at each VA, with capture ids and exec counts
dis VA -n N disassemble N instructions, one listing per distinct variant
func VA walk back to the prologue, forward past jr $ra, then list
cover VA... which load bases/sizes cover each VA
scan --op OP [--imm-min/--imm-max] every site matching an opcode, optionally by immediate range
imm VALUE every site whose 16-bit immediate equals VALUE (ALU and load/store displacements)
callers VA every jal/j site targeting VA — a call graph without Ghidra analysis
window [--signed] [--gap N] find the centered-window gate idiom (below)
ramimage OUT composite the captures into a flat guest-RAM image for Ghidra

A parse of the full Tomba 2 set takes ~85 s cold; results are cached to <captures.json.d>/.overlay_xref.cache (~10 s warm). --no-cache forces a re-parse.


The centered-window gate idiom

Almost every PS1 visibility or proximity gate is written as one unsigned compare against a recentred delta:

subu   rT, rRef, rObj        # signed coordinate delta
addiu  rT, rT, BIAS          # recentre                      <-- bias site
andi   rT, rT, 0xFFFF        # truncate back to 16-bit (optional)
sltiu  rD, rT, SPAN          # |delta| < SPAN/2              <-- range site

window finds these pairs. Instructions between the bias and the compare are allowed only when they carry the value (a same-register andi/addiu/ sll/sra/or); a genuine redefinition breaks the pair.

The andi truncation is part of the idiom, not a clobber. A naive "destination was rewritten, so this isn't a pair" heuristic breaks on it and silently reports zero real gates while still finding plenty of false ones. That failure is quiet and costs a whole analysis pass.

Reading the constants

SPAN == 2*BIAS + 1 is a symmetric window (|delta| < BIAS). Anything else is one-sided or asymmetric and deserves a second look:

bias / span shape
0xE6 / 0x1CD (230 / 461) symmetric, `
0x258 / 0x259 (600 / 601) one-sided, delta ∈ [-600, 0]
0x320 / 0x44D (800 / 1101) asymmetric, delta ∈ [-800, 300]

A trap worth naming

Most window pairs in a 3D action title are 12-bit ANGLE windows inside collision tests, not distance gates. On Tomba 2, 43 pairs were found and the strongest-looking candidates turned out to be attack/hit cones:

sqrt(dx² + dz²)            (jal 0x80084080)
compare against summed radii at actor+0x80
atan2                      (jal 0x80085690)
angle window (andi 0xFFF, bias/span)
sb 1, 43(actor)            # set the hit flag

Confirmed for 0x8001FBBC (bias 0x580 / span 0x301) and 0x8010E2FC (682 / 1365). Widening one of these changes combat reach, not what is drawn. Always read the surrounding function before classifying a pair.


Feeding Ghidra: ramimage

Overlay code only exists in RAM at runtime, so the decompiler cannot reach it from a static EXE import. ramimage flattens the capture set into a raw guest RAM image that Ghidra can analyse:

python tools/overlay_xref.py -d <captures.json.d> \
    ramimage <game>/ghidra/<title>_ram.bin \
    --manifest <game>/ghidra/<title>_ram_conflicts.txt

Import it with language = MIPS:LE:32:default, base_address = 0x80000000, compiler default, then let auto-analysis run.

Where several overlays mapped different bodies to one address, the winner is the variant seen executing in the most captures (ties broken by capture count). Every disagreement is written to the conflict manifest.

The image is a composite fiction in the conflicting regions

Tomba 2's image covers 74.4 % of the 2 MB address space and has 44 576 conflicting addresses (~11 % of covered words). Inside those regions the listing is a blend of overlays that never coexisted.

Treat Ghidra output from this image as a lead, not a fact. Confirm every instruction you intend to patch with word <VA> / dis <VA> against the real per-overlay variants. See the worked example below for why this is not optional.

Worked example — the decompiler will invent addressing

Ghidra decompiled an actor state machine as reading the player through a global:

uVar2 = FUN_800782b0(param_1 + 0x2c, (int)*(short *)(DAT_800e7f5c + 0x2c), ...);

The actual instructions are:

0x8006A560: 8E0200DC  lw   $v0,220($s0)     # player via <context>+0xDC
0x8006A568: 8445002C  lh   $a1,44($v0)

There is exactly one real reference to 0x800E7F5C in the entire capture set (0x80069B70) — confirmed independently by imm 0x7F5C and by Ghidra's own xrefs. The DAT_800e7f5c in that function body is decompiler inference from constant propagation, not a load. A site list built from the decompiler's view of addressing here would have been wrong.

Recommended split of labour:

task use
call graph / xrefs overlay_xref.py callers, imm, scan — exact, needs no analysis pass
readable control flow Ghidra create_function + get_code decompiler
anything you will patch word / dis on the real variants — always

  • docs/config_schema.md — [widescreen.cull] schema
  • docs/WIDESCREEN.md — native-wide / adaptive view design
  • docs/COMPILING_OVERLAYS.md — how captures become compiled overlay DLLs