Files
psxrecomp/docs/overlay-plan.md
T
Matthew Stanley b2929aa504 overlay: restore overlay DLL cache + capture (faithful port of codex/cache-discovery)
Reinstates the runtime-loaded overlay DLL cache (Layer A) and the offline
overlay compilation pipeline that were built on the pruned codex/cache-discovery
branch (tip 2bbafc2) and never merged. Ported onto current master; rebuilt
rather than history-restored.

Runtime (off by default, opt in via [runtime] overlay_cache = true):
- overlay_loader.c/.h, overlay_api.h: CRC32-keyed DLL cache at
  cache/<game_id>/<region>_<crc>.dll. On a dirty-RAM dispatch miss the loader
  LoadLibrary's the matching DLL, wires runtime callbacks via overlay_init(),
  enumerates func_XXXXXXXX exports, and dispatches them BEFORE the interpreter.
- overlay_capture.c/.h: write-once capture set; records overlay DMA bytes and
  writes overlay_captures.json (execution-verified seeds) at clean exit.
- dma.c: capture + cd_dma_log hook at CD DMA completion (async model).
- cdrom.c: cdrom_get_setloc_lba() for disc-position correlation.
- dirty_ram_interp.c: g_dirty_ram_exec_pc_table (exec-verified PCs), a
  decodability guard on JAL-into-dirty targets, and the overlay dispatch hooks.
- debug_server.c: cd_read_log + overlay_loader_status TCP commands.
- config_loader: [runtime] overlay_cache flag (default false).

Recompiler (compile_overlays.py / extract_overlays.py drive these):
- main_psx.cpp --overlay-exact: analyze only explicit entries + direct JAL
  targets and set split_mid_function_targets=false, so overlay basic-block
  targets are NOT split into bogus standalone functions (the mid-function-seed
  failure mode that produced black-on-load / blue-on-new-game).
- function_analysis.cpp: analyze_exact_entries(); code_generator.cpp: gate the
  mid-function split pre-pass behind split_mid_function_targets (default true,
  so normal main-EXE codegen is unchanged - Tomba still regens to 2854 funcs).

Deltas from the original: save states dropped (never worked); overlay enable is
runtime-config-gated (off by default) instead of a build-time define; loader
stderr logging converted to the overlay_loader_status TCP path (CLAUDE.md Rule 3).

Recovered from _backups/2026-06-06-consolidation/psxrecomp-all-PRE.bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:32:19 -07:00

6.7 KiB

Overlay System — Implementation Plan

Written: 2026-05-30
Status: Active


Goal

Make PSX overlay code transparent to the user. Shipped builds run pre-compiled overlays at native speed (Layer B). Unknown overlays fall back to the interpreter once, compile in the background, and run as native code from the second encounter onward (Layer A). Users contribute new coverage automatically by playing.


Data structures

Capture set

A hash map keyed by load_addr. Write-once: a given load address is inserted exactly once regardless of how many times that overlay loads during a session.

typedef enum {
    OVERLAY_STATE_QUEUED    = 0,  /* in compilation queue, not yet started */
    OVERLAY_STATE_COMPILING = 1,  /* background thread is working on it    */
    OVERLAY_STATE_COMPILED  = 2,  /* DLL written to cache, dispatch patched */
} OverlayState;

typedef struct OverlayEntry {
    uint32_t      load_addr;
    uint32_t      size;
    uint8_t      *bytes;          /* heap copy of the DMA'd bytes          */
    OverlayState  state;          /* atomic, guards queue dedup            */
    struct OverlayEntry *next;    /* intrusive hash chain                  */
} OverlayEntry;

Compilation queue

A simple FIFO of OverlayEntry * pointers into the capture set. Does not own data. Background thread drains it. Never contains duplicates — the state machine on the capture set entry prevents duplicate pushes.


State machine (per entry)

DMA completion (first time, load_addr not in set)
  → alloc entry, copy bytes
  → state = QUEUED
  → push pointer onto queue

DMA completion (load_addr already in set)
  → state == QUEUED    → no-op  (already waiting to compile)
  → state == COMPILING → no-op  (being compiled now, interp handles it)
  → state == COMPILED  → no-op  (dispatch table has it, interp won't fire)

Background thread (dequeues entry)
  → state = COMPILING
  → emit C for bytes at load_addr
  → compile to DLL → write cache/<game_id>/<hash>.dll
  → LoadLibrary + register functions in live dispatch table
  → state = COMPILED

The NEW→QUEUED transition and the queue push are done under a single mutex lock so a concurrent DMA completion on a second thread cannot push the same entry twice.


Implementation phases

B-1 — Capture + JSON dump (prerequisite for everything)

Files: runtime/src/overlay_capture.c + runtime/include/overlay_capture.h

  • overlay_capture_init(const char *game_id) — called at game handoff
  • overlay_capture_on_dma(uint32_t load_addr, uint32_t size, const uint8_t *bytes) — called from dma.c execute_ch3_cdrom when load_start < 0x1C0000
  • overlay_capture_write_json(const char *path) — writes overlay_captures.json next to the executable; called from main.cpp at clean exit
  • JSON schema per entry:
    { "load_addr": "0xA3000", "size": 208896, "bytes_b64": "...", "seeds": [] }
    
    (seeds is empty at capture time; filled in by the ingestion script from per_pc data)

Hook in dma.c: existing execute_ch3_cdrom already has load_start and total_words. Add one call after the existing cd_dma_log push:

if (!step && load_start < 0x1C0000u)
    overlay_capture_on_dma(load_start, total_words * 4u, ram + load_start);

Hook in main.cpp: at clean shutdown (SDL quit, window close) call overlay_capture_write_json. Retail and dev builds both do this.


B-2 — psxrecomp-game overlay compilation

Files: recompiler/src/overlay_compiler.cpp, extended game.toml schema

game.toml gains an [[overlays]] array:

[[overlays]]
load_addr  = "0xA3000"
capture    = "captures/A3000.bin"
seeds      = ["0xA3140", "0xA52C0"]

psxrecomp-game reads each entry, disassembles the MIPS bytes at load_addr, traces from seeds, emits C functions, and appends to the game's dispatch table. These functions are linked into the shipped binary at zero runtime cost.


B-3 — Ingestion script

File: tools/ingest_overlays.py

python3 tools/ingest_overlays.py \
    --captures overlay_captures.json \
    --per-pc   logs/SCUS-94236/per_pc.json \
    --out-dir  captures/ \
    --toml     game.toml
  • Reads overlay_captures.json (from user submission or own playthrough)
  • Reads per_pc.json for executed addresses within each overlay range
  • Writes captures/<load_addr_hex>.bin
  • Appends new [[overlays]] blocks to game.toml (skips already-present entries)

After running, psxrecomp-game --config game.toml re-emits the dispatch table with the new overlays compiled in.


A-1 — Cache DLL check on DMA completion

Files: runtime/src/overlay_capture.c (extended)

On DMA completion, after the capture-set insert:

  • Compute hash = crc32(bytes, size)
  • Check cache/<game_id>/<hash>.dll exists
  • If yes: LoadLibrary → enumerate exported functions → register each in the live dispatch table for [load_addr, load_addr + size) → set state COMPILED
  • If no: proceed to queue for background compilation (A-2)

A-2 — Background compiler thread

Files: runtime/src/overlay_compiler_thread.c

A single long-lived thread started at game handoff:

loop:
  wait on queue semaphore
  dequeue OverlayEntry*
  set state = COMPILING
  emit_c_for_overlay(entry)        // calls into recompiler lib
  compile_c_to_dll(entry)          // invokes GCC/Clang subprocess
  LoadLibrary + register dispatch
  set state = COMPILED

The recompiler is embedded in the runtime as a static library for dev builds. For retail builds, the compiler subprocess path uses the system C compiler if present, or skips to "write JSON only" mode if not (Layer A gracefully degrades to JSON-only on systems without a toolchain).


Build order

  1. B-1 — capture set + JSON dump (unblocks everything)
  2. B-2 — psxrecomp-game overlay compilation (unblocks B-3)
  3. B-3 — ingestion script (completes the contribution loop)
  4. A-1 — cache DLL check (fast path for returning users)
  5. A-2 — background compiler thread (self-healing on first run)

B-1 through B-3 are useful independently: they close the developer feedback loop and give users a way to contribute without any runtime compilation. A-1 and A-2 are the quality-of-life layer.


What already exists

Component File Status
Dirty-RAM interpreter (last-resort fallback) runtime/src/dirty_ram_interp.c Done
CD DMA ring log (cd_read_log TCP cmd) runtime/src/dma.c Done (filter + 65536 ring, 2026-05-30)
Dispatch table infrastructure runtime/src/full_function_emitter.cpp Done
Recompiler core (MIPS→C) recompiler/src/ Done
game.toml config loader recompiler/src/config_loader.cpp Done