Files
psxrecomp/docs/RECURSION_BUG.md
Matthew Stanley 55cec09acd docs: retire stale sljit references, attribute TinyCC
The sljit Tier-2 in-process JIT was replaced by the tcc tier in c2a3f6ad
(2026-06-25) and removed outright in 5b7e69b4 (2026-07-15). That removal
reworded the C-level comments but touched no markdown, so the prose still
described sljit as a live, default-on backend.

Backends today are static -> gcc -> tcc: gcc is the development default when
it is on PATH, tcc is the toolchain-free path for players. Both are external
compilers invoked as a subprocess that produce a DLL; neither is an in-process
JIT. docs/ARCHITECTURE.md, docs/BUILDING.md, docs/EXECUTION_MODEL.md,
docs/COMPILING_OVERLAYS.md and README.md already said this correctly and are
unchanged here.

- THIRD_PARTY_ATTRIBUTION.md: the sljit entry claimed code vendored at
  lib/sljit/ and compiled into the runtime. That directory does not exist.
  Replaced with a TinyCC entry, which was missing entirely despite tcc being
  the compiler intended to reach end users.
- docs/internal/SLJIT_PERSIST_CACHE.md: deleted. It documented the persisted
  shard cache of a subsystem that no longer exists, as "implemented" and
  "default-on". docs/internal/README.md updated to point at the current model
  instead of a dead link.
- docs/ASYNC_OVERLAY_COMPILE.md: superseded-in-part header. Deliberately NOT
  rewritten sljit->tcc: the passages about serializing .sljit blobs,
  overlay_sljit_deserialize and an in-memory JIT registry describe a mechanism
  tcc never used, so renaming it would trade one false claim for another. The
  async worker/file-handoff design and the CPS work remain accurate.
- docs/OVERLAY_CACHE_V2.md: sljit dropped from the dispatch priority chain.
- ISSUES.md: Issue #7 closed as superseded — the backend it reported against
  was deleted rather than fixed.
- docs/internal/COSIM_ORACLE.md: sljit_helpers left CPUState with the tier.
- Dated session logs (RECURSION_BUG.md 25.5, BACKDROP_PRELOAD.md,
  ecosystem-watch.md) keep their narrative and get a pointer to the removal.

One thing this does not fix: docs assert the tcc toolchain is "downloaded at
release-packaging time and bundled beside the game exe". runtime/src/main.cpp
does look for <exe_dir>/overlay_toolchain/, but no script in this repository
creates it. The attribution entry now states only what the code does and notes
that if packaging supplies the bundle, it happens outside this repo and must
carry the TinyCC notice. Flagged rather than papered over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:16:34 -07:00

83 KiB
Raw Permalink Blame History

Tomba long-run recursion freeze — working doc

Branch: bug/recursion. This is the single source of truth for the long-run freeze investigation and the soak-harness regression that is currently blocking reproduction. Update it as we learn.


1. The bug we are actually chasing (the freeze)

Symptom. Tomba runs correctly for a long time, then hard-freezes. Intermittent; often sooner than 15 minutes but not reliable. The captured specimen is _freeze_specimens/tomba_4393_freeze_46440.dmp (frame 46440).

Root cause (from the minidump). A runaway guest recursion that never unwinds. The per-frame game-flow state machine re-enters itself across the compiled ↔ dirty-RAM-interpreter boundary. Validated, symbolized cycle:

exec_one (main loop 0x8001A51C)
  -> psx_dispatch_game_compiled(0x8001A954)
    -> 0x8001A954 -> 0x8001AC00 -> 0x8001B2B4 -> 0x8001B5A8 -> 0x80046264
      -> dirty_ram_dispatch
        -> psx_dispatch_game_compiled(0x8001A954)   <-- back to the top

Repeats ~1443 times -> native fiber stack overflows -> stack-depth guard halts the process (psx_fatal_halt, which parks or exits). The state-machine fields [+0x4a]/[+0x4c]/[+0x4e] on the controller object at 0x801FD800 are pinned at 1/1/1 and never advance — that is the cycle that won't terminate. The ~1443 frames overflow nearly instantly once the cycle starts; the "~15m / intermittent" is how long until the trigger condition is met, not how long the recursion runs.

What it is NOT (all prior hypotheses, refuted):

  • NOT data corruption — 0x8001a954 is not a wild pointer; it exists nowhere in RAM as data.
  • NOT nested interrupt delivery — in_exception=0, exception counts normal.
  • NOT a fiber deadlock/livelock.
  • Scene id intact, table DAT_8007c640 intact.

Why it's slippery to capture. g_psx_dispatch_depth reads only 5 — it is blind to this, because the 1443 frames are direct func_X(cpu) calls it does not count. The recent_fn ring is time-ordered, so it shows only the leaf churning at the trip, not the recursing cycle. This is the entire reason the native_stack tool exists (see §3).

Suspected trigger. CD-DMA dirty-page over-marking: dma.c:695 marks every DMA'd word executable/dirty and never clears it (463/512 RAM pages dirty at the freeze), time-correlated with the onset of boundary re-entry.

Fix territory (NOT yet done). The interp<->compiled call/return contract (dirty_ram_dispatch / psx_dispatch_game_compiled) must unwind at the boundary instead of nesting; and/or stop dma.c:695 marking non-code CD-DMA data as executable/dirty. Same family as the "wild call contract" recursion bugs (Bug A/C/D). The fix is in the recompiler, never in generated C.


1a. Confirmed organic capture — frame 50241 (build-recursion, 2026-06-17)

Second specimen, captured live by the 4-soak harness + harvest watcher (_freeze_specimens/soak_report_20260617_171145.json). The native_stack tool worked end-to-end (walked 300000 frames, decoded the cycle); the early-flush was not even needed. Confirms §1 and adds the guest-level mechanism:

  • reason: native-stack guard tripped — runaway guest recursion.
  • recursion_func = 0x8004DEE0. At halt: v0 = 0x8004DEE0, ra = 0x8004DEE8 (= v0 + 8) -> the guest ran a jalr v0 whose target slot points back into the recursing chain. Indirect call through a jump-table / function-pointer slot — the wild-call family (cf. seesaw func_8001DFD4, bogus v0=0x8001DFF8), NOT a benign loop. pc = 0x00000000 = the dispatch pc==0 unwind sentinel.
  • Steady-state lap (~23074x, ~13 host frames each): exec_one -> dirty_ram_dispatch -> psx_check_interrupts -> psx_dispatch_impl -> func_80046264 -> func_8001B5A8 -> func_8001B2B4 -> func_8001AC00 -> func_8001A954 -> exec_delay_slot -> psx_dispatch_game_compiled -> exec_one.
  • Dirty pages on the interpreted path: 0x80063xxx and 0x8011xxxx (overlay region); last_store_pc = 0x80116A24; dirty_block_tail tail shows self-referential dispatch (target == ra == 0x80116204).

Mechanism, restated with evidence: a guest indirect transfer (jalr through a jumptable/fp slot) crosses the compiled<->dirty-interp boundary, and the recompiler implements each crossing as a nested host call (psx_dispatch_game_compiled / dirty_ram_dispatch / exec_one) that never returns. The guest transfer is jump/tail-style (guest stack does not grow proportionally) but the HOST stack grows ~13 frames/lap -> fiber overflow. CD-DMA dirty-marking (dma.c:695) is what forces those overlay/0x80063xxx pages onto the interpreted path in the first place.


2. The reproduction harness ("soaks") — and the regression blocking it

Workflow. Run 4 instances in parallel ("Soak A/B/C/D"). Claude launches them; the user navigates 2 to a New Game and 2 into Dwarf Forest (overworld), then lets all four idle. Running 4x in parallel is how we hit the intermittent freeze fast. Per-instance memcard dirs (saves_a..d), software renderer, 4:3, ports 4393-4396 (tomba_soak_{a,b,c,d}.toml). Stay off heavy host work while soaking (a wall-clock starvation watchdog, 4s no-heartbeat -> exit(2), false-trips under host load).

The regression — RESOLVED. It was a stale build artifact (build-soak), NOT the tooling and NOT master. build-soak's generated .obj were byte-identical to the good build, but its runtime objects predated the session and the link was incoherent; a clean from-scratch rebuild (build-recursion = master + native_stack tooling + early-flush) does not crash on overworld-load (user-confirmed). The "crash the instant anything loads" symptom is gone. See §4 / §5.


3. The native_stack tool (the thing that may be causing the regression)

Uncommitted tooling added to runtime/src/crash_trace.c (129 lines): a host-stack walker append_native_stack() that recovers the true recursion cycle (the recent_fn ring can't — see §1). It is called only from psx_crash_trace_dump(), i.e. only while a crash/fatal/SEH dump is already in progress. It walks from the faulting/the current rsp up to the fiber StackBase, keeps only validated return addresses (value in .text preceded by a call), run-length-collapses them, and emits module-relative RVAs + a histogram. Every stack read is bounded by VirtualQuery; symbolize offline with nm (_freeze_specimens/analyze_named.py, decode_report.py).

Known hazard (handoff issue #3). Both psx_signal_handler and psx_seh_handler run on the faulting stack and call the dump. If the original fault is the recursion's stack overflow, append_native_stack runs on the already-exhausted stack and can re-fault — taking the whole report with it (silent SIGSEGV, no JSON). As written, the tool meant to capture the overflow can destroy the very report it was built for.


4. Evidence collected this session (artifact-level)

Compared TombaRecomp/build-soak (A, has tooling, crashes) vs TombaRecomp/build-soak-e (B, pure master, healthy). MD5'd all 49 objects in each: only two differ — crash_trace.c.obj and main.cpp.obj. main.cpp is an AppImage getenv("APPIMAGE") change, inert on Windows. All generated BIOS/game code (SCUS_942.36_*.c.obj, SCPH1001_*.c.obj) is byte-identical MD5. The psxrecomp reflog shows HEAD parked at master 3ba40b0 since 09:34 that day, so A and B were regen'd off the same master tip — not a flavor trap, not stale generated code. (Those were the handoff's two hypotheses; both refuted.)

Open logical tension — RESOLVED. The tension (crash_trace.c's only new code runs in the terminating dump path, yet B "got into game") dissolved when a clean rebuild WITH the tooling did not crash. The crashing build-soak was a stale/ incoherent incremental build; neither the tooling nor a master regression was at fault. The session pivoted from "explain the overworld-load crash" to "capture and fix the actual freeze" — which we then did (§1a, §7, §8).


5. Status & plan (updated 2026-06-17, end of session)

Done this session:

  • Branch bug/recursion created; native_stack tooling committed (d9b6ae5).
  • native_stack tool hardened with an early-flush (commit e63fdee): the report is written to disk BEFORE the risky host-stack walk, so a walk fault can no longer destroy it. (Turned out not to be needed for the captures — the walk completed — but it's the right robustness fix and unblocks future overflow captures.)
  • Overworld-load regression (§2) resolved — stale build artifact; clean build-recursion is healthy.
  • Freeze captured organically (3 specimens: A frame 50241, C frame 50088, B Dwarf Forest) via the 4-soak harness + harvest_soak.py. Deterministic: all three halt with identical v0=0x8004DEE0, ra=0x8004DEE8, pc=0. Both New-Game and Dwarf-Forest areas reproduce → universal per-frame code path.
  • Root-caused to the interpreter↔compiled boundary (§7) + Ghidra-mapped the guest state machine (§8). External review (§9) rates this Hypothesis A; flags a distinct Hypothesis C to rule out with an oracle trace (§10).

Builds in tree (do NOT delete): build-recursion (X = master+tooling+early-flush, the good build), build-soak/build-soak-e (the A/B subjects). Soak configs tomba_soak_{a,b,c,d}.toml (ports 4393-6, software, per-instance saves_a..d seeded from the real card). harvest_soak.py watches the shared psx_last_run_report.json and decodes with allsyms.txt.

Next (in order):

  1. A-vs-C static check: disassemble the dynamic routine around 0x80063864 (the dirty page func_80046264 first calls into), specifically the instructions before/after its transfer to 0x8001A954 and its stack-restoration / intended return path (§13). Is the re-entry a legit guest tail-transfer (A) or an interp artifact (C)? 0x80063xxx is runtime-loaded — read from a PARKED instance via the debug server read, not the static EXE.
  2. Oracle disambiguation (§10): trace control flow at 0x8001A954 / 0x80046264 / 0x80063864 / the interpreted jal 0x8001A954 vs psxref @ 4380. Strongest metric: main_loop_entries_per_vblank. Cheap pre-check: on the oracle, sample state[0x4a] + 0x8009BCDD over minutes of play — if they sit unchanged for thousands of frames, Hypothesis B is falsified outright.
  3. Implement the single mixed-dispatch-owner fix (§11) — RUNTIME-only (dirty_ram_interp.c + cpu_state.h), so rebuild, no regen. Re-soak all 4 (New-Game area ≈ 14-min repro) to validate the freeze is gone.
  4. Separately: tighten CD-DMA dirty-marking (§12) — not the root cause but it makes the broken boundary common.

No stubs, no HLE, never edit generated C; the fix is in the recompiler/runtime.


6. Build & run (this project)

cd ../TombaRecomp
export PATH=/c/msys64/mingw64/bin:$PATH TMP=/c/msys64/tmp TEMP=/c/msys64/tmp
# regen tools + BIOS + game (generated/ is gitignored; off master tip):
cmake --build ../psxrecomp/recompiler/build --target psxrecomp-game psxrecomp-bios -j8
(cd ../psxrecomp && ./recompiler/build/psxrecomp-bios.exe --config bios/SCPH1001.toml)
../psxrecomp/recompiler/build/psxrecomp-game.exe --config tomba_soak_e.toml
# build:
cmake -S . -B <build-dir> -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_DEBUG_TOOLS=ON -DPSX_RECOMP_UI=OFF \
  -DCMAKE_C_COMPILER=C:/msys64/mingw64/bin/gcc.exe -DCMAKE_CXX_COMPILER=C:/msys64/mingw64/bin/g++.exe
cmake --build <build-dir> --target psx-runtime -j8
# run (per-instance card; taskkill first):
taskkill //F //IM psx-runtime.exe 2>/dev/null
nohup ./<build-dir>/psx-runtime.exe --game tomba_soak_a.toml > _soak_a.log 2>&1 &
# decode a freeze report's native_stack:
nm -n <build-dir>/psx-runtime.exe | awk '$2~/^[Tt]$/{print $1,$3}' > ../_freeze_specimens/allsyms.txt
python ../_freeze_specimens/decode_report.py psx_last_run_report.json ../_freeze_specimens/allsyms.txt

Hard rules: fix the recompiler, never generated C; no printf/log files (TCP debug server + always-on rings; read psx_last_run_report.json for fatals); per-instance memcard dir per concurrent instance (sharing corrupts the card -> fake "regression"); software renderer for soaks; taskkill //F //IM psx-runtime.exe before any launch.


7. The runtime contract — why the host stack leaks (the mechanism)

The codebase ALREADY solves tail-calls for compiled code, via two pieces:

  • psx_dispatch_impl (generated; emitted in recompiler/src/full_function_emitter.cpp:1103) is a tail-call trampoline:

    for (;;) {
        cpu->pc = 0;
        dispatch(addr);                 // run one compiled func, or interp a dirty block
        if (g_psx_call_bail) { ...resolve/propagate/flatten... }
        if (cpu->pc == 0) { ...contract check; return... }   // genuine return
        addr = cpu->pc;                 // TAIL call: re-dispatch in-place, NO host nesting
    }
    

    A compiled function signals a tail-call by setting cpu->pc and returning; the loop re-dispatches without growing the host stack. Compiled tail-call loops stay flat.

  • psx_call_contract (runtime/include/cpu_state.h:132, static inline) is the wild-return unwind. After a direct call returns, if guest $sp/$ra don't match the call site, it raises g_psx_call_bail = 1, sets cpu->pc = $ra (the true target), and unwinds frame-by-frame until the frame whose (ra,sp) matches resolves it (clears bail, resumes).

The dirty-RAM interpreter BYPASSES both. In runtime/src/dirty_ram_interp.c, exec_one:

  • guest JR/J (case 0x08 ~L550, 0x02 ~L697): correctly unwind — cpu->pc=target; return 1.
  • guest JAL/JALR (case 0x03 ~L703, 0x09 ~L556): NEST — call psx_dispatch_game_compiled(cpu, target) directly. The post-call psx_call_contract(...) is only reached if that nested call RETURNS.
  • dirty_ram_dispatch_inner (~L1170-1186) does the same for a surfaced compiled target: nests psx_dispatch_game_compiled instead of returning cpu->pc=target to the trampoline.

So when interpreted overlay code does jal func_8001A954 (tail-transfer back into the per-frame loop), the interpreter nests a host frame. The previous lap's compiled chain (func_8001A954 → … → func_80046264) is still suspended (it called into the interpreter and is waiting for a return that never comes). Every lap leaks the chain; the psx_call_contract that would unwind it is unreachable because the nested psx_dispatch_game_compiled never returns (it descends forever).

Proof it's host-only, not real guest recursion: at the halt the guest sp = 0x801FE338 — only ~7 KB below the stack base 0x801FFFF0 — and it does not grow across all ~23,074 laps. The guest is iterating (tail dispatch); the host is recursing (~13 frames/lap).


8. The guest state machine (Ghidra: SCUS_942.36_no_header, base 0x80010000)

Two nested selectors, both pinned at the freeze:

Selector 1 — func_8001A954 (per-frame state dispatcher). Reads state[0x4a] (halfword) from the object pointer *(0x1F8001D4) and switches: 0 → func_8001A9F0, 1 → func_8001AC00, 2 → func_8001D2F0, 3 → func_8001D480, else return. Stuck at state[0x4a] == 1 → calls func_8001AC00 every frame. It only reads the selector; it never writes it. (Adjacent fields [+0x4a]/[+0x4c]/[+0x4e] read 1/1/1 and never advance.)

Chain: func_8001AC00 → func_8001B2B4 → func_8001B5A8 → func_80046264.

Selector 2 — func_80046264. Runs a per-frame call sequence (incl. the dirty hop jal 0x80063864 first), then jump-table dispatches on a byte at 0x8009BCDD (16 cases, table at 0x80013C60, computed jr v0). Stuck case → 0x8004DEE0 stub (jal 0x8004DFA0; move a0,s0; j 0x8004DF68). Also at 0x80046340 it reads a word at 0x8009BCC8 (scene id, reads 0) and one branch calls overlay 0x8011AF78. A third byte selector sits at 0x800A539A inside the 0x8004DEx stubs.

func_8004DFA0 (the stuck case's handler): a per-object GPU/GTE render routine — GTE transforms, appends primitives to the ordering table at *0x1F800164, loops over vertices, jr ra. Returns normally; advances no selector.

So nothing in the hot loop (func_8001A954, func_80046264, func_8004DFA0) advances either selector. Whatever flips them (an event/input/animation/CD-script step) lives elsewhere.


9. External review verdict (fresh-context second opinion)

  • Hypothesis A (interp↔compiled boundary nesting) — overwhelmingly likely. Mechanically exact: flat guest sp + host +~13/lap + the unreachable contract check fully explain "thousands of successful iterations, then overflow."
  • Hypothesis B (HW/event state wedge) — no positive evidence. state[0x4a]==1 plausibly just means "active gameplay" and can stay 1 the entire session. 0x8009BCDD indexes a render handler (func_8004DFA0) → looks like an object/render-TYPE selector, not a temporal counter; an object using the same renderer every frame for its whole lifetime is normal. Adjacent 1/1/1 may be flags, not counters. Normal gameplay continuing right up to the guard trip favors A (a real wedge would show visible guest-level nonprogress before the host stack happened to exhaust).
  • Hypothesis C (NEW, must be ruled out) — interp control-transfer bug. The interpreter could mishandle a JAL delay slot / return-PC / a subsequent JR and fabricate the re-entry of 0x8001A954. The flat guest stack does NOT rule this out. A short oracle PC-trace distinguishes C from A.

Conclusion: fix/neutralize the boundary stack leak (A) before any broad IRQ/event hunt (B); run the oracle trace to exclude C first.


10. Oracle disambiguation plan (psxref @ 4380)

Cheap pre-check (falsifies B without reproducing the freeze): on the oracle, sample state[0x4a] (via *(0x1F8001D4)+0x4A) and the byte at 0x8009BCDD over several minutes of ordinary gameplay. If they commonly sit unchanged for thousands of frames, the "stuck selector" premise is dead.

Decisive control-flow trace. At each entry to 0x8001A954, 0x80046264, 0x80063864, and the interpreted jal 0x8001A954, record: emulated_cycle, vblank_serial, pc, ra, sp, *(0x1F8001D4), state[0x4a], *(uint8*)0x8009BCDD, I_STAT, I_MASK. Plus control-transfers only: source_pc, opcode kind (J/JAL/JR/JALR), target, new_ra, sp. Compare a few hundred laps recomp vs oracle:

Result Verdict
Same PC/target/RA/SP sequence (incl. repeated 0x8001A954 entries) A — guest valid, only host representation leaks
First divergence at JAL/JR target, RA, delay-slot result, or return PC C — interp control-transfer semantics wrong
Flow agrees, then a memory/IRQ value differs and only recomp stays in the loop B — HW/event state divergence

Strongest single metric: main_loop_entries_per_vblank (same count + flat guest sp on both ⇒ selectors are irrelevant to the stack bug). Trace the pointer at 0x1F8001D4 too, not just [ptr+0x4A] — the object may be replaced and each replacement may start in state 1.


11. Fix design (single mixed-dispatch OWNER) — RUNTIME-only, no regen

Core rule: exactly one owner of mixed-mode dispatch per native call chain. exec_one must never blindly call psx_dispatch_game_compiled in a way that can establish another interp→compiled→interp chain.

Make the bail typed and per-CPUState (not a global bool):

typedef enum { PSX_BAIL_NONE=0, PSX_BAIL_WILD_RETURN, PSX_BAIL_MIXED_TRANSFER,
               PSX_BAIL_EXCEPTION, PSX_BAIL_STOP } PsxBailKind;
// in CPUState: PsxBailKind bail_kind;  bool mixed_dispatch_active;

The first dirty_ram_dispatch becomes the owner (mixed_dispatch_active=true). A nested boundary crossing does NOT start a new loop — it publishes the target and unwinds to the owner:

if (cpu->mixed_dispatch_active) { cpu->pc = target;
    cpu->bail_kind = PSX_BAIL_MIXED_TRANSFER; return 1; }

The owner re-dispatches in a for(;;) loop (compiled or interp), consuming PSX_BAIL_MIXED_TRANSFER to continue and other bail kinds to unwind. Real returns to the original compiled caller still return normally; native depth stays bounded.

Supporting changes:

  • Externalize interpreter continuation into an InterpState (pc, pending-load reg/value/valid, cycles); yield only AFTER fully committing the control-transfer instruction + its delay slot.
  • Replace the overloaded cpu->pc==0 sentinel with an explicit DispatchResult{kind,target} where practical — far easier to reason about than pc + return value + a global bool.

Regression-prone cases to test (this is the Bug A/C/D contract area): JAL/JALR delay-slot exec before yield; JR/JALR target captured before delay-slot mutates the source reg; JALR with rd==rs aliasing; MIPS load-delay commitment; exceptions in a branch delay slot (EPC/BD bit); cycle/event accounting exactly once across a yield; interrupts arriving while a mixed-dispatch owner is active; guest returns through KSEG aliases; wild returns must pass THROUGH the owner, not be consumed by it; generated code caching guest regs in native locals across calls; the pc==0 overload.

Long-term (bigger codegen change, not now): continuation-passing / resumable compiled frames — a compiled block returns the next guest PC/result and a central dispatcher chooses compiled vs interp — eliminates host guest-call mirroring entirely. The single-owner boundary is the lower-risk bridge for the current design.


12. CD-DMA "dirty/executable" marking (dma.c:695) — contributor, not root cause

The CD-ROM DMA loop marks every word it writes executable/dirty (dirty_ram_mark_executable_range(addr, 4)). It conflates three properties: written vs modified-relative-to-compiled-bytes vs executed. CD DMA carries textures/audio/compressed data, not just code. This is NOT the stack-leak root cause, but it makes the broken boundary common (page-wide dirtiness forces static code onto the interpreter when unrelated data shares a page).

Better model (aligns with the overlay-cache work): per-region write generation/version + compiled-code byte-range + expected hash + ever-executed flag + dynamic translation cache. On ANY write (CPU/CD-DMA/other DMA/decompressor/ BIOS copy), invalidate only the OVERLAPPING compiled ranges. On dispatch: if a compiled version exists and bytes/generation match → run compiled; else interpret/compile-cache this byte-version. "Executable" becomes true on instruction fetch, not on write. Natural end state: address + content-hash → compiled specialization, with CD DMA merely one way a new byte-version arrives.


13. Immediate next disassembly target

The complete dynamic routine around 0x80063864 — the dirty page that func_80046264 first calls into — especially the instructions before and after its transfer to 0x8001A954, plus its stack restoration and intended return path. This is the static-side A-vs-C discriminator: is the re-entry of 0x8001A954 a legitimate guest tail-transfer (A), or does the interpreter fabricate it via a mishandled delay-slot / RA / JR (C)?

0x80063xxx is runtime-loaded/dirty, so it may NOT match the static EXE — read it from a parked instance (python tools/debug_client.py --port 4393 read 0x80063864 <len>, length in DECIMAL) after re-soaking to a freeze, or from a captured overlay dump. Earlier parked read of 0x800638C4 showed a clean GTE-loader leaf (lw t0..t2; mtc2 ×3; jr ra), so at least part of 0x80063xxx is well-formed code.


15. Strategy reset — control-flow flight recorder (session 2 end; ChatGPT-assisted)

The oracle RAM-diff of two separately-navigated idle emulators was a dead end (alignment noise; §14). New plan (external review): stop diffing RAM; build a triggered control-flow flight recorder on OUR side, find the edge that flips at ~frame 50k, then walk a backward causal slice. Use the oracle only at the END, for a small causal address set (not whole-RAM).

My analytical narrowing (reason first, then confirm with the recorder):

  • The 23k laps are WITHIN ONE FRAME, not accumulated across frames: the host call stack resets to the SDL frame loop every frame (the per-frame dispatch returns, then present, then next frame), so host depth CANNOT accumulate across frames. So a single frame's per-frame-update spirals to 23k and never returns.

    ❌ FALSE — superseded by §17. There is NO per-frame return to a shallow loop: present/s_frame_count++ is a gpu_vblank_tick→sdl_vblank_present callback fired from within the guest stack, so host depth CAN accumulate across frames. This premise was load-bearing for the whole "frame-50k trigger" framing; see §17.

  • The trip cycle advances psx_advance_cycles(1) + psx_check_interrupts per lap, so 23k laps ≈ 23k guest cycles ≪ ~565k cycles/frame → no VBlank can fire before overflow (~40µs guest time). So it is NOT "stuck waiting for a late interrupt" (scheduler-starvation-wait); it is a control-flow cycle — the per-frame update re-enters itself. => we are in ChatGPT's "branch/target changes abruptly → state/interp control-flow divergence" or "interpreter semantic bug" row.

Decision tree (which problem do we have):

Observation Diagnosis
depth grows gradually across frames; CF + scheduler stable pure mixed-boundary stack leak
23k laps in ONE frame; cycles/events don't advance mixed dispatch starves scheduler
scheduler advances but a branch/target changes abruptly state / interp CF divergence
branch operands identical but recomp picks a different target interpreter semantic bug
recomp value differs, last-writer = a HW-produced write hardware emulation bug
watermark unwind stops the outer pump (normal exec didn't) bailout impl incomplete

Execution order (focused, NOT another broad diff):

  1. Per-frame frequency + first-occurrence of the re-entry edge (interp → 0x8001A954) and the jumptable edge (0x8004630C → its jr target). Is the re-entry ordinary bounded per-frame behavior that stops TERMINATING at ~50k, or a brand-new edge? (ChatGPT expects at least one is normal rendering behavior.)
  2. Last-writer map on the values controlling those edges (selector 0x8009BCDD, the jump-table entry, state[0x4a], the loop's termination variable): record {pc, frame, cycle, old, new} per RAM write; when a load feeds the controlling branch, emit a backward slice "edge taken because reg==V loaded from Y last written at PC Z." That answers what changed and why, vs 900 differing bytes.
  3. Indirect-site state per branch: {last_target, first_seen_frame, last_change_frame, counts_by_target} — makes "did func_80046264 start taking a new target at ~50k?" instantly answerable.
  4. Oracle, narrowly: only after #1–3 name a small address set, ask Beetle (write-trace trace_arm/ram_diff, or a tiny 5-address PC hook added locally inside the Beetle core — much smaller than lockstep) whether real HW takes the same edge, how often, and whether it ever transitions the controlling value the way we do. Anchor epochs on a once-per-update write (OT-cursor reset / game frame counter), local-align (allow ins/del), filter framebuffer/OT/stack/ audio/RNG, compress repeated identical writes.

Hooks available (runtime, no regen): s_frame_count (main.cpp:1189), psx_cycle_count (psx_cycles.c), the interp_enter_compiled boundary (dirty_ram_interp.c — the re-entry edge target==0x8001A954 passes through here), g_dirty_ram_flow_log ring, crash_trace.c report dump. First instrument = a re-entry-edge per-frame count ring dumped on the guard trip.


16. Flight-recorder runs #1/#2 — the recursion is VARIABLE; use the data-driven rings

Two turbo'd soaks with the per-frame site recorders (commits 2475ee0 → 8cbea2a, branch bug/recursion). Turbo works and is organic-safe (TCP turbo enabled=1 → ~2.4× block-dispatch rate; the freeze is identical, just reached faster).

  • Run #1 (frame 63826, recursion_func 0x8004DEE0): the edge I instrumented, interp→0x8001A954, is ORDINARY — exactly 1/frame, every frame, even on the crash frame (max_per_frame=1). The cycle counter advances normally per frame. So the 23k-deep recursion is NOT that edge.
  • Run #2 (frame 49599, recursion_func 0x8005FF60): dispatch_8001A954 = max_per_frame=0, last_frame=0xFFFFFFFF → dirty_ram_dispatch was NEVER called with 0x8001A954. So func_8001A954 isn't re-dispatched via dirty_ram_dispatch either. And recursion_func VARIES (0x8004DEE0 / 0x8004DFA0 / 0x8005FF60). => address-specific counters are the WRONG instrument; the recursing functions change run to run.

The data-driven cycle (from dirty_block_tail, run #2, frame 49599) is the real picture: dirty_ram_dispatch targets are overlay blocks 0x8011xxxx–0x8013xxxx (+0x800E9xxx), called (the ra field) from main-EXE 0x8002xxxx–0x8005xxxx (the 0x8005FF60 cluster). Repeating targets (0x8012608C×4, 0x800E9120×4). So the runaway is the per-frame MESSAGE/RENDER overlay system (main-EXE message handlers 0x8004–0x8005 ↔ interpreted overlays 0x8011–0x8013), and which functions recurse depends on which message/state is rendering when it trips. The func_8001A954 chain from the minidump (§1) was just one instance of this family.

BUG fixed: the 3 site recorders (256 entries × 3) overflowed crash_trace.c's 8KB re954[] sub-buffer → truncated the whole report JSON. SITE_CAP 256→32 (committed) so reports parse. But the recorders are at the wrong sites anyway.

PIVOT for next session — stop guessing addresses:

  1. Re-soak with the fixed buffer (turbo'd) and read the existing data-driven rings from a clean report: native_stack (the host cycle), dirty_block_tail (the dirty_ram_dispatch target+ra cycle — the ra names the re-dispatch caller), dispatch_tail. These show the ACTUAL cycle without guessing.
  2. From the ra callers (main-EXE 0x8002–0x8005), Ghidra those functions to find the per-frame loop and its termination condition.
  3. Add the last-writer map (hook psx_write_word/byte/half in memory.c — generated code writes via cpu->write_word→these; writer-PC via... NOTE g_debug_last_store_pc is NOT set per-store in generated code, count was 0 — need another writer-PC source, e.g. a global cpu via debug_cpu_ptr (memory.c:252) + its pc) on the controlling value → the causal slice "loop didn't terminate because reg==V from addr Y written by PC Z." psx_read_word/half/byte (memory.c 586/671/727) are free funcs usable in the write hook to read state.
  4. Determinism check worth doing: does the freeze frame VARY run-to-run (63826 vs 49599) or is it input/navigation-dependent? Both runs idled on a dialogue; frames differ.

14. Fix attempt + CORRECTED understanding (session 2, 2026-06-17 PM)

Implemented + live-tested the single-mixed-dispatch-owner fix (§11) at the interp↔ compiled boundary (commits f488dc5 → e59043b, branch bug/recursion). Two iterations: a nesting-counter trigger that LEAKED across the fiber exception longjmp (psx_exception_longjmp never returns → the post-call decrement is skipped → the counter crept up → false-tripped on a benign call → froze a render) was replaced by a host-stack-USAGE watermark (PSX_MIXED_STACK_KB, default 700; can't leak). Gated by PSX_MIXED_OWNER (default on).

RESULT — the fix is a SYMPTOM-fix and does NOT make the game playable. Live 2×2 soak (A/B fix-on, C/D fix-off, overlay_cache OFF): ALL FOUR froze at ~14 min (~frame 50k), fix on or off. C/D overflowed (recursion crash, harvest report); A/B hung "(Not Responding)" (the watermark surfaced into the still-wedged guest state and the main thread spun). The fix bounds the host stack (no overflow crash) but the guest still wedges → hang — arguably worse for debugging than the crash-with-report. Keep it gated; default it OFF in a future pass.

CORRECTION — strike a wrong mid-session assessment. The freeze is NOT dialogue-triggered. It is the ~14-minute IDLE freeze, time/frame-based (~frame 50k), location-independent (New Game area, Dwarf Forest, or idling on a dialogue — all identical; same conclusion we reached for Dwarf Forest earlier). The screenshots of a frozen dialogue were just where the game sat idle. The e59043b commit message claiming it "fires at the first dialogue" is WRONG: overlay_cache OFF does not move the trigger earlier — the ~14-min timing is the SAME with cache on or off, so the trigger is NOT interp-coverage; it is a time/frame- correlated divergence. (USER correction, authoritative.)

Root, restated: at ~frame 50k of idle, our sim diverges from real hardware — which idles indefinitely, USER-CONFIRMED — and tips the per-frame loop (func_8001A954 → func_80046264) into the boundary recursion. The recursion is the SYMPTOM; the time-based divergence is the ROOT. Every soak so far was us-vs-us; we have never diffed against real hardware over the idle window.

NEXT = the oracle. psxref (<psxref>, Beetle PSX, TCP 4380, shares card1.mcd; see psxref_oracle / memory/psxref_oracle.md). Run BOTH our recomp and psxref idle (turbo if possible, to reach ~frame 50k in well under 14 min wall clock) and find the FIRST state / control-flow divergence around frame ~50k — that divergence is the bug. Candidate signals to diff (find_first_divergence, emu_read_ram, emu_trace_addr, emu_step): the guest frame/timer counters, the state object *(0x1F8001D4)+0x4A, the dirty-bitmap growth, IRQ I_STAT/I_MASK, root-counter state. Whatever first differs from real HW is the lead. The host-stack fix is a dead end for fixing it (kept only as a robustness/observability aid).


17. REFRAME — gradual host-stack accumulation, not a "frame-50k trigger" (session 3, 2026-06-17, ChatGPT-assisted via user)

The §15 premise is architecturally false, and it was load-bearing. §15 asserts the 23k laps happen within one frame "because the host call stack resets to the SDL frame loop every frame (the per-frame dispatch returns, then present, then next frame)." There is no per-frame return to a shallow main loop. Verified in the runtime:

  • The guest runs continuously on a 1 MB guest fiber (traps.c — psx_fiber_create(1024*1024, …); the BIOS runs each TCB thread on its own fiber).
  • s_frame_count++ / present happen inside sdl_vblank_present(), which is a callback fired by gpu_vblank_tick() ← psx_check_interrupts() (gpu.c:743, interrupts.c:288) — i.e. from within the guest's call stack as a side effect of cycle advancement, NOT by unwinding the guest stack back to a loop.
  • psx_check_interrupts appears ×23,084 inside the recursion histogram itself (§1a / the 205330 capture) — vblanks fire throughout the accumulation, not after.

So the frame counter advances 1/frame while the host fiber stack grows monotonically and never unwinds. That is exactly the condition for a gradual cross-frame leak:

Model A (predicted). Every game-logic tick (30 Hz), the per-frame message/render overlay walk crosses the interp↔compiled boundary, and (per §7) the boundary nests one host call chain that never unwinds. ~1 leaked chain/tick accumulates on the fiber; after ~23k ticks the 1 MB fiber is exhausted and the native-stack guard trips. Frame ~50k is a CAPACITY limit, not a trigger. Real HW never does this because it doesn't mirror the guest call graph onto a host stack.

Why Model A fits the evidence better than Model W ("a state flips at ~50k"):

Evidence Model A (gradual leak) Model W (50k trigger)
frame counter advances 1/frame to the very end ✅ vblank is a mid-stack callback ⚠️ needs the whole 23k spiral inside one inter-vblank gap
psx_check_interrupts ×23,084 in the cycle ✅ vblanks fire all through accumulation ⚠️ then frames would advance — contradicts "one frame"
recursion_func varies run-to-run (0x8004DEE0 / 0x8004DFA0 / 0x8005FF60 / 0x8001A954) ✅ steady leak, arbitrary leaf at trip ❌ a fixed self-recursive fn would be constant
wall clock 23,074 × 30 Hz ≈ 12.8 min + boot ≈ 14 min ✅ exact —
50,241 − 2×23,074 = 4,093 (≈ leak starts at gameplay) ✅ ~2 display-fields / leaked chain = 1/tick @ 30 Hz —

The native_stack block in the reports is a snapshot at the trip — both models yield "23,074 copies on the stack at capture," so it never discriminated them. The missing measurement was always host-stack depth as a function of frame.

The decisive instrument (built this session, RUNTIME-only — rebuild, no regen): an always-on per-vblank sample of (frame, host_stack_used = TEB StackBase − rsp) on the guest fiber, decimated 1-per-128-frames into a 512-entry ring. Read live via the stack_profile TCP command (while the game is still responsive — no need to reach the overflow) and dumped in the crash report (before the early-flush, so it survives a native_stack-walk fault). Files: main.cpp (ring + sampler + stack_profile_json, sampled in sdl_vblank_present), debug_server.c (handle_stack_profile), crash_trace.c (report block). Also added PSX_FIBER_STACK_KB (traps.c) for the orthogonal stack-size sweep: if the overflow frame scales ~linearly with stack size (0.5×→~27k, 2×→~96k), accumulation is confirmed by a second independent method.

  • Verdict reading: used climbs ~linearly with frame from ~gameplay-start → Model A; the slope = leak rate, the x-intercept = leak-start frame. used stays flat then cliffs at one frame → Model W.
  • Boot baseline (validated live): frames 128–2576 sit flat at 1–6 KB (no gameplay loop yet → no leak), confirming the instrument reads the right quantity.

If Model A holds, the fix is §11 (single mixed-dispatch owner / resumable boundary) — but framed correctly: stop leaking one host chain per frame at the interp↔compiled boundary, NOT "find what flips at 50k." The §14 "NEXT = oracle idle-diff to find the first divergence at ~50k" plan is then misdirected — there is no 50k divergence to find; the divergence is at every boundary crossing from gameplay start. (Oracle still useful later to confirm real HW takes the same guest edges — Hypothesis C — but it is not the path to the root.)

VERDICT (2026-06-17, decisive — Model W, NOT Model A). Turbo'd idle soak, port 4393, PSX_MIXED_OWNER=0. The stack_profile curve is DEAD FLAT: used = 5 KB, max_kb = 7 KB, for every sample from boot through frame 48,504 (continuous decimated samples 128,256,…,47488,47616,…,48384 and the per-frame now=48504, ALL at 5 KB). Then the frame counter froze at 48,504 and the native-stack guard tripped with a deep recursion present (native_stack ~23,073 laps; recursion_func 0x8004DFA0). CPU went to 0 (parked, halt-and-serve).

  • Model A (gradual host-stack leak from ~frame 4093) is REFUTED. The host stack did not grow at all over 48,503 frames — zero accumulation. The 50241−2×23074=4093 arithmetic was a coincidence.
  • Model W (within-one-frame runaway re-entry) is CONFIRMED. The per-frame loop terminates normally to a ~5 KB depth every frame, until one frame (48,504) where it re-enters itself ~23,073 deep and overflows the 1 MB fiber within that single frame — which is why no vblank fires during the spiral (frame counter frozen) and the per-vblank sampler can't see the climb (the crash report's native_stack walker does; the two instruments are complementary, not contradictory). §15's conclusion ("within one frame") was right; its premise (return-based loop) was wrong — the loop stays shallow because it genuinely TERMINATES each normal frame, not because of a per-frame return to a main loop.
  • Trigger is event/state-driven, not a fixed frame counter: trip frame varies run to run (48,504 / 49,599 / 50,241 / 63,826).
  • Selectors at the freeze are the NORMAL idle steady-state (read live from the parked process): *(0x1F8001D4)=0x801FD800, state[0x4a/4c/4e]=1/1/1, word@0x8009BCC8(scene)=0, byte@0x8009BCDD(jumptable)=0. So the runaway is NOT a flipped top-level selector (Hypothesis B weakened, as §9 predicted) — it is the canonical §8 cycle func_8001A954→AC00→B2B4→B5A8→func_80046264→(interp overlay crossing)→…→func_8001A954 failing to terminate on one frame.

Corrected next step (re-points to §9 Hypothesis C / oracle, NOT a leak hunt): the loop re-enters via interpreted overlay blocks (dirty_block_tail: dirty_ram_dispatch to 0x800E9120 / 0x8012608C / 0x80124318 / 0x801168F4 … called from main-EXE ra 0x8002–0x8005). The question is whether that re-entry is a real guest tail-transfer (then real HW must escape it via an event we don't deliver — Hyp A/B) or an interp control-transfer artifact (Hyp C — interp mishandles a JAL/JALR/delay-slot/RA in an overlay crossing and fabricates the re-entry). Decide with (1) an onset-capture instrument that trips EARLY (≈a few thousand laps, before overflow) and dumps the dirty_ram_dispatch sequence at the first bad lap — capturing what STARTS the runaway, not the 23k-deep steady state that overwrites the ring; and (2) a narrow oracle check (psxref @4380): does real HW execute the same overlay crossing without the unbounded re-entry? The §11 boundary-resumability fix remains SYMPTOM-only (bounds the stack; guest still wedges → "(Not Responding)", per §14) — the root is whatever makes the loop stop terminating on the trigger frame.

The PSX_FIBER_STACK_KB sweep is now redundant confirmation only: under Model W a bigger stack just lets the single-frame spiral go deeper before tripping at the SAME trigger frame (it would NOT push the freeze to ~96k as a leak would).


18. REFRAME #2 — it is DIRECT compiled recursion, NOT the interp↔compiled boundary (session 3 cont., 2026-06-17)

Built an always-on boundary control-flow flight recorder (dirty_ram_interp.c: xprobe_event at every interp→compiled crossing — JAL/JALR at 888/1042, the block-loop tail-transfer at 1514, and the dd-site at 1352; per-frame summary ring + per-crossing detail ring; runtime-armable trip via xprobe_arm; xprobe TCP cmd; dumped in the crash report). Reproduced the freeze 3 more times (frames 44145, ~48504-class, 87176) — deterministic, recursion_func 0x8004DEE0/0x8004DFA0.

The decisive negative result. Through the overflow, the boundary recorder stayed FLAT: per-frame crossings ~1846 (normal) on the frozen frame, mixed_depth ≤ 7, the detail ring's last activity a tight d=1 overlay loop with flat guest sp. My trip never fired (the native-stack guard did). Yet the native_stack shows the §8 chain func_8001A954→AC00→B2B4→B5A8→func_80046264 ×23,073 plus the render cluster. Reconciliation:

The recursion is the §8 per-frame chain re-entering ITSELF via the recompiler's in-range jal→func_X(cpu) DIRECT C calls — exactly what the native-stack-guard comment describes ("an in-range guest jal emits a DIRECT func_X(cpu) call … grows the native stack WITHOUT going through psx_dispatch_impl"). The interp_enter_compiled / dirty_ram_dispatch frames in the native_stack are per-lap hops that RETURN (g_mixed_depth balanced ≤7) and/or stale stack values the walker collected — NOT the accumulating mechanism. §7's "boundary nesting" was the WRONG layer.

Proof it's direct compiled recursion: (1) mixed_depth (inc/dec around the only two interp→compiled nesting calls) never grows; (2) guest sp flat (0x801FE338) ⇒ the guest uses TAIL-transfers, not calls; (3) the native-stack guard (a compiled-entry chokepoint, debug_server_log_call_entry) is what trips; (4) recent_fn (compiled func-entry ring) shows the recursing cycle, the boundary recorder does not.

The recursing functions (recent_fn cycle, leaf→): 0x8004DEE0 → 0x8004DFA0 → 0x8005FA60 → 0x8005FF60 → 0x80060194 → 0x8005DFD8 → [0x8005E1A4 → 0x8005E08C] → … recursion_func 0x8004DEE0.

  • 0x8004DEE0 is a jump-table case inside FUN_8004dd14 (a per-frame RENDER LOOP: count at scratchpad 0x1F800240/0x1F80025A, object list at 0x1F800270/ 0x1F80022C, dispatch each object by type byte lbu 0xA(obj) through table 0x80013F00/0x80013F18; case → jal 0x8004DFA0 = the GPU/GTE render routine). func_8004DEE0 recompiled = { jal 0x8004DFA0; j 0x8004DF68 (loop tail) }.
  • FUN_8004dd14's own loop is a backward j (flat). So the recursion is NOT its iteration — it is func_80046264's subtree transferring back up to func_8001A954 unboundedly (the §8 chain re-entering), with the render cluster as the leaf where the guard happens to trip.

The 5 answers (capture frame 87176):

  1. first mixed-depth>0 = frame 565 (boot) — but mixed-depth is the wrong layer; it never grows. The recursion is compiled direct calls.
  2. accumulate vs explode → EXPLODE within one frame (per-frame crossings flat ~1846 every frame incl. the trip frame; 23k laps inside the single frozen frame).
  3. cycles/events during → cycles DO advance (psx_advance_cycles/lap → 139,703 in the frozen frame ≈ ¼ frame) but no VBlank (frame frozen) and psx_check_interrupts not per-lap → no scheduler progress. A control-flow cycle, NOT scheduler-wait.
  4. first anomalous transfer → boundary recorder can't see it (wrong layer); the compiled cycle is 0x8004DEE0/DFA0 ↔ 0x8005DFD8/E08C/E1A4/FA60/FF60/60194.
  5. Diagnosis: a real guest control-flow cycle in the per-frame render path, compiled into direct host recursion (guest tail-transfer emitted as a host call; guest sp flat, host stack grows). NOT a gradual boundary leak, NOT scheduler starvation, NOT interp fabrication at the boundary.

Next (no soak needed): Ghidra func_80046264 + func_8001A954 to find the edge that re-enters func_8001A954 from func_80046264's subtree, and decide: (a) the recompiler emits an in-range TAIL-transfer (j/jr) as a direct func_X(cpu) call (→ recompiler emission fix: tail-transfers must set cpu->pc + return to the trampoline, not nest), vs (b) a genuine guest non-termination (the loop's exit condition diverges from real HW → narrow oracle check). The right onset instrument is now at the COMPILED-entry chokepoint (debug_server_log_call_entry: per-frame compiled-entry count + func-entry sequence + early trip), NOT the interp boundary. The boundary recorder (§18) stays as a proven-negative (rules out the boundary).


20. FINAL DIAGNOSIS — gradual per-frame host-stack leak (Model A), CONFIRMED (session 3 end, 2026-06-17)

Built the §19 compiled-entry depth profile (debug_server.c ce_sample in psx_native_stack_guard — sampled at EVERY compiled function entry, the deepest hot path; per-frame MAX host-stack-used + entry count + deepest func; raw TEB at the trip; ce_profile TCP cmd + crash-report block). It samples the GUEST (main-thread) stack with NO per-frame reset. It settled everything:

Captured freeze (frame 44732), TEB SANE (not garbage): base=0x9528000000 dealloc=0x9524000000 sp=0x9524FFFFD0 → 64 MB stack reserve, 48 MB used at trip, 16 MB headroom, base>sp>dealloc. The guest runs on the 64 MB main-thread stack (NOT a 1 MB fiber); the guard correctly trips at ¼ headroom = 48 MB.

The trajectory is dead-linear: +1.17 KB/frame from gameplay start (frame 4453 ≈ 1948 KB → frame 44732 ≈ 49152 KB), with entries/frame CONSTANT (~1347) and the deepest frame always the render cluster (0x8004DEE0/DFA0/0x8005FA60/FF60). Constant entries + climbing depth ⇒ a fixed per-frame LEAK (~one ~13-frame call chain, ~1.17 KB), not a growing recursion.

ROOT: ~1.17 KB of host stack (≈ one §8 per-frame chain, entered through the interp→compiled boundary) LEAKS every frame and never unwinds. It accumulates on the 64 MB main-thread stack until 48 MB → native-stack guard → freeze (~40k frames ≈ 14 min). The native_stack's "§8 chain ×23,073" is ~41,000 LEAKED chains (one/frame; the walker capped at 300k slots), NOT a within-one-frame spiral.

This is ChatGPT's Model A, fully confirmed, and §7 was right about the leak site (the interp↔compiled boundary). The two mid-session reversals were INSTRUMENT ARTIFACTS, both now understood:

  • §15/§17 "flat → Model W" was wrong: stack_profile sampled sdl_vblank_present, which runs on the scheduler fiber (flat ~5 KB), not the guest stack.
  • §18 "boundary ruled out" was wrong: xprobe reset mixed_depth per frame, masking the cross-FRAME accumulation (the leak is 1 chain/frame — a per-frame reset zeroes exactly the accumulating quantity). The boundary IS the leak; it leaks 1 chain/frame, not within-one-frame.

Lesson (matches the global ring-buffer rule): sample the RIGHT context, at ABSOLUTE scale, with NO reset that can hide accumulation. §19 (guest stack, absolute, no reset) is the trustworthy instrument; §17 (wrong fiber) and §18 (per-frame reset) misled.

The 5 answers, corrected: (1) the leak starts at gameplay (~frame 4453), depth>0 immediately; (2) ACCUMULATE across frames (linear +1.17 KB/frame), NOT explode; (3) cycles/events/scheduler all advance normally every frame (frame counter advances — a slow resource leak, not a control-flow stall); (4) the leaked unit is the §8 chain func_8001A954→AC00→B2B4→B5A8→func_80046264 entered via interp_enter_compiled→psx_dispatch_game_compiled once/frame and left un-unwound; (5) diagnosis: gradual interp↔compiled boundary host-stack leak (~1 chain/frame).

FIX (the real one, not the watermark): the §11 single mixed-dispatch-owner / resumable-boundary design — when the per-frame compiled chain entered from the interpreter "returns" by tail-transfer (cpu->pc set / pc==0), the interp_enter_compiled/psx_dispatch_game_compiled/dirty_ram_dispatch frames MUST unwind back to the outer psx_dispatch_impl trampoline instead of being left on the stack. The §14 watermark fix was a band-aid (bounds the stack, doesn't stop the leak → game still wedges). The proper fix makes every interp→compiled crossing resumable so the host stack stays flat across frames. RUNTIME-only (dirty_ram_interp.c + cpu_state.h); rebuild, no regen. Validate by re-running the §19 ce_profile soak: the per-frame max_kb must stay FLAT instead of climbing 1.17 KB/frame.

NEXT: pin the exact non-unwinding crossing (one per frame, entering func_8001A954) across interp_enter_compiled / the block-loop tail-transfer (line ~1514) / dirty_ram_dispatch_inner, then implement the §11 resumable-owner fix and re-soak under §19 to confirm the climb is gone.


21. EXACT crossing pinned + fix attempts (session 3 end, 2026-06-18)

Added an env early-trip (PSX_STACK_GUARD_KB=N in psx_native_stack_guard) to capture the leak structure after a few thousand frames instead of the full 48 MB. Self-driven repro (inject START=0xFFF7/release=0xFFFF presses through FMV→New Game→idle; screenshot-validated — see self-drive-tomba-soak).

EXACT leaking crossing (early-trip capture, frame 14461, 6 MB): the per-frame re-entry chain is

func_80046264 → jal 0x80063864 (the dirty "hop", func_80046264's first call)
  → psx_dispatch_impl → dirty_ram_dispatch → exec_one (interp the 0x80063xxx overlay)
  → jalr func_8001A954 → interp_enter_compiled → psx_dispatch_game_compiled
  → func_8001A954 → AC00 → B2B4 → B5A8 → func_80046264 → jal 0x80063864 → …

psx_dispatch_game_compiled is a SINGLE-dispatch switch (func_X(cpu); return 1), not a trampoline. So the leak is: once per frame an interpreted overlay (reached via func_80046264's dirty hop jal 0x80063864) does jalr func_8001A954, nesting a host call that never unwinds (the per-frame loop never returns). ~1 nest/frame = the 1.17 KB/frame climb. Contributor: func_8001A954's page is CD-DMA-dirty (§12), so it is routed through dirty_ram_dispatch/the interp instead of the flat static table.

Fix attempt #1 — block-loop tail-transfer surface (line ~1514): RULED OUT. Changed the block-loop to SURFACE compiled tail-transfer targets to the trampoline instead of nesting. Correct for tail-transfers (no return obligation) and kept, but the §19 climb was UNCHANGED (still +1.17 KB/frame) — the leak is the call path (jalr/jal), not the tail-transfer path. (Empirically confirmed: post-fix early-trip leak unit still exec_one → interp_enter_compiled ×5238.)

The real fix is delicate (NOT a runtime 2-liner — §11 was optimistic):

  • (A) Surface compiled CALLS to the owner trampoline. The interp jalr/jal to a compiled-game target should set gpr[31]=return_pc, cpu->pc=target, and return (surface) instead of nesting psx_dispatch_game_compiled. The owner runs the callee flat and re-dispatches return_pc on its jr ra (the overlay resumes at return_pc — functionally identical, flat). RISK: doing this for overlay-native targets (precompiled DLLs that C-return with pc==0) reintroduces the dwarf→overworld lost-continuation bug (comment dirty_ram_interp.c ~903-907). So it must fire ONLY for compiled-game targets — which needs a non-nesting membership test (psx_dispatch_game_compiled is a switch; add a generated psx_dispatch_game_has() bsearch → REGEN, or a runtime-maintained set). Validate with §19 (flat) + a full playtest incl. dwarf→overworld (the regression this risks).
  • (B) §12 CD-DMA dirty-marking precision. If func_8001A954's page weren't spuriously CD-DMA-dirty, it would dispatch via the flat static table and never hit the interp boundary. Stop dma.c:695 marking non-code CD-DMA words executable/dirty (per-range invalidation + content hash). Bigger, but removes the boundary pressure generally.

Recommendation: implement (A) with the generated membership test, gated to compiled-game targets only, and validate with §19 + a dwarf→overworld playtest before trusting it. Do NOT ship a naive all-targets surface (regression). The §19 ce_profile (per-frame max_kb must stay FLAT) is the objective regression check; the self-driven repro + screenshot validation is the harness.


22. Fix (A) IMPLEMENTED + VALIDATED INSUFFICIENT — it's a guest CALL-CYCLE (session 3 end, 2026-06-18)

Pinned the exact crossing from the xprobe detail: the leaking entry to func_8001A954 is a single JAL, src 0x8001A630 (the main loop's call site — func_8001A954's only caller) — once per frame. Implemented (A): surface interp JAL/JALR to a game-text target (psx_game_address_in_text gate, so overlay-native keeps the nesting contract → no dwarf→overworld risk) to the owner trampoline instead of nesting psx_dispatch_game_compiled.

Result (§19, self-driven idle soak): STILL CLIMBS (~0.875 KB/frame, down from 1.17). So (A) reduced but did NOT eliminate the leak. REVERTED (uncommitted, partial, risky semantics change). The mechanism is a guest CALL-CYCLE: main_loop(0x8001A51C) → jal func_8001A954 → … → jal back to main_loop → …. Surfacing the interp side just relocates the nest to the compiled side (the chain's jal 0x8001A51C is emitted as a nesting psx_dispatch_call). You cannot fix a call-cycle by surfacing ONE crossing — the host stack mirrors the guest call graph and the cycle nests at whatever edge isn't surfaced. Whack-a-mole.

Crucial nuance: the leak is ~1 host frame PER FRAME (565k cycles between leaks), NOT per cycle-iteration — so the per-frame loop MOSTLY returns (flat); exactly ONE call per frame fails to unwind. Real HW idles forever (flat), so on real HW that loop is balanced (returns each frame). So either (C) our recomp mishandles ONE specific return per frame (fixable, contained), or it is the fundamental host-stack-mirrors-guest-calls limitation that only the §11 continuation-passing redesign (compiled funcs return to a single owner instead of calling each other) resolves.

DECISIVE NEXT STEP — the oracle (not more surfacing). Compare the per-frame return structure of main_loop/func_8001A954 against psxref @4380: does real HW RETURN from func_8001A954 to 0x8001A634 each frame (→ we mishandle a specific return = Hyp C, contained interp/dispatch fix), or does it genuinely never return (→ §11 redesign)? Both prior surface attempts (block-loop §20, JAL/JALR §22) are proven dead ends; stop surfacing single crossings. The §19 ce_profile flat-vs-climb remains the objective check; the self-driven screenshot-validated soak remains the harness.


23. ORACLE DISAMBIGUATION DONE — VERDICT: contained return-contract fix, NOT §11 (2026-06-18)

Ran the oracle disambiguation (only). Both sides traced in the identical idle state (Tomba New Game, starting village, "Hey, you with the pink hair!" dialogue; object *(0x1F8001D4)=0x801FD950, state/scene/jumptable all 0). Artifacts in _freeze_specimens/oracle/ (oracle_chain_filtered.json, oracle_state.txt, recomp_allfn_frame.json, recomp_ce_profile.json).

Tooling built (CLAUDE.md §15 — the oracle had a real gap). psxref has no instruction/PC trace (emu_step/emu_trace_addr never existed — the handoff/§10 assumed them). The right oracle is psx-beetle.exe (shares the wire protocol; Beetle PSX core INTERPRETS, so its always-on g_psxrecomp_fntrace_cb records every J/JAL/JR/JALR with caller/target/ra/a0/a1/kind). Loads Tomba via --disc tomba.cue <bios>. Added SP capture to the fntrace ring on BOTH backends (read GPR[29] live) — beetle_libretro.cpp

  • beetle_debug_server.c and recomp fntrace.c/fntrace.h/debug_server.c; runtime-only, no regen. Also moved psx-beetle's default debug port 4380 → 4382 (--port N override): 4380 collided with psxref AND cdirecomp/CdiRuntime.exe, and two LISTENers on one port silently route connections to the wrong process — that dual-listener was the whole "oracle keeps dying / unknown cmd" confusion early in the session, NOT a real crash.

ORACLE (real HW), one func_8001A954 invocation — perfectly balanced, once/frame:

JAL 0x8001A630 -> 0x8001A954  ra=0x8001A5DC  sp=0x801FE3E0   main_loop calls A954 (ret=0x8001A638)
JAL 0x8001A9B8 -> 0x8001AC00  ra=0x8001A638  sp=0x801FE3C8  ┐
JAL 0x8001AC4C -> 0x8001B2B4  ra=0x8001A9C0  sp=0x801FE3A8  │ nested chain,
JAL 0x8001B3E4 -> 0x8001B5A8  ra=0x8001AC54  sp=0x801FE390  │ SP descends
JAL 0x8001B750 -> 0x80046264  ra=0x8001B73C  sp=0x801FE378  │
JAL 0x80046274 -> 0x80063864  ra=0x8001B758  sp=0x801FE360  ┘ (the dirty hop)
JR  0x8001A9E8 -> 0x8001A638                 sp=0x801FE3E0   A954 RETURNS, SP fully restored
  • func_8001A954 is entered exactly once/frame (JAL from 0x8001A630) and returns exactly once/frame (jr ra at 0x8001A9E8 → 0x8001A638), with SP back to its entry value 0x801FE3E0 ⇒ the whole subtree unwinds every frame.
  • NO re-entry of func_8001A954 before its exit (armed on 0x8001A954; the only hit per frame is the 0x8001A630 JAL). The overlay 0x80063864 NEVER calls back into func_8001A954 — §21's "interp overlay does jalr func_8001A954" is REFUTED on real HW. There is no guest call-cycle: main_loop is a per-frame VSync callback that calls A954 and A954 returns; §22's "jal back to main_loop" was a misread of the recomp's forward-dispatched returns (below).

RECOMP (build-recursion, PSX_MIXED_OWNER=0), same idle — IDENTICAL guest flow, but every boundary-crossing RETURN is a forward psx_dispatch:

... render leaf 0x8004DEE0 (ra=0x8004DEE8) ×5 ...
psx_dispatch 0x800462C4  sp=0x801FE360   (80046264 subtree returns)
psx_dispatch 0x8001B758  sp=0x801FE378   (80046264 -> B5A8 return)
psx_dispatch 0x8001B3EC  sp=0x801FE390   (B5A8 -> B2B4 return)
psx_dispatch 0x8001AC54  sp=0x801FE3A8   (B2B4 -> AC00 return)
psx_dispatch 0x8001A9C0  sp=0x801FE3C8   (AC00 -> A954 return)
psx_dispatch 0x8001A638  sp=0x801FE3E0   (A954 -> main_loop return; SP restored)
  • The guest stack unwinds identically to the oracle (SP climbs back to 0x801FE3E0, same addresses, same RA). The down-chain calls (A954→AC00→…→80046264) are compiled DIRECT calls; the render leaves are interpreted overlays (0x8011xxxx/0x8012xxxx/0x8006Bxxx, ra 0x8001E0D8). When those interpreted/dirty frames return to a compiled main-EXE address, the dirty-RAM dispatch realizes the jr ra as a forward psx_dispatch(ret) that NESTS psx_dispatch_game_compiled instead of surfacing to the trampoline owner.
  • Live leak confirmed (ce_profile): max_kb 20099→22215 over 1807 frames = +1.17 KB/frame, entries/frame constant ~1300, deepest func the render cluster (0x8004DEE0/0x8005FF60/0x8005FA60). Identical to §20.

VERDICT (decisive):

  1. No guest control-flow divergence. Targets, RA, delay-slot results and SP are bit-identical on both sides. ⇒ Hypothesis C-as-interp-fabrication is RULED OUT (the interp does NOT mishandle a JAL/JALR/delay-slot/RA; it does not fabricate the re-entry).
  2. Beetle UNWINDS each invocation; the recomp leaves the chain SUSPENDED (ChatGPT determination #1), because the recomp maps the guest's nonlocal-but-balanced returns onto accumulating host calls (determination #2). The guest genuinely returns every frame (proven by SP on BOTH sides) → this is a CONTAINED return/unwind-contract bug, NOT the §11 continuation-passing redesign.
  3. The leaking edge is the RETURN path, not the call path. The boundary crossings that leak are the up-chain jr ra returns from interpreted/dirty code into compiled main-EXE functions: 0x800462C4 / 0x8001B758 / 0x8001B3EC / 0x8001AC54 / 0x8001A9C0 / 0x8001A638. This is why §20 (block-loop tail-transfer) and §22 (interp JAL/JALR down-calls) failed — they surfaced the CALL path; the leak is the dirty→compiled RETURN dispatch nesting.

Next (the fix, contained — runtime-only, no regen): at the dirty-RAM↔compiled boundary, a guest return (jr ra, and any dirty-dispatch whose target is a compiled-game address reached as a RETURN) must surface cpu->pc=ret to the psx_dispatch_impl trampoline owner and UNWIND, not nest psx_dispatch_game_compiled. Single-owner model is §11's boundary piece (NOT the full resumable-frame codegen). Regression-guard with: (a) §19 ce_profile per-frame max_kb must go FLAT (currently +1.17 KB/frame); (b) the oracle trace above is the gold reference — recomp psx_dispatch returns must vanish into host unwinds while the guest JAL/JR/SP sequence stays bit-identical; (c) full dwarf→overworld playtest (the overlay-native pc==0 return contract is the regression risk — the surface must fire for compiled-GAME return targets, not overlay-native DLL returns).


24. native_stack capture CORRECTS §23's fix scope — it is the §11 boundary work, NOT a one-liner (2026-06-18)

Before implementing the §23 fix I captured a clean early-trip native_stack (PSX_STACK_GUARD_KB=6144, frame 7592, 6 MB; report decoded with decode_report.py). This revised the fix scope — §23's oracle VERDICT stands, but its "quick contained runtime patch" framing was wrong. Recorded honestly:

The leaking host cycle (native_stack histogram, ~5239× each, ≈1 chain/frame):

func_8001A954 → func_8001AC00 → func_8001B2B4 → func_8001B5A8 → func_80046264
  → psx_dispatch_impl → dirty_ram_dispatch → exec_one
  → interp_enter_compiled → psx_dispatch_game_compiled → func_8001A954  (repeats)

So the per-frame chain (the §8 chain, emitted as DIRECT compiled func_X(cpu) calls) re-enters itself: func_80046264's dirty hop (psx_dispatch_impl → dirty_ram_dispatch → interp) loops back up to func_8001A954, and the direct-call chain never unwinds. This is exactly the native-stack-guard comment's "in-range guest transfer emitted as a DIRECT func_X(cpu) call grows the native stack WITHOUT going through psx_dispatch_impl."

Instrument cross-check (the instruments DISAGREE — do not over-trust native_stack):

  • ce_profile (the ONE trustworthy gauge, §20): +1.17 KB/frame, real, live-reconfirmed.
  • xprobe per-frame summary: a954≈1/frame, cr≈1844 crossings/frame, dmax (mixed_depth) BOUNDED at 6 (reset per frame); the xprobe detail ring shows the chain UNWINDING cleanly at frame end (mixed_depth d=1, surfaced returns to 0x8001A638/0x8001A9C0/0x8001AC54/…).
  • dirty_insn_log (65536 insns) and dirty_block_log: NO transfer/dispatch targets 0x8001A954. So the re-entry is NOT a dirty-interp instruction with a literal func_8001A954 target, and dirty_ram_dispatch(0x8001A954) is never called (matches §16).
  • ⇒ The interp_enter_compiled → psx_dispatch_game_compiled → func_8001A954 frames in the native_stack are partly the compiled chain's own direct calls (and possibly some stale return addresses the walker over-collects). The accumulating unit is the COMPILED per-frame chain not unwinding — the interp boundary is the enabler (CD-DMA dirtiness routes the chain through dirty_ram_dispatch, §12), not a simple nesting site.

Corrected fix scope. §23's oracle proof (real HW's func_8001A954 is balanced and returns once/frame, guest SP bit-identical) RULES OUT a simple one-instruction return-contract bug — but that means the fix is NOT the quick "surface the return" patch §23 sketched. The recomp's whole per-frame chain is emitted as direct nested C calls that re-enter via the CD-DMA-dirty boundary and never unwind; making them unwind is the §11 single-mixed-dispatch-owner / resumable-boundary work, which spans the interp boundary (dirty_ram_interp.c, runtime) AND the psx_dispatch_impl trampoline (full_function_emitter.cpp, codegen → REGEN). It is delicate (the Bug A/C/D contract area) and regression-prone — the §14 watermark band-aid of this already made the wedge "(Not Responding)". This is precisely the change ChatGPT's gate flagged to undertake only deliberately. Two real, substantial options (NOT one-liners):

  • (§11) Boundary resumability / single owner — the per-frame compiled chain entered from the interp must surface tail-transfers/returns to ONE owner trampoline and re-dispatch flat. Spans runtime + codegen (regen). Highest fidelity; highest risk.
  • (§12) CD-DMA dirty-marking precision — func_8001A954's page is only on the dirty/interp path because CD-DMA marks its whole page dirty (textures/audio sharing the page). Make dirtiness byte-range/content-precise so the compiled chain dispatches via the FLAT static table (normal C returns) and the boundary cycle never forms. Removes the enabler at the source; lower codegen risk but touches dma.c + dirty_ram granularity.

Validation gate for EITHER fix (unchanged): §19 ce_profile per-frame max_kb FLAT (not +1.17 KB/frame) + the §23 oracle JAL/JR/SP sequence stays identical + 15-min+ idle soak with no freeze + dwarf→overworld playtest (regression). Artifacts this session in _freeze_specimens/: oracle/oracle_chain_filtered.json, oracle/recomp_allfn_frame.json, the early-trip psx_last_run_report.json (native_stack), _xprobe.json, _dirty_insn.json.


25. UNIVERSAL FIX = continuation-passing call/return (user-chosen 2026-06-18; perf-loss OK)

User direction: build the universal fix (matures the ecosystem; nothing but Tomba is load-bearing; "right before fast"). That is §11 done fully = continuation-passing, not a §12 mitigation.

Current call contract (the host-mirroring root), full_function_emitter.cpp. A guest jal target is emitted as a nested dispatch:

{ uint32_t _csp = cpu->gpr[29];
  cpu->gpr[31] = return_addr;
  psx_dispatch(cpu, target);                       // NESTS a psx_dispatch_impl frame
  if (psx_call_contract(cpu, return_addr, _csp)) return; }
// continuation runs INLINE here

One nested psx_dispatch_impl per guest call frame ⇒ the guest call graph mirrors onto the host stack. Bounded guest depth → bounded host; unbounded guest re-entry (the §8 per-frame cycle) → unbounded host nesting → 48 MB → freeze. psx_call_contract/g_psx_call_bail (Bug A/C/D) exist only to paper over the mismatch between host C-nesting and guest ra-based returns — they are a symptom of the mirroring.

Continuation-passing emission (the fix). Emit every guest call as a TAIL-TRANSFER, with the return address registered as a dispatchable continuation:

// jal target:
cpu->gpr[31] = return_addr;     cpu->pc = target; return;
// jalr rd, rs:
{ uint32_t _t = cpu->gpr[rs];   /* capture BEFORE link → rd==rs alias-safe */
  cpu->gpr[rd] = return_addr;   /* if rd != 0 */
  cpu->pc = _t; return; }

The trampoline (psx_dispatch_impl, already a for(;;) tail-dispatch loop) dispatches the callee; the callee's jr ra sets cpu->pc = return_addr; the loop dispatches the registered continuation block. The whole call/return runs FLAT in one loop, driven by the guest's own ra/sp — exactly like hardware. Each basic block becomes a separate dispatch (the perf cost the user accepted). psx_call_contract/the bail machinery become unnecessary (no host nesting to mismatch; a wild jr just dispatches the wild PC, as HW would jump there).

Why this is universal. It makes the recompiler architecturally incapable of host-stack overflow from guest control flow — any game, any re-entrancy depth, real or divergent. §12 (CD-DMA precision) and fixing the specific func_80046264→func_8001A954 divergence are per-symptom shortcuts; this is the complete fix.

Scope (multi-component — staged, validated rollout, NOT a big-bang):

  1. C emitter (full_function_emitter.cpp) — the 4 call sites (jal/jalr × orphaned-delay + pending-delay paths). Tail-transfer + ensure EVERY return_addr (addr+8) is a block leader AND a local_continuation (so interior dispatch routes back in). This is where the leaking §8 chain lives → fixes the main-EXE mirroring. Needs REGEN.
  2. Dirty interp (dirty_ram_interp.c) — interp_enter_compiled must SURFACE a compiled-GAME call target (set gpr[31]=return_pc, cpu->pc=target, return) instead of nesting psx_dispatch_game_compiled (this is §22's change — which was only PARTIAL because the compiled side still nested; with #1 it completes). Gate to game-text targets so overlay-native DLLs (which C-return pc==0) keep their contract → no dwarf→overworld regression. Runtime-only.
  3. sljit backend + overlay compiler (compile_overlays.py) — mirror the contract so sljit/overlay-compiled code tail-transfers too (else mixed contracts). Follow-on stage.
  4. Regen BIOS + game(s); the psx_call_contract / bail code can be left inert first, then removed once #1–#3 are proven.

Regression-prone cases (test): JALR rd==rs alias (handled by capture-before-link); delay-slot side effects committed before the transfer (already emitted inline before the terminator); jr $ra longjmp-returns for exception handlers (psx_restore_state_escape) — keep as-is; install-slot hooks (Rule 18); jump tables; continuation that lands outside the caller's function (dispatch routes by table — must be a registered entry/continuation somewhere); guest regs cached in C locals across a call (must be flushed at block boundary — calls are block terminators, so block-local temps don't span; verify the emitter never caches across a terminator).

Validation (the gate): §19 ce_profile per-frame max_kb FLAT + BIOS boots to shell + Tomba boots to gameplay + the §23 oracle JAL/JR/SP sequence stays bit-identical + 15-min idle soak no-freeze + dwarf→overworld playtest. Safe point to revert to: commit ae47e69.

25.1 Implementation status (2026-06-18) — Stage 1 emitter done + GATED; boot regression open

DONE (full_function_emitter.cpp, gated behind gen-time env PSX_CPS):

  • All 4 call sites (jal/jalr × orphaned-delay + pending-delay) emit cpu->pc=target; return; (tail-transfer) under CPS instead of nested psx_dispatch + psx_call_contract. JALR captures the target reg BEFORE the link (rd==rs alias-safe).
  • All 3 jr $ra return sites publish cpu->pc = cpu->gpr[31]; return; under CPS (legacy just return; and relied on the nested-dispatch C-return). This was required — without it the BIOS "completed" instantly (returns left cpu->pc=0 → outermost trampoline exit).
  • Every call's return address registered as a dispatchable continuation (local_continuation in the caller's function, else register_cross_function_target).

GATING PROVEN SAFE: regen+build WITHOUT PSX_CPS (legacy) boots Tomba normally — the default codegen is byte-unaffected; CPS is fully opt-in. So this WIP is safe to land.

OPEN (the boot regression, CPS path only): with PSX_CPS=1, BIOS regen+build boots main() then exits at frame 0 (execution completed, PC=0x00000000). Root cause (high confidence): in CPS the trampoline must dispatch mid-function continuation addresses (a jr $ra returns to a caller's return_addr, which the trampoline then dispatches), but the dispatch table contains only function ENTRIES — a return to a mid-function continuation misses the table → psx_unknown_dispatch → pc=0 → outermost exit. Legacy never needed continuations in the table (they were reached by C-return from the nested psx_dispatch).

NEXT (the fix to make CPS boot): add every registered continuation (local_continuations + out_cross_targets) to the dispatch table (mapping continuation_addr → containing func), so the trampoline routes a returned-to continuation into its function's entry-switch. Find the dispatch-table emitter (writes *_dispatch.c), and include continuations alongside function entries. Then re-regen PSX_CPS=1, boot-test, and run the §25 validation gate (ce_profile flat

  • oracle-identical + 15-min soak + dwarf→overworld). Stages 2 (interp interp_enter_compiled surface) and 3 (sljit/overlay parity) follow once the C-emitter path boots+flattens.

25.2 BIOS-only CPS BOOTS — root cause was NOT the dispatch table (2026-06-18)

The §25.1 "continuations not in the dispatch table" hypothesis was WRONG — the continuation-wrapper machinery already adds every continuation to the BIOS dispatch table (func_X_cont_Y entries; 6256 in the legacy build). Verified by inspecting generated SCPH1001_dispatch.c. The real boot regression (frame-0 exit) had two distinct causes, found by dumping the always-on fntrace ring on the abnormal psx_dispatch return (added a tail dump to main.cpp; psx_cps_exit_trace.json). Tooling fix on the way: fntrace defaults to record-NOTHING now (armed_match returns 0 when nothing armed; the header comment is stale) — set PSX_FNTRACE_ARM=all.

  1. BIOS A0/B0/C0 vector runtime-fallback (emit_dispatch): used nested psx_dispatch_call(target, stop=ra). A nested dispatch runs its own trampoline with stop_addr = the caller's return; when the handler returns there it zeroes cpu->pc and C-returns to the flat main trampoline → the outermost loop sees pc==0 and exits. In legacy this was safe because the caller's jal gate was itself a nested dispatch whose psx_call_contract resumed the continuation; under CPS the caller tail-transferred, so the B-call return must flow back to the SAME flat trampoline. Fix: under CPS the vector fallback tail-transfers (cpu->pc = target; return; — $ra already correct).
  2. psx_syscall Enter/ExitCriticalSection (traps.c): set cpu->pc = 0 (the legacy "return via nested dispatch" idiom) → outermost flat trampoline exits. Fix: psx_syscall now returns int (0 = void syscall, 1 = transfer); under CPS the emitter (strict_translator) emits if (psx_syscall(...)) return; so a void syscall falls through to the inline post-syscall code (the guest's own jr $ra), matching real-HW resume at EPC+4. Keeps cpu->pc==0 so legacy + the dirty interp (return cpu->pc != 0) are unchanged. Overlay ABI bumped 4→5 (callback signature change).

Result: BIOS reaches the shell under CPS and runs (user-confirmed). Also fixed: BIOS-only build was broken (interp_enter_compiled called psx_dispatch_game_compiled outside the PSX_HAS_GAME_DISPATCH guard) — gated the whole function.

25.3 GAME emitter is a SEPARATE codebase — CPS ported there too (2026-06-18, in progress)

KEY CORRECTION to §25.1's staging: the game (SCUS_*) is generated by code_generator.cpp (via main_psx.cpp), NOT full_function_emitter.cpp. The previous session's PSX_CPS=1 regen left the game fully legacy (0 CPS markers, 3406 psx_call_contract) — and the §8 leaking chain (func_8001A954 …) lives in GAME code, so the BIOS CPS work alone could not fix the leak. The game emitter uses a different model (one C function per guest fn, block_X: labels + goto, calls as DIRECT nested func_target(cpu) calls with inline goto continuations — that nesting IS the leak).

Ported CPS to code_generator.cpp (gated PSX_CPS):

  • jal/jalr/jr-$ra/jr-table/branch-split/j-split → tail-transfer (cpu->pc = target; return; with $ra/$rd set; jalr captures rs before the link, alias-safe). psx_call_contract / call_by_address no longer emitted (0 of each in CPS game).
  • Each call's return point registered as a dispatchable continuation; an entry-switch (keyed on cpu->pc) is emitted at the top of every function AND every overlapping-alias body (psx_alias_body, 1549 in Tomba — alias groups route a continuation by calling the first alias wrapper with cpu->pc set, which overrides the entry arg).
  • psx_dispatch_game_compiled (in main_psx.cpp) gains continuation cases (case cont: cpu->pc = cont; func_owner(cpu);) + entries clear cpu->pc. 3340 continuations.

Unified flat trampoline: the BIOS psx_dispatch_impl already routes ALL game text through dirty_ram_dispatch → psx_dispatch_game_compiled, so one trampoline drives BIOS + game flat. Under CPS the game funcs tail-transfer after one block, so the interp→psx_dispatch_game_compiled boundary is depth-1 (no accumulation) → Stage-1b alone should bound the host stack. Mixed CPS-BIOS + legacy-game gave "black screen after PS logo" (boundary contract mismatch) — expected; full CPS is the destination.

25.4 LEAK FIXED — full-CPS Tomba boots, plays, soak FLAT (2026-06-18) ✅

One more runtime fix was needed past first-boot: psx_syscall case 3 (ChangeThread). The fiber thread-scheduler (psx_change_thread_fiber) leaves cpu->pc == 0 on thread resume (lines 289/343); legacy resumed via cpu->pc==0 + the nested-dispatch C-return, but under the flat CPS trampoline pc==0 at the outermost level = exit. Tomba exited at frame 712 on a ChangeThread for exactly this. Fix: case-3 ChangeThread-success returns 0 (CPS falls through to the wrapper's own jr $ra so the thread resumes at its caller via the flat trampoline); legacy ignores the return value.

Result (user-validated): full-CPS Tomba boots BIOS → FMV → New Game → village idle (the soak repro spot) and is playable. The host-stack leak is ELIMINATED: ce_profile cur_max_kb held FLAT at 5 KB from frame ~9.9k through frame 56,890 (turbo soak, ~zero climb) — legacy climbed +1.17 KB/frame to 20,000+ KB → 48 MB → the ~14-min/~50k-frame freeze. We blew straight past the freeze threshold with the stack bounded. Branch bug/recursion, UNCOMMITTED.

NOTE: process RSS grows under PSX_FNTRACE_ARM=all (the 144 MB fntrace ring + turbo buffering); it decelerates and is unrelated to the (now-fixed) host-stack leak. Run gameplay WITHOUT PSX_FNTRACE_ARM for a normal footprint.

Validation gate status: ce_profile FLAT ✅ · 56.9k-frame soak no-freeze ✅ · boots+plays ✅.

25.5 Stage 3 — overlay (sljit + gcc-DLL) CPS parity (2026-06-18)

Historical session log. The sljit tier was removed in 5b7e69b4 (2026-07-15); the gcc-DLL findings below remain current, and the toolchain-free role sljit filled is now the bundled tcc tier. A gcc miss falls to tcc, then interp.

sljit JIT (overlay_sljit.c), scoped: jr $ra publishes cpu->pc; call-containing fragments decline to the interp (CPS-safe, depth-1); jr rX already tail-transferred. So only leaf shards JIT — jr covered, calls interp. Validated: boots, leak flat, shards persist. A runtime CPS flag g_psx_cps_mode (defined in overlay_loader.c, set by a __attribute__((constructor)) emitted in the BIOS + game CPS dispatch) gates the sljit/overlay CPS behavior.

gcc-DLL path — FULL native overlay coverage under CPS (the production path), DONE + proven:

  • compile_overlays.py --cps sets PSX_CPS for the recompiler → CPS overlay C (tail-transfer
    • per-function entry-switch; code_generator --overlay mode only affects widescreen, not CPS). The DISPATCH_PREAMBLE's psx_syscall decl was fixed void→int. PSX_OVERLAY_CODEGEN_VER bumped 3→4 (fresh cg4/ cache namespace; legacy unit-model DLLs in cg3 ignored).
  • Loader continuation routing (overlay_loader_dispatch, gated g_psx_cps_mode): under CPS an overlay function is flat-dispatched (one block then tail-transfer), so a callee returns to a mid-function continuation that idx_head() (entry-keyed) misses. New overlay_find_by_range() finds the owning candidate by code range and re-enters it with cpu->pc = addr → its entry-switch routes to the block. gcc DLLs are the trusted tier (diff-validated at entry), run native directly. This is the CPS equivalent of the old unit-model that fixed the dwarf→overworld blue screen — overlay funcs no longer run as units; continuations re-dispatch flat.
  • Result: CPS overlay DLLs compile + load + dispatch_native > 0; with continuation routing dispatch_interp_fallback → 0 (full native overlay coverage), ce_profile FLAT (6 KB), boots clean. Addresses are KSEG0-consistent (overlay C sets gpr[31]=0x800E… continuations = the entry-switch cases).

REMAINING: capture breadth — only the 2 overlays my short run captured are gcc-compiled; other scenes' overlays gcc-miss → sljit-leaf/interp (still CPS-safe) until captured+compiled (play through → overlay_captures.json grows → re-run compile_overlays.py --cps). dwarf→overworld playtest (the regression check). Then consider CPS default + removing inert psx_call_contract/g_psx_call_bail. Commit Stage 3 when the user is satisfied.

25.6 CPS IS NOW THE DEFAULT (2026-06-18) — landing on master

The PSX_CPS gate is flipped to DEFAULT-ON in all three emitter sites (full_function_emitter.cpp ×2, code_generator.cpp, strict_translator.cpp): cps = (getenv("PSX_CPS") == nullptr) || (PSX_CPS[0] != '0'). So a normal regen emits continuation-passing; PSX_CPS=0 is the legacy opt-out (escape hatch, no recompiler rebuild needed). Verified: default regen → CPS markers present; PSX_CPS=0 → 0 markers; default-CPS Tomba boots, no frame-0 exit, ce_profile flat (4 KB).

Merged to master + pushed (both repos). The shared-framework caveat: ApeEscape/MMX6 pin old psxrecomp commits, so they're unaffected until they bump their pin + regen — at which point they build CPS (validate then, or PSX_CPS=0 to stay legacy). Overlay cache codegen_ver=4 (CPS) means production overlay caches rebuild CPS on next compile; the production game.toml overlay_autocompile_cmd no longer needs --cps (default-on makes the recompiler emit CPS regardless). The inert psx_call_contract / g_psx_call_bail machinery is left in place (a future cleanup once CPS has soaked).