From 94a1966444668d6d727cb3a7f10abc7319bf09fb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 18 Jun 2026 12:45:02 -0700 Subject: [PATCH] =?UTF-8?q?recursion(fix):=20universal=20continuation-pass?= =?UTF-8?q?ing=20=E2=80=94=20FIXES=20the=20idle-freeze=20host-stack=20leak?= =?UTF-8?q?=20(BIOS+game)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The complete §25 fix: make BOTH recompiler emitters emit guest calls as TAIL-TRANSFERS driven by the unified flat trampoline + the guest's own $ra/$sp, instead of nesting one host C frame per guest call. The host stack can no longer mirror unbounded guest re-entrancy — the architectural root of the ~14-min idle freeze. Gated gen-time by PSX_CPS; legacy codegen byte-identical when unset. VALIDATED (user): full-CPS Tomba boots BIOS -> FMV -> New Game -> village idle and plays; ce_profile cur_max_kb FLAT at 5 KB from frame ~9.9k through 56,890 (turbo soak) — blew past the ~50k-frame freeze threshold with the host stack bounded (legacy climbed +1.17 KB/frame -> 48 MB -> freeze). KEY CORRECTION to the prior session's §25.1: there are TWO emitters. The BIOS (full_function_emitter.cpp) was done last session but the §8 leaking chain (func_8001A954 ...) is GAME code, emitted by code_generator.cpp (via main_psx.cpp) — never converted. Also, §25.1's "continuations not in the dispatch table" hypothesis was wrong (they were always added); the real boot regressions were found by dumping the always-on fntrace ring on the abnormal psx_dispatch return. Emitter changes (gated PSX_CPS): - full_function_emitter.cpp (BIOS): BIOS A0/B0/C0 vector runtime-fallback tail-transfers instead of nested psx_dispatch_call (was the frame-0 exit). - code_generator.cpp/.h (game): jal/jalr/jr-$ra/jr-table/branch-split/j-split -> tail-transfer (jalr captures rs before the link, alias-safe); each call's return point registered as a dispatchable continuation; entry-switch (keyed on cpu->pc) at the top of every function AND every overlapping-alias body. 0 psx_call_contract / call_by_address emitted under CPS. - main_psx.cpp: psx_dispatch_game_compiled gains continuation cases (case cont: cpu->pc=cont; func_owner(cpu)); entries clear cpu->pc. - strict_translator.cpp: syscall emits `if (psx_syscall(...)) return;` so a void syscall falls through to the inline post-syscall jr $ra (real-HW EPC+4). Runtime changes (the legacy "return via nested dispatch / cpu->pc=0" idioms that exit the flat CPS trampoline): - traps.c: psx_syscall returns int (0=void/fall-through, 1=transfer); Enter/ExitCriticalSection return 0; case-3 ChangeThread-success returns 0 — the fiber scheduler leaves cpu->pc=0 on resume, which was the frame-712 exit. - cpu_state.h / overlay_api.h: psx_syscall signature void->int; overlay callback ABI bumped 4->5 (stale overlay caches rejected). - dirty_ram_interp.c: gate interp_enter_compiled behind PSX_HAS_GAME_DISPATCH (un-breaks the BIOS-only build). - main.cpp: dump the always-on fntrace ring tail + stack to psx_cps_exit_trace.json on an abnormal psx_dispatch return (diagnostic; CLAUDE.md ring-buffer model). Docs: RECURSION_BUG.md §25.2-§25.4. Remaining: dwarf->overworld playtest; Stage 3 (sljit/overlay CPS parity); then consider making CPS default. Co-Authored-By: Claude Opus 4.8 --- docs/RECURSION_BUG.md | 85 +++++++++++ recompiler/include/code_generator.h | 26 ++++ recompiler/src/code_generator.cpp | 183 +++++++++++++++++++---- recompiler/src/full_function_emitter.cpp | 41 ++++- recompiler/src/main_psx.cpp | 24 ++- recompiler/src/strict_translator.cpp | 26 +++- runtime/include/cpu_state.h | 8 +- runtime/include/overlay_api.h | 11 +- runtime/src/dirty_ram_interp.c | 8 +- runtime/src/main.cpp | 40 ++++- runtime/src/overlay_loader.c | 2 +- runtime/src/traps.c | 28 +++- 12 files changed, 429 insertions(+), 53 deletions(-) diff --git a/docs/RECURSION_BUG.md b/docs/RECURSION_BUG.md index 4aeee925..80d3ade7 100644 --- a/docs/RECURSION_BUG.md +++ b/docs/RECURSION_BUG.md @@ -1186,3 +1186,88 @@ entries. Then re-regen `PSX_CPS=1`, boot-test, and run the §25 validation gate + 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 ✅. +REMAINING: dwarf→overworld playtest (overlay-native `pc==0` return contract = the regression +risk) · Stage 3 = sljit + overlay CPS parity (overlays must be recompiled under CPS; overlay ABI +already bumped 4→5) · then consider making CPS the default and removing the now-inert +`psx_call_contract`/`g_psx_call_bail` machinery. Commit the branch when the user is satisfied. + diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 2766529f..c8a18682 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -176,6 +176,32 @@ private: std::set extra_labels_; // Mid-block addresses that need inline labels (jump table targets) const AnnotationTable* annotations_ = nullptr; + // RECURSION_BUG.md §25 — continuation-passing call/return (the universal fix + // for the idle-freeze host-stack leak). When set (gen-time env PSX_CPS), + // guest calls (jal/jalr/jr-table/external) are emitted as TAIL-TRANSFERS + // (set $ra, cpu->pc = target, return) and each call's return point is + // registered as a dispatchable continuation; jr $ra publishes cpu->pc. + // The unified flat trampoline (psx_dispatch_impl) drives the whole + // call/return via the guest's own $ra/$sp — so the host stack can never + // mirror unbounded guest re-entrancy. The §8 leaking chain (func_8001A954 + // ...) lives in GAME code, so the game emitter MUST honor CPS too. + bool cps_enabled_ = false; + // Continuations (return points) collected during the CURRENT function's + // block translation; consumed by generate_function to emit the entry-switch. + std::vector cps_cur_continuations_; + // Global map: continuation address -> owning function entry, for the game + // dispatch table (psx_dispatch_game_compiled) to route a returned-to + // continuation into its function's entry-switch. + std::map cps_continuation_owner_; + +public: + bool cps_enabled() const { return cps_enabled_; } + // continuation_addr -> owning function entry (CPS dispatch routing). + const std::map& cps_continuations() const { + return cps_continuation_owner_; + } +private: + // Set per-function by generate_function when config_.ws_auto_screen_x_cull is // on AND the function carries the GTE screen-extent reject signature; read by // translate_instruction to widen that function's width compares. See diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index dc28d606..a30b30e7 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -7,6 +7,7 @@ #include "../../runtime/include/ws_backdrop_detect.h" #include #include +#include #include #include @@ -48,7 +49,11 @@ static uint32_t ram_to_rom(uint32_t addr, const PS1Executable& exe) { } CodeGenerator::CodeGenerator(const PS1Executable& exe, const CodeGenConfig& config) - : exe_(exe), config_(config) {} + : exe_(exe), config_(config) { + // RECURSION_BUG.md §25 — continuation-passing call/return. Gen-time opt-in + // via PSX_CPS so legacy codegen stays byte-identical when unset. + cps_enabled_ = (std::getenv("PSX_CPS") != nullptr); +} std::string CodeGenerator::reg_name(int reg_num) { if (reg_num >= 0 && reg_num < 32) { @@ -1159,6 +1164,9 @@ std::string CodeGenerator::translate_basic_block( if (target_in) { ss << config_.indent << config_.indent << fmt::format("goto block_{:08X}; /* taken */\n", branch_target); + } else if (cps_enabled_) { + ss << config_.indent << config_.indent + << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS taken: split */\n", branch_target); } else if (known_functions_.count(branch_target)) { ss << config_.indent << config_.indent << fmt::format("func_{:08X}(cpu); return; /* taken: split piece */\n", branch_target); @@ -1170,6 +1178,9 @@ std::string CodeGenerator::translate_basic_block( if (fallthru_in) { ss << config_.indent << config_.indent << fmt::format("goto block_{:08X}; /* not taken */\n", fall_through_addr); + } else if (cps_enabled_) { + ss << config_.indent << config_.indent + << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS not taken: split */\n", fall_through_addr); } else if (known_functions_.count(fall_through_addr)) { ss << config_.indent << config_.indent << fmt::format("func_{:08X}(cpu); return; /* not taken: split piece */\n", fall_through_addr); @@ -1184,6 +1195,12 @@ std::string CodeGenerator::translate_basic_block( if (!block.successors.empty()) { ss << config_.indent << fmt::format("goto block_{:08X}; /* j */\n", block.successors[0]); + } else if (cps_enabled_ && block.exit_instr.target != 0) { + // CPS: out-of-function jump target — tail-transfer (the + // flat trampoline dispatches the split piece / mid-func). + ss << config_.indent + << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS j: split */\n", + block.exit_instr.target); } else if (block.exit_instr.target != 0 && known_functions_.count(block.exit_instr.target)) { // Jump target is out-of-function and is a known function start ss << config_.indent @@ -1230,6 +1247,11 @@ std::string CodeGenerator::translate_basic_block( ss << config_.indent << "cpu->pc = cpu->gpr[31]; psx_restore_state_escape(); return;" << " /* jr $ra — longjmp-return (ra loaded from non-sp) */\n"; + } else if (cps_enabled_) { + // CPS: publish $ra so the flat trampoline dispatches the + // caller's continuation (no host C-return to nest). + ss << config_.indent + << "cpu->pc = cpu->gpr[31]; return; /* CPS: jr $ra */\n"; } else { ss << config_.indent << "return; /* jr $ra */\n"; } @@ -1353,24 +1375,52 @@ std::string CodeGenerator::translate_basic_block( for (auto& [rt, rom] : targets) { ss << config_.indent << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", rt, rom); } - ss << config_.indent << " default: call_by_address(cpu, " << reg_name(jr_rs) << "); return;\n"; + if (cps_enabled_) { + ss << config_.indent << " default: cpu->pc = " << reg_name(jr_rs) + << "; return; /* CPS: jr table miss — tail-transfer */\n"; + } else { + ss << config_.indent << " default: call_by_address(cpu, " << reg_name(jr_rs) << "); return;\n"; + } ss << config_.indent << "}\n"; emitted_switch = true; } } if (!emitted_switch) { - // BIOS call or unrecognised indirect jump - ss << config_.indent << fmt::format("call_by_address(cpu, {}); /* jr {} */\n", - reg_name(jr_rs), reg_name(jr_rs)); - ss << config_.indent << "return;\n"; + if (cps_enabled_) { + // CPS: indirect jump / BIOS-call gate — tail-transfer + // to the target (the flat trampoline dispatches it). + ss << config_.indent << fmt::format("cpu->pc = {}; return; /* CPS: jr {} */\n", + reg_name(jr_rs), reg_name(jr_rs)); + } else { + // BIOS call or unrecognised indirect jump + ss << config_.indent << fmt::format("call_by_address(cpu, {}); /* jr {} */\n", + reg_name(jr_rs), reg_name(jr_rs)); + ss << config_.indent << "return;\n"; + } } } else if (block.exit_instr.type == ControlFlowType::JumpLink) { + uint32_t target = block.exit_instr.target; + uint32_t cont_addr = block.exit_instr.address + 8; + if (cps_enabled_) { + // CPS: tail-transfer to the callee. Set $ra to the + // return point and register it as a dispatchable + // continuation; the callee's jr $ra publishes cpu->pc = + // $ra and the flat trampoline dispatches back into this + // function's entry-switch. No host nesting. + if (!block.successors.empty()) { + cps_cur_continuations_.push_back(cont_addr); + } + ss << config_.indent + << fmt::format("cpu->gpr[31] = 0x{:08X}u; /* CPS jal return addr */\n", cont_addr); + ss << config_.indent + << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS jal -> 0x{:08X} */\n", + target, target); + } else { // Function call (jal). The call contract (Bug D family) // guards the continuation: it may only run if the guest // actually returned here with the caller's $sp. ss << config_.indent << "{ uint32_t _csp = cpu->gpr[29];\n"; ss << config_.indent << "cpu->gpr[31] = " << fmt::format("0x{:08X}; /* return address */\n", addr + 8); - uint32_t target = block.exit_instr.target; if (known_functions_.count(target) > 0) { ss << config_.indent << fmt::format("func_{:08X}(cpu); /* jal */\n", target); ss << config_.indent << fmt::format("if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return; }}\n", addr + 8); @@ -1386,7 +1436,6 @@ std::string CodeGenerator::translate_basic_block( } else { // Split-function: JAL continuation is outside this function piece. // Tail-call to the continuation piece (at exit_addr + 8, past delay slot). - uint32_t cont_addr = block.exit_instr.address + 8; if (known_functions_.count(cont_addr)) { ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* jal cont: split piece */\n", cont_addr); @@ -1395,11 +1444,28 @@ std::string CodeGenerator::translate_basic_block( << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* jal cont: split */\n", cont_addr); } } + } } else if (block.exit_instr.type == ControlFlowType::JumpLinkReg) { // Register indirect call (jalr $rs, $rd) — call function at rs, return to rd uint32_t rs = get_rs(block.exit_instr.instruction); uint32_t rd = get_rd(block.exit_instr.instruction); + uint32_t cont_addr = block.exit_instr.address + 8; + if (cps_enabled_) { + // CPS: capture the target reg BEFORE writing the link + // (rd==rs alias-safe), set $rd to the return point and + // register it as a dispatchable continuation, then + // tail-transfer. The flat trampoline drives the rest. + if (!block.successors.empty()) { + cps_cur_continuations_.push_back(cont_addr); + } + ss << config_.indent << fmt::format("{{ uint32_t _t = {};\n", reg_name(rs)); + if (rd != 0) { + ss << config_.indent << fmt::format(" {} = 0x{:08X}; /* CPS jalr return addr */\n", + reg_name(rd), cont_addr); + } + ss << config_.indent << "cpu->pc = _t; return; } /* CPS jalr */\n"; + } else { // Set link register to return address (PC + 8, past delay slot) ss << config_.indent << fmt::format("{} = 0x{:08X}; /* jalr return addr */\n", reg_name(rd), addr + 8); @@ -1418,7 +1484,6 @@ std::string CodeGenerator::translate_basic_block( block.successors[0]); } else { // Split-function: JALR continuation is outside this function piece. - uint32_t cont_addr = block.exit_instr.address + 8; if (known_functions_.count(cont_addr)) { ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* jalr cont: split piece */\n", cont_addr); @@ -1427,6 +1492,7 @@ std::string CodeGenerator::translate_basic_block( << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* jalr cont: split */\n", cont_addr); } } + } } } } @@ -1526,8 +1592,50 @@ GeneratedFunction CodeGenerator::generate_function( bd_windows, func.name, func.start_addr); } + // Pre-scan: find jump table targets that land mid-block (not a block boundary). + // These need inline labels so goto statements in the switch can reach them. + // Must run BEFORE translating blocks (translate_basic_block reads extra_labels_). + extra_labels_.clear(); + std::map> jr_table_edges; + scan_jr_tables(cfg, jr_table_edges); + + // CPS (§25): collect this function's continuations (call return points) as + // blocks are translated, so the entry-switch below can route a dispatched + // continuation address into the right block. + cps_cur_continuations_.clear(); + + // Translate blocks into a temp buffer first: the CPS entry-switch is emitted + // at the top (before the entry hooks) and must know every continuation. + std::stringstream blocks_ss; + for (uint32_t block_addr : cfg.block_order) { + const BasicBlock& block = cfg.blocks.at(block_addr); + blocks_ss << "\n" << translate_basic_block(block, cfg); + } + std::stringstream body_ss; body_ss << "{\n"; + + // CPS entry-switch: when the unified flat trampoline dispatches a + // continuation address (a callee published cpu->pc = $ra back to us), route + // into the owning block. Emitted BEFORE the entry hooks so a continuation + // re-entry bypasses debug_server_log_call_entry / widescreen entry hooks + // (those must run only on a true function entry, cpu->pc == 0). + if (cps_enabled_ && !cps_cur_continuations_.empty()) { + std::set seen; + body_ss << config_.indent << "if (cpu->pc != 0u) {\n"; + body_ss << config_.indent << " uint32_t _cont = cpu->pc; cpu->pc = 0;\n"; + body_ss << config_.indent << " switch (_cont) {\n"; + for (uint32_t c : cps_cur_continuations_) { + if (!seen.insert(c).second) continue; + body_ss << config_.indent + << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", c, c); + cps_continuation_owner_[c] = func.start_addr; + } + body_ss << config_.indent << " default: break;\n"; + body_ss << config_.indent << " }\n"; + body_ss << config_.indent << "}\n"; + } + body_ss << config_.indent << fmt::format("debug_server_log_call_entry(0x{:08X}u);\n", func.start_addr); @@ -1547,17 +1655,7 @@ GeneratedFunction CodeGenerator::generate_function( func.start_addr, func.size, cfg.blocks.size()); } - // Pre-scan: find jump table targets that land mid-block (not a block boundary). - // These need inline labels so goto statements in the switch can reach them. - extra_labels_.clear(); - std::map> jr_table_edges; - scan_jr_tables(cfg, jr_table_edges); - - // Generate code for each basic block - for (uint32_t block_addr : cfg.block_order) { - const BasicBlock& block = cfg.blocks.at(block_addr); - body_ss << "\n" << translate_basic_block(block, cfg); - } + body_ss << blocks_ss.str(); // Emit fallthrough call if the function ends without explicit control flow. // A MIPS function truly falls through when: @@ -1688,6 +1786,11 @@ std::vector CodeGenerator::generate_alias_group( std::map> jr_table_edges; scan_jr_tables(cfg, jr_table_edges); + // CPS (§25): collect this alias group's continuations during block + // translation (also prevents a stale list from leaking into the next + // generate_function call). + cps_cur_continuations_.clear(); + // Union of blocks reachable from any alias entry. Edges are // BasicBlock::successors plus jump-table targets (mapped to their // containing block — table targets may be mid-block labels). @@ -1721,12 +1824,41 @@ std::vector CodeGenerator::generate_alias_group( } } + // Translate live blocks first (populates cps_cur_continuations_ for the CPS + // continuation switch below). + std::stringstream blocks_buf; + for (uint32_t block_addr : cfg.block_order) { + if (!live_blocks.count(block_addr)) continue; + const BasicBlock& block = cfg.blocks.at(block_addr); + blocks_buf << "\n" << translate_basic_block(block, cfg); + } + // Shared body: host-range blocks (live subset) behind an entry switch. std::stringstream body; body << fmt::format("/* Overlapping-alias body for host func_{:08X}: {} entries */\n", host, aliases.size()); body << fmt::format("static void psx_alias_body_{:08X}(CPUState* cpu, uint32_t entry)\n{{\n", host); + // CPS (§25): a dispatched continuation (callee published cpu->pc = $ra) is + // routed into its block here, BEFORE the entry switch — cpu->pc overrides + // the `entry` arg. The dispatch routes a continuation by calling the first + // alias wrapper with cpu->pc set (entry is then ignored). Owner = + // aliases[0]->start_addr. + if (cps_enabled_ && !cps_cur_continuations_.empty()) { + std::set seen; + body << config_.indent << "if (cpu->pc != 0u) {\n"; + body << config_.indent << " uint32_t _cont = cpu->pc; cpu->pc = 0;\n"; + body << config_.indent << " switch (_cont) {\n"; + for (uint32_t c : cps_cur_continuations_) { + if (!seen.insert(c).second) continue; + body << config_.indent + << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", c, c); + cps_continuation_owner_[c] = aliases[0]->start_addr; + } + body << config_.indent << " default: break;\n"; + body << config_.indent << " }\n"; + body << config_.indent << "}\n"; + } body << config_.indent << "switch (entry) {\n"; for (const Function* a : aliases) { body << config_.indent @@ -1736,11 +1868,7 @@ std::vector CodeGenerator::generate_alias_group( body << config_.indent << " default: return;\n"; body << config_.indent << "}\n"; - for (uint32_t block_addr : cfg.block_order) { - if (!live_blocks.count(block_addr)) continue; - const BasicBlock& block = cfg.blocks.at(block_addr); - body << "\n" << translate_basic_block(block, cfg); - } + body << blocks_buf.str(); // Host-end fallthrough (mirrors generate_function): only relevant when the // host's final block is live and ends without explicit control flow. @@ -1787,6 +1915,11 @@ std::vector CodeGenerator::generate_all_functions( std::vector results; results.reserve(functions.size()); + // CPS (§25): rebuild the continuation->owner map for this full pass so the + // game dispatch table (psx_dispatch_game_compiled) reflects exactly the + // functions emitted here (the final generate_file pass is authoritative). + cps_continuation_owner_.clear(); + fmt::print("\n=== C Code Generation ===\n\n"); fmt::print("Generating C code for {} functions...\n", functions.size()); diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 9680ac91..8a909609 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1006,6 +1006,18 @@ void FullFunctionEmitter::emit_dispatch( const std::vector& bios_vectors, const std::vector& bios_aliases) { + // RECURSION_BUG.md §25 — continuation-passing. Under CPS the BIOS A0/B0/C0 + // vector dispatch must TAIL-TRANSFER to the handler (set cpu->pc, return) + // rather than nest psx_dispatch_call. A nested psx_dispatch_call 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, which then sees pc==0 and exits at boot. In legacy this was + // safe because the caller's `jal gate` was itself a nested dispatch whose + // psx_call_contract resumed the continuation; in CPS the caller + // tail-transferred, so the return must flow back to the SAME flat + // trampoline — which only happens if the vector handler tail-transfers too. + static const bool cps = (std::getenv("PSX_CPS") != nullptr); + out += "/* AUTO-GENERATED by psxrecomp-bios --emit-full. DO NOT EDIT.\n"; out += " *\n"; out += fmt::format(" * BIOS SHA256: {}\n", bios_sha256); @@ -1114,13 +1126,28 @@ void FullFunctionEmitter::emit_dispatch( // Runtime fallback: for Shell-patched entries not in the ROM table, // read the live function pointer from RAM and dispatch. if (bvt.table_ram_addr != 0u) { - out += fmt::format( - " /* Runtime fallback for Shell-patched / out-of-range entries */\n" - " {{\n" - " uint32_t target = cpu->read_word(0x{:08X}u + (uint32_t)cpu->gpr[{}] * 4u);\n" - " if (target) psx_dispatch_call(cpu, target, cpu->gpr[31]);\n" - " }}\n", - bvt.table_ram_addr, bvt.index_reg); + if (cps) { + // CPS: tail-transfer to the handler. $ra already holds the + // caller's return (set by the guest's jal to the A0/B0/C0 + // gate, preserved through the gate's jr and this vector). + // The flat trampoline dispatches the handler; its jr $ra + // then continues at the caller's return — no nesting. + out += fmt::format( + " /* Runtime fallback (CPS tail-transfer) */\n" + " {{\n" + " uint32_t target = cpu->read_word(0x{:08X}u + (uint32_t)cpu->gpr[{}] * 4u);\n" + " if (target) {{ cpu->pc = target; return; }}\n" + " }}\n", + bvt.table_ram_addr, bvt.index_reg); + } else { + out += fmt::format( + " /* Runtime fallback for Shell-patched / out-of-range entries */\n" + " {{\n" + " uint32_t target = cpu->read_word(0x{:08X}u + (uint32_t)cpu->gpr[{}] * 4u);\n" + " if (target) psx_dispatch_call(cpu, target, cpu->gpr[31]);\n" + " }}\n", + bvt.table_ram_addr, bvt.index_reg); + } } out += "}\n\n"; vec_handlers.push_back({bvt.ram_addr, hname}); diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index bf76e195..f5bbc2b7 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -812,9 +812,27 @@ int main(int argc, char** argv) { ds << "/* Maps PS1 address to compiled game code. Returns 1 if dispatched, 0 if unknown. */\n"; ds << "int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr) {\n"; ds << " switch (addr) {\n"; - for (uint32_t addr : dispatch_addrs) { - ds << fmt::format(" case 0x{:08X}u: func_{:08X}(cpu); return 1;\n", - addr, addr); + if (codegen.cps_enabled()) { + // CPS (§25): entries clear cpu->pc so the function's entry-switch is + // skipped (runs from the top); continuations set cpu->pc so the + // entry-switch routes into the owning block. The unified flat + // trampoline (psx_dispatch_impl) then drives every tail-transfer. + for (uint32_t addr : dispatch_addrs) { + ds << fmt::format(" case 0x{:08X}u: cpu->pc = 0; func_{:08X}(cpu); return 1;\n", + addr, addr); + } + const auto& conts = codegen.cps_continuations(); + ds << fmt::format(" /* {} continuation return-points (CPS) */\n", conts.size()); + for (const auto& [cont, owner] : conts) { + if (dispatch_addrs.count(cont)) continue; /* already a function entry */ + ds << fmt::format(" case 0x{:08X}u: cpu->pc = 0x{:08X}u; func_{:08X}(cpu); return 1;\n", + cont, cont, owner); + } + } else { + for (uint32_t addr : dispatch_addrs) { + ds << fmt::format(" case 0x{:08X}u: func_{:08X}(cpu); return 1;\n", + addr, addr); + } } ds << " default: return 0;\n"; ds << " }\n"; diff --git a/recompiler/src/strict_translator.cpp b/recompiler/src/strict_translator.cpp index babc28d0..e3570983 100644 --- a/recompiler/src/strict_translator.cpp +++ b/recompiler/src/strict_translator.cpp @@ -9,12 +9,22 @@ #include "strict_translator.h" #include +#include #include #include "fmt/format.h" namespace PSXRecompV4 { +// RECURSION_BUG.md §25 — continuation-passing call/return. Under CPS, a +// directly-handled "void" syscall (Enter/ExitCriticalSection) must fall +// through to the inline post-syscall code (the guest's own jr $ra) instead of +// returning to a nested dispatcher, so the syscall is emitted as +// `if (psx_syscall(...)) return;`. psx_syscall returns 0 for those void +// syscalls and 1 when control transfers (cpu->pc set). Gated so legacy codegen +// stays byte-identical. +static const bool g_strict_cps = (std::getenv("PSX_CPS") != nullptr); + namespace { // Emit a register-write that respects $zero being hardwired to 0. @@ -202,9 +212,19 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& case 0x0C: { // SYSCALL r.supported = true; - r.c_code = fmt::format( - "cpu->pc = 0x{:08X}u; psx_syscall(cpu, cpu->gpr[2]); return;", - d.address); + if (g_strict_cps) { + // CPS: void syscalls (psx_syscall returns 0) fall through to + // the inline post-syscall code (the guest's jr $ra), which + // returns to the caller via the flat trampoline. Transfers + // (return 1) set cpu->pc and return to the trampoline. + r.c_code = fmt::format( + "cpu->pc = 0x{:08X}u; if (psx_syscall(cpu, cpu->gpr[2])) return;", + d.address); + } else { + r.c_code = fmt::format( + "cpu->pc = 0x{:08X}u; psx_syscall(cpu, cpu->gpr[2]); return;", + d.address); + } r.comment = "syscall"; return r; } diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 9d90fbd4..9cbbeb8f 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -58,7 +58,13 @@ typedef struct CPUState { } CPUState; /* Trap trampolines — defined in runtime/src/traps.c */ -extern void psx_syscall(CPUState* cpu, uint32_t code); +/* psx_syscall returns 1 if control transfers (cpu->pc set to a dispatch target; + * the caller must return to the trampoline), 0 for a directly-handled "void" + * syscall (Enter/ExitCriticalSection). Under CPS the generated code emits + * `if (psx_syscall(...)) return;` so a void syscall falls through to the inline + * post-syscall code (the guest's own jr $ra); legacy callers ignore the return + * value and rely on cpu->pc (0 = handled, resume at caller). */ +extern int psx_syscall(CPUState* cpu, uint32_t code); extern void psx_arith_overflow(CPUState* cpu); extern void psx_unaligned_access(CPUState* cpu, uint32_t addr, uint32_t pc); extern void psx_break(CPUState* cpu, uint32_t code, uint32_t pc); diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index a00c4ed2..bd95f5f7 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -29,7 +29,9 @@ * window START/END finalize, and the callback struct grows a * ws_backdrop_value pointer (appended last). Bumping rejects pre-preload * DLLs (which lack both the emit and a host that supplies the callback). */ -#define PSX_OVERLAY_ABI_VERSION 4 +/* v5: psx_syscall callback return type void->int (CPS, RECURSION_BUG.md §25); + * overlays compiled under CPS emit `if (psx_syscall(...)) return;`. */ +#define PSX_OVERLAY_ABI_VERSION 5 /* Codegen flavor of the recompiled output the overlays + runtime were built * against. Overlays are keyed in the cache by guest-bytes CRC, which is @@ -76,8 +78,11 @@ typedef struct { void (*check_interrupts)(CPUState *cpu); /* GTE coprocessor 2 execution */ void (*gte_execute)(CPUState *cpu, uint32_t cmd); - /* MIPS syscall (break/syscall instructions) */ - void (*psx_syscall)(CPUState *cpu, uint32_t code); + /* MIPS syscall (break/syscall instructions). Returns 1 if control + * transfers (cpu->pc set), 0 for a directly-handled void syscall. See + * cpu_state.h. The signature changed with CPS (RECURSION_BUG.md §25), so + * PSX_OVERLAY_ABI_VERSION was bumped to reject stale overlay caches. */ + int (*psx_syscall)(CPUState *cpu, uint32_t code); /* Unresolved dispatch target */ void (*psx_unknown_dispatch)(CPUState *cpu, uint32_t addr, uint32_t phys); /* Debug instrumentation: called at every function entry (may be NULL) */ diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 72ce176f..4a18ed12 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -721,7 +721,12 @@ int dirty_ram_xprobe_json(char *out, int cap) { /* Enter compiled code from the interpreter for a guest control transfer. Returns * like psx_dispatch_game_compiled (1 = handled, OR a stack-watermark bail was * surfaced; 0 = not a compiled target — caller falls through to overlay/dirty/ - * nonlocal paths). On surface: cpu->pc = target and g_psx_call_bail set. */ + * nonlocal paths). On surface: cpu->pc = target and g_psx_call_bail set. + * + * Only present when a game dispatch table is linked (PSX_HAS_GAME_DISPATCH). + * In the BIOS-only build psx_dispatch_game_compiled does not exist, and the + * two callers below are themselves gated behind the same macro. */ +#ifdef PSX_HAS_GAME_DISPATCH static int interp_enter_compiled(CPUState *cpu, uint32_t target) { if (target == 0x8001A954u) site_note(&g_site_interp); if (psx_mixed_owner_enabled() @@ -735,6 +740,7 @@ static int interp_enter_compiled(CPUState *cpu, uint32_t target) { g_mixed_depth--; return r; } +#endif /* PSX_HAS_GAME_DISPATCH */ /* Execute ONE instruction at *pc on the given CPU state. Returns: * 0 = continue (advance pc by 4) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 71502445..6c3bc82a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2410,7 +2410,45 @@ int main(int argc, char** argv) { psx_dispatch(&cpu, cpu.pc); #endif - /* If we reach here, all execution completed without MMIO abort. */ + /* If we reach here, all execution completed without MMIO abort. + * During normal operation the guest runs an infinite main loop and the + * top-level psx_dispatch NEVER returns. Reaching this point means the + * outermost trampoline loop saw cpu->pc == 0 (some jr/tail-transfer + * published a null PC) — an abnormal boot exit. Dump the always-on + * fntrace ring tail (last dispatch chain) to a JSON artifact so we can + * see exactly which targets led to the null PC. (CLAUDE.md ring-buffer + * model: consume the always-on ring after the fact, not arm-and-time.) */ + { + FILE* tf = std::fopen("psx_cps_exit_trace.json", "wb"); + if (tf) { + std::fprintf(tf, "{\n \"final_pc\": \"0x%08X\",\n \"final_ra\": \"0x%08X\",\n" + " \"final_sp\": \"0x%08X\",\n \"fntrace_seq\": %llu,\n \"tail\": [\n", + cpu.pc, cpu.gpr[31], cpu.gpr[29], + (unsigned long long)g_fntrace_seq); + uint64_t seq = g_fntrace_seq; + uint32_t n = seq < 128u ? (uint32_t)seq : 128u; + for (uint32_t i = 0; i < n; i++) { + uint64_t idx = seq - n + i; + const FntraceEntry* e = &g_fntrace_ring[idx % FNTRACE_RING_CAP]; + std::fprintf(tf, + " {\"seq\":%llu,\"frame\":%u,\"target\":\"0x%08X\",\"ra\":\"0x%08X\"," + "\"sp\":\"0x%08X\",\"a0\":\"0x%08X\",\"a1\":\"0x%08X\"}%s\n", + (unsigned long long)idx, e->frame, e->target, e->ra, e->sp, + e->a0, e->a1, (i + 1 < n) ? "," : ""); + } + std::fprintf(tf, " ],\n \"stack\": [\n"); + uint32_t sbase = (cpu.gpr[29] - 0x40u) & ~3u; + for (int w = 0; w < 40; w++) { + uint32_t a = sbase + (uint32_t)w * 4u; + uint32_t v = cpu.read_word(a); + std::fprintf(tf, " {\"addr\":\"0x%08X\",\"val\":\"0x%08X\"}%s\n", + a, v, (w < 39) ? "," : ""); + } + std::fprintf(tf, " ]\n}\n"); + std::fclose(tf); + } + } + std::fprintf(stdout, "psxrecomp runtime: execution completed, PC=0x%08X\n", cpu.pc); shutdown_runtime(); diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 2d0afcbb..2024c295 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -666,7 +666,7 @@ int overlay_loader_has_cached_crc(uint32_t region_start, uint32_t crc) { extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t ra); extern void psx_check_interrupts(CPUState *cpu); extern void gte_execute(CPUState *cpu, uint32_t cmd); -extern void psx_syscall(CPUState *cpu, uint32_t code); +extern int psx_syscall(CPUState *cpu, uint32_t code); extern void psx_unknown_dispatch(CPUState *cpu, uint32_t addr, uint32_t phys); extern void debug_server_log_call_entry(uint32_t func_addr); diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 0c089075..595f54aa 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -354,7 +354,7 @@ static int psx_change_thread(CPUState* cpu, uint32_t target_tcb) return psx_change_thread_fiber(cpu, target_tcb); } -void psx_syscall(CPUState* cpu, uint32_t code) { +int psx_syscall(CPUState* cpu, uint32_t code) { /* * PS1 BIOS SYSCALL convention: * $a0 = 1: EnterCriticalSection — disable interrupts, return old SR @@ -377,18 +377,29 @@ void psx_syscall(CPUState* cpu, uint32_t code) { cpu->cop0[12] = sr & ~1u; /* clear IEc (bit 0) */ cpu->gpr[2] = sr & 1u; /* return old IEc */ cpu->pc = 0; - return; + /* Directly handled "void" syscall. Return 0: under CPS the caller + * (`if (psx_syscall(...)) return;`) falls through to the inline + * post-syscall code (the guest's own jr $ra), which returns to the + * caller via the flat trampoline — matching real-HW resume at + * EPC+4. Legacy callers ignore this and rely on cpu->pc==0. */ + return 0; case 2: /* ExitCriticalSection: enable interrupts */ cpu->cop0[12] = sr | 0x0401u; /* set IEc (bit 0) + IM[2] (bit 10) */ cpu->gpr[2] = 0; cpu->pc = 0; - return; + return 0; case 3: { /* ChangeThread / ReturnFromException */ uint32_t target_tcb = cpu->gpr[5]; if (!psx_get_in_exception() && psx_change_thread(cpu, target_tcb)) { - return; + /* Thread switched (and this thread has since been resumed): the + * fiber scheduler leaves cpu->pc == 0. Under CPS that must NOT + * be a transfer — return 0 so the syscall wrapper falls through + * to its own jr $ra and this thread resumes at its caller via + * the flat trampoline. Legacy ignores the return value (it uses + * cpu->pc == 0 + the nested-dispatch C-return to resume). */ + return 0; } uint32_t tcb_ptr_addr = cpu->read_word(0x00000108u); @@ -412,13 +423,13 @@ void psx_syscall(CPUState* cpu, uint32_t code) { if (psx_get_in_exception()) { psx_exception_longjmp(); /* unwind handler */ } - return; + return 1; } } /* Fallback: simple RFE on current SR. */ cpu->cop0[12] = (sr & ~0x0Fu) | ((sr >> 2) & 0x0Fu); cpu->pc = cpu->cop0[14]; - return; + return 1; } default: @@ -434,14 +445,14 @@ void psx_syscall(CPUState* cpu, uint32_t code) { cpu->cop0[12] = (sr & ~0x3Fu) | ((sr & 0x0Fu) << 2); /* SR push */ uint32_t vector = (sr & 0x00400000u) ? 0xBFC00180u : 0x80000080u; psx_dispatch(cpu, vector); - return; + return 1; } /* Early boot fallback for syscall 3. */ if (func == 3) { cpu->cop0[12] = (sr & ~0x0Fu) | ((sr >> 2) & 0x0Fu); cpu->pc = cpu->cop0[14]; - return; + return 1; } /* Unknown syscall number and no handler — fatal. */ @@ -453,6 +464,7 @@ void psx_syscall(CPUState* cpu, uint32_t code) { fprintf(stderr, "%s\n", buf); fflush(stderr); exit(1); } + return 1; } void psx_break(CPUState* cpu, uint32_t code, uint32_t pc) {