This reverts commit b836f2198c.
83 KiB
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 —
0x8001a954is 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_8007c640intact.
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 ajalr v0whose target slot points back into the recursing chain. Indirect call through a jump-table / function-pointer slot — the wild-call family (cf. seesawfunc_8001DFD4, bogusv0=0x8001DFF8), NOT a benign loop.pc = 0x00000000= the dispatchpc==0unwind 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:
0x80063xxxand0x8011xxxx(overlay region);last_store_pc = 0x80116A24;dirty_block_tailtail 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/recursioncreated; 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-recursionis 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 identicalv0=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):
- A-vs-C static check: disassemble the dynamic routine around
0x80063864(the dirty pagefunc_80046264first calls into), specifically the instructions before/after its transfer to0x8001A954and its stack-restoration / intended return path (§13). Is the re-entry a legit guest tail-transfer (A) or an interp artifact (C)?0x80063xxxis runtime-loaded — read from a PARKED instance via the debug serverread, not the static EXE. - Oracle disambiguation (§10): trace control flow at
0x8001A954/0x80046264/0x80063864/ the interpretedjal 0x8001A954vs psxref @ 4380. Strongest metric:main_loop_entries_per_vblank. Cheap pre-check: on the oracle, samplestate[0x4a]+0x8009BCDDover minutes of play — if they sit unchanged for thousands of frames, Hypothesis B is falsified outright. - 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. - 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 inrecompiler/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->pcand 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/$radon't match the call site, it raisesg_psx_call_bail = 1, setscpu->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 — callpsx_dispatch_game_compiled(cpu, target)directly. The post-callpsx_call_contract(...)is only reached if that nested call RETURNS. dirty_ram_dispatch_inner(~L1170-1186) does the same for a surfaced compiled target: nestspsx_dispatch_game_compiledinstead of returningcpu->pc=targetto 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]==1plausibly just means "active gameplay" and can stay 1 the entire session.0x8009BCDDindexes 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. Adjacent1/1/1may 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==0sentinel with an explicitDispatchResult{kind,target}where practical — far easier to reason about thanpc+ 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 agpu_vblank_tick→sdl_vblank_presentcallback 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_interruptsper 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):
- Per-frame frequency + first-occurrence of the re-entry edge (interp →
0x8001A954) and the jumptable edge (0x8004630C→ itsjrtarget). 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.) - 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. - Indirect-site state per branch:
{last_target, first_seen_frame, last_change_frame, counts_by_target}— makes "didfunc_80046264start taking a new target at ~50k?" instantly answerable. - 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_dispatchwas NEVER called with0x8001A954. Sofunc_8001A954isn't re-dispatched viadirty_ram_dispatcheither. Andrecursion_funcVARIES (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:
- 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(thedirty_ram_dispatchtarget+racycle — theranames the re-dispatch caller),dispatch_tail. These show the ACTUAL cycle without guessing. - From the
racallers (main-EXE0x8002–0x8005), Ghidra those functions to find the per-frame loop and its termination condition. - Add the last-writer map (hook
psx_write_word/byte/halfin memory.c — generated code writes viacpu->write_word→these; writer-PC via... NOTEg_debug_last_store_pcis NOT set per-store in generated code, count was 0 — need another writer-PC source, e.g. a global cpu viadebug_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. - 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 insidesdl_vblank_present(), which is a callback fired bygpu_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_interruptsappears ×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:
usedclimbs ~linearly with frame from ~gameplay-start → Model A; the slope = leak rate, the x-intercept = leak-start frame.usedstays 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=4093arithmetic 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_stackwalker 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 cyclefunc_8001A954→AC00→B2B4→B5A8→func_80046264→(interp overlay crossing)→…→func_8001A954failing 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"). Theinterp_enter_compiled/dirty_ram_dispatchframes 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.
0x8004DEE0is a jump-table case insideFUN_8004dd14(a per-frame RENDER LOOP: count at scratchpad0x1F800240/0x1F80025A, object list at0x1F800270/0x1F80022C, dispatch each object by type bytelbu 0xA(obj)through table0x80013F00/0x80013F18; case →jal 0x8004DFA0= the GPU/GTE render routine).func_8004DEE0recompiled ={ jal 0x8004DFA0; j 0x8004DF68 (loop tail) }.FUN_8004dd14's own loop is a backwardj(flat). So the recursion is NOT its iteration — it isfunc_80046264's subtree transferring back up tofunc_8001A954unboundedly (the §8 chain re-entering), with the render cluster as the leaf where the guard happens to trip.
The 5 answers (capture frame 87176):
- first mixed-depth>0 = frame 565 (boot) — but mixed-depth is the wrong layer; it never grows. The recursion is compiled direct calls.
- 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).
- cycles/events during → cycles DO advance (
psx_advance_cycles/lap → 139,703 in the frozen frame ≈ ¼ frame) but no VBlank (frame frozen) andpsx_check_interruptsnot per-lap → no scheduler progress. A control-flow cycle, NOT scheduler-wait. - first anomalous transfer → boundary recorder can't see it (wrong layer); the
compiled cycle is
0x8004DEE0/DFA0 ↔ 0x8005DFD8/E08C/E1A4/FA60/FF60/60194. - 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_profilesampledsdl_vblank_present, which runs on the scheduler fiber (flat ~5 KB), not the guest stack. - §18 "boundary ruled out" was wrong:
xproberesetmixed_depthper 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/jalto a compiled-game target should setgpr[31]=return_pc,cpu->pc=target, and return (surface) instead of nestingpsx_dispatch_game_compiled. The owner runs the callee flat and re-dispatchesreturn_pcon itsjr ra(the overlay resumes at return_pc — functionally identical, flat). RISK: doing this for overlay-native targets (precompiled DLLs that C-return withpc==0) reintroduces the dwarf→overworld lost-continuation bug (commentdirty_ram_interp.c~903-907). So it must fire ONLY for compiled-game targets — which needs a non-nesting membership test (psx_dispatch_game_compiledis aswitch; add a generatedpsx_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. Stopdma.c:695marking 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.cand recompfntrace.c/fntrace.h/debug_server.c; runtime-only, no regen. Also moved psx-beetle's default debug port 4380 → 4382 (--port Noverride): 4380 collided with psxref ANDcdirecomp/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_8001A954is entered exactly once/frame (JALfrom0x8001A630) and returns exactly once/frame (jr raat0x8001A9E8→0x8001A638), with SP back to its entry value0x801FE3E0⇒ the whole subtree unwinds every frame.- NO re-entry of
func_8001A954before its exit (armed on0x8001A954; the only hit per frame is the0x8001A630JAL). The overlay0x80063864NEVER calls back intofunc_8001A954— §21's "interp overlay doesjalr 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, ra0x8001E0D8). When those interpreted/dirty frames return to a compiled main-EXE address, the dirty-RAM dispatch realizes thejr raas a forwardpsx_dispatch(ret)that NESTSpsx_dispatch_game_compiledinstead of surfacing to the trampoline owner. - Live leak confirmed (
ce_profile):max_kb20099→22215 over 1807 frames = +1.17 KB/frame,entries/frameconstant ~1300, deepest func the render cluster (0x8004DEE0/0x8005FF60/0x8005FA60). Identical to §20.
VERDICT (decisive):
- 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).
- 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.
- The leaking edge is the RETURN path, not the call path. The boundary crossings that
leak are the up-chain
jr rareturns 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.xprobeper-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) anddirty_block_log: NO transfer/dispatch targets0x8001A954. So the re-entry is NOT a dirty-interp instruction with a literalfunc_8001A954target, anddirty_ram_dispatch(0x8001A954)is never called (matches §16).- ⇒ The
interp_enter_compiled → psx_dispatch_game_compiled → func_8001A954frames 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 throughdirty_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 touchesdma.c+dirty_ramgranularity.
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):
- C emitter (
full_function_emitter.cpp) — the 4 call sites (jal/jalr × orphaned-delay + pending-delay paths). Tail-transfer + ensure EVERYreturn_addr(addr+8) is a block leader AND alocal_continuation(so interior dispatch routes back in). This is where the leaking §8 chain lives → fixes the main-EXE mirroring. Needs REGEN. - Dirty interp (
dirty_ram_interp.c) —interp_enter_compiledmust SURFACE a compiled-GAME call target (setgpr[31]=return_pc,cpu->pc=target, return) instead of nestingpsx_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-returnpc==0) keep their contract → no dwarf→overworld regression. Runtime-only. - sljit backend + overlay compiler (
compile_overlays.py) — mirror the contract so sljit/overlay-compiled code tail-transfers too (else mixed contracts). Follow-on stage. - 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 nestedpsx_dispatch+psx_call_contract. JALR captures the target reg BEFORE the link (rd==rs alias-safe). - All 3
jr $rareturn sites publishcpu->pc = cpu->gpr[31]; return;under CPS (legacy justreturn;and relied on the nested-dispatch C-return). This was required — without it the BIOS "completed" instantly (returns leftcpu->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_compiledsurface) 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.
- BIOS A0/B0/C0 vector runtime-fallback (
emit_dispatch): used nestedpsx_dispatch_call(target, stop=ra). A nested dispatch runs its own trampoline withstop_addr = the caller's return; when the handler returns there it zeroescpu->pcand C-returns to the flat main trampoline → the outermost loop seespc==0and exits. In legacy this was safe because the caller'sjal gatewas itself a nested dispatch whosepsx_call_contractresumed 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;—$raalready correct). psx_syscallEnter/ExitCriticalSection (traps.c): setcpu->pc = 0(the legacy "return via nested dispatch" idiom) → outermost flat trampoline exits. Fix:psx_syscallnow returnsint(0 = void syscall, 1 = transfer); under CPS the emitter (strict_translator) emitsif (psx_syscall(...)) return;so a void syscall falls through to the inline post-syscall code (the guest's ownjr $ra), matching real-HW resume at EPC+4. Keepscpu->pc==0so 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/$rdset; jalr captures rs before the link, alias-safe).psx_call_contract/call_by_addressno 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 withcpu->pcset, which overrides theentryarg). psx_dispatch_game_compiled(inmain_psx.cpp) gains continuation cases (case cont: cpu->pc = cont; func_owner(cpu);) + entries clearcpu->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)
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 --cpssetsPSX_CPSfor the recompiler → CPS overlay C (tail-transfer- per-function entry-switch;
code_generator--overlaymode only affects widescreen, not CPS). The DISPATCH_PREAMBLE'spsx_syscalldecl was fixed void→int.PSX_OVERLAY_CODEGEN_VERbumped 3→4 (freshcg4/cache namespace; legacy unit-model DLLs in cg3 ignored).
- per-function entry-switch;
- Loader continuation routing (
overlay_loader_dispatch, gatedg_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. Newoverlay_find_by_range()finds the owning candidate by code range and re-enters it withcpu->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 routingdispatch_interp_fallback→ 0 (full native overlay coverage), ce_profile FLAT (6 KB), boots clean. Addresses are KSEG0-consistent (overlay C setsgpr[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).