recursion(fix): universal continuation-passing — FIXES the idle-freeze host-stack leak (BIOS+game)

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 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-18 12:45:02 -07:00
parent e28c2a0ff9
commit 94a1966444
12 changed files with 429 additions and 53 deletions
+85
View File
@@ -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.
+26
View File
@@ -176,6 +176,32 @@ private:
std::set<uint32_t> 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<uint32_t> 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<uint32_t, uint32_t> cps_continuation_owner_;
public:
bool cps_enabled() const { return cps_enabled_; }
// continuation_addr -> owning function entry (CPS dispatch routing).
const std::map<uint32_t, uint32_t>& 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
+158 -25
View File
@@ -7,6 +7,7 @@
#include "../../runtime/include/ws_backdrop_detect.h"
#include <fmt/format.h>
#include <algorithm>
#include <cstdlib>
#include <set>
#include <vector>
@@ -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<uint32_t, std::vector<uint32_t>> 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<uint32_t> 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<uint32_t, std::vector<uint32_t>> 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<GeneratedFunction> CodeGenerator::generate_alias_group(
std::map<uint32_t, std::vector<uint32_t>> 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<GeneratedFunction> 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<uint32_t> 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<GeneratedFunction> 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<GeneratedFunction> CodeGenerator::generate_all_functions(
std::vector<GeneratedFunction> 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());
+34 -7
View File
@@ -1006,6 +1006,18 @@ void FullFunctionEmitter::emit_dispatch(
const std::vector<BiosVectorTable>& bios_vectors,
const std::vector<BiosAlias>& 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});
+21 -3
View File
@@ -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";
+23 -3
View File
@@ -9,12 +9,22 @@
#include "strict_translator.h"
#include <cstdint>
#include <cstdlib>
#include <string>
#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;
}
+7 -1
View File
@@ -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);
+8 -3
View File
@@ -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) */
+7 -1
View File
@@ -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)
+39 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
+20 -8
View File
@@ -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) {