From 2d468ee5b87cc27568d4f557ce09e6ec2f9c49d4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 18 Jun 2026 13:35:31 -0700 Subject: [PATCH] =?UTF-8?q?recursion(stage3):=20overlay=20(sljit=20+=20gcc?= =?UTF-8?q?-DLL)=20CPS=20parity=20=E2=80=94=20full=20native=20overlay=20co?= =?UTF-8?q?verage=20under=20CPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RECURSION_BUG.md §25.5. Brings the overlay native path under continuation-passing so the idle-freeze leak fix holds with overlay_cache=true (the production gcc>sljit>interp path). Runtime CPS-mode flag g_psx_cps_mode (overlay_loader.c) set by a __attribute__((constructor)) emitted in the BIOS + game CPS dispatch (full_function_emitter.cpp / main_psx.cpp). Gates all overlay/sljit CPS behavior; legacy unaffected. sljit JIT (overlay_sljit.c), scoped: - jr $ra publishes cpu->pc (was a bare return). jr rX already tail-transferred. - call-containing fragments DECLINE to the interpreter (CPS-safe, depth-1) — leaf shards still JIT. (Full sljit call coverage / per-fragment entry-switch is future work.) gcc-DLL path — FULL native overlay coverage (validated): - compile_overlays.py --cps sets PSX_CPS for the recompiler -> CPS overlay C (tail-transfer + per-function entry-switch; --overlay mode only affects widescreen, not CPS). DISPATCH_PREAMBLE psx_syscall decl fixed void->int. - PSX_OVERLAY_CODEGEN_VER 3->4: fresh cg4/ cache namespace; legacy unit-model DLLs (cg3) ignored. - overlay_loader_dispatch: under CPS, when a dispatched addr is not an overlay ENTRY but lies inside a registered candidate's code range (a tail-transferred call's continuation), overlay_find_by_range() re-enters that function with cpu->pc=addr so 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. Validated: CPS overlay DLLs compile + load + dispatch_native>0; with continuation routing dispatch_interp_fallback -> 0 (full native), ce_profile FLAT (6 KB), boots clean. Addresses are KSEG0-consistent. Co-Authored-By: Claude Opus 4.8 --- docs/RECURSION_BUG.md | 36 +++++++++++-- recompiler/src/full_function_emitter.cpp | 9 ++++ recompiler/src/main_psx.cpp | 10 ++++ runtime/include/overlay_api.h | 7 ++- runtime/src/overlay_loader.c | 66 ++++++++++++++++++++++++ runtime/src/overlay_sljit.c | 25 +++++++++ tools/compile_overlays.py | 15 ++++-- 7 files changed, 158 insertions(+), 10 deletions(-) diff --git a/docs/RECURSION_BUG.md b/docs/RECURSION_BUG.md index 80d3ade7..b13193c7 100644 --- a/docs/RECURSION_BUG.md +++ b/docs/RECURSION_BUG.md @@ -1266,8 +1266,36 @@ it decelerates and is unrelated to the (now-fixed) host-stack leak. Run gameplay `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. + +### 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 --cps` sets `PSX_CPS` for the recompiler → CPS overlay C (tail-transfer + + per-function entry-switch; `code_generator` `--overlay` mode only affects widescreen, not + CPS). The DISPATCH_PREAMBLE's `psx_syscall` decl was fixed void→int. `PSX_OVERLAY_CODEGEN_VER` + bumped 3→4 (fresh `cg4/` cache namespace; legacy unit-model DLLs in cg3 ignored). +- **Loader continuation routing** (`overlay_loader_dispatch`, gated `g_psx_cps_mode`): under CPS + an overlay function is flat-dispatched (one block then tail-transfer), so a callee returns to + a mid-function continuation that idx_head() (entry-keyed) misses. New `overlay_find_by_range()` + finds the owning candidate by code range and re-enters it with `cpu->pc = addr` → its + entry-switch routes to the block. gcc DLLs are the trusted tier (diff-validated at entry), run + native directly. This is the CPS equivalent of the old unit-model that fixed the dwarf→overworld + blue screen — overlay funcs no longer run as units; continuations re-dispatch flat. +- **Result:** CPS overlay DLLs compile + load + `dispatch_native > 0`; with continuation routing + **`dispatch_interp_fallback` → 0** (full native overlay coverage), ce_profile FLAT (6 KB), + boots clean. Addresses are KSEG0-consistent (overlay C sets `gpr[31]=0x800E…` continuations = + the entry-switch cases). + +REMAINING: capture breadth — only the 2 overlays my short run captured are gcc-compiled; other +scenes' overlays gcc-miss → sljit-leaf/interp (still CPS-safe) until captured+compiled (play +through → `overlay_captures.json` grows → re-run `compile_overlays.py --cps`). dwarf→overworld +playtest (the regression check). Then consider CPS default + removing inert +`psx_call_contract`/`g_psx_call_bail`. Commit Stage 3 when the user is satisfied. diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 8a909609..824d7dd9 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1336,6 +1336,15 @@ void FullFunctionEmitter::emit_dispatch( out += "void psx_dispatch_call(CPUState* cpu, uint32_t addr, uint32_t return_addr) {\n"; out += " psx_dispatch_impl(cpu, addr, return_addr);\n"; out += "}\n"; + + if (cps) { + // RECURSION_BUG.md §25 — mark CPS mode at startup for runtime code that + // must emit the CPS contract (the overlay sljit JIT, overlay_sljit.c). + out += "\n/* CPS runtime-mode marker (overlay sljit JIT reads g_psx_cps_mode). */\n"; + out += "__attribute__((constructor)) static void psx_cps_mark_bios(void) {\n"; + out += " extern int g_psx_cps_mode; g_psx_cps_mode = 1;\n"; + out += "}\n"; + } } // --------------------------------------------------------------------------- diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index f5bbc2b7..ff57ea53 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -838,6 +838,16 @@ int main(int argc, char** argv) { ds << " }\n"; ds << "}\n"; + if (codegen.cps_enabled()) { + // RECURSION_BUG.md §25 — mark CPS mode at startup so the overlay sljit + // JIT (overlay_sljit.c) emits the CPS contract. Static ctor: no clash + // with the BIOS dispatch's marker. + ds << "\n/* CPS runtime-mode marker (overlay sljit JIT reads g_psx_cps_mode). */\n"; + ds << "__attribute__((constructor)) static void psx_cps_mark_game(void) {\n"; + ds << " extern int g_psx_cps_mode; g_psx_cps_mode = 1;\n"; + ds << "}\n"; + } + std::ofstream dispatch_file(dispatch_filename); if (dispatch_file.is_open()) { dispatch_file << ds.str(); diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index bd95f5f7..c20d2ff5 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -68,8 +68,11 @@ * 1: baseline + auto_screen_x render-funnel cull via psx_ws_cull_sltiu. * 2: + auto_backdrop far-backdrop column preload via psx_ws_backdrop_value. * 3: auto_backdrop switched to camera-tracked window WIDENING - * (psx_ws_backdrop_value gains a window_cols arg). */ -#define PSX_OVERLAY_CODEGEN_VER 3 + * (psx_ws_backdrop_value gains a window_cols arg). + * 4: continuation-passing (PSX_CPS, RECURSION_BUG.md §25) — overlay funcs + * tail-transfer + carry an entry-switch; the loader routes continuations. + * Fresh namespace so CPS DLLs never reuse a legacy (unit-model) cg3 DLL. */ +#define PSX_OVERLAY_CODEGEN_VER 4 typedef struct { /* Core dispatch: routes call_by_address() and out-of-overlay jal */ diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 2024c295..03b952df 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -80,6 +80,12 @@ typedef struct { static Candidate s_cand[CAND_CAP]; static int s_cand_n = 0; +/* RECURSION_BUG.md §25 — 1 when the build is continuation-passing (set by a + * constructor in the generated CPS dispatch). The overlay dispatch + sljit JIT + * read it to emit/route the CPS tail-transfer contract. Defined here (before + * overlay_loader_dispatch) so both can see it. 0 = legacy unit-model. */ +int g_psx_cps_mode = 0; + /* Open-addressed index: phys entry addr -> head candidate index (-1 sentinel * stored as chain terminator on each Candidate). addr 0 = empty slot. */ #define IDX_CAP 32768u @@ -990,6 +996,25 @@ static void try_load_region(uint32_t phys) { /* ---- Dispatch ---------------------------------------------------------- */ +/* CPS (§25): find a non-blacklisted candidate whose code range CONTAINS phys — + * i.e. phys is a continuation / return point INSIDE an overlay function, not its + * registered entry. Under continuation-passing, an overlay function tail- + * transfers after each block, so a callee returns to a mid-function address that + * idx_head() (entry-keyed) misses; we re-enter the owning function with + * cpu->pc = that address so its entry-switch routes to the right block. Returns + * the candidate index, or -1. */ +static int overlay_find_by_range(uint32_t phys) { + for (int i = 0; i < s_cand_n; i++) { + const Candidate *c = &s_cand[i]; + if (c->state == ENTRY_BLACKLIST) continue; + for (int r = 0; r < c->nranges; r++) { + uint32_t lo = c->range_lo[r]; + if (phys >= lo && phys < lo + c->range_len[r]) return i; + } + } + return -1; +} + int overlay_loader_dispatch(CPUState *cpu, uint32_t addr) { uint32_t phys = addr & 0x1FFFFFFFu; int head = idx_head(phys); @@ -1013,6 +1038,47 @@ int overlay_loader_dispatch(CPUState *cpu, uint32_t addr) { } } + /* CPS (§25) continuation re-entry: phys is not an overlay ENTRY, but it may + * be a return point inside an already-registered overlay function (the + * caller's continuation after a tail-transferred call). Re-enter that + * function natively with cpu->pc = addr so its entry-switch routes to the + * continuation block — without this the continuation falls to the interp and + * native coverage stops at the first call. gcc-DLL candidates are the trusted + * tier (diff-validated at their entry; the continuation is the same DLL code), + * so they run native directly here; device-touch funcs still go to interp. */ + if (head < 0 && g_psx_cps_mode) { + int ci = overlay_find_by_range(phys); + if (ci >= 0) { + Candidate *c = &s_cand[ci]; + uint32_t live = cand_crc(c); + s_rehashes++; + s_last_crc = live; + if (live == c->crc_code) { + if (c->state != ENTRY_VALID) { c->state = ENTRY_VALID; s_valid_count++; } + if (c->device_touch) { s_disp_interp++; return 0; } + if (!s_native_exec) { s_would_run_native++; s_disp_interp++; return 0; } + uint32_t slot = s_nring_pos++ & (NRING_CAP - 1u); + s_nring[slot].addr = addr; + s_nring[slot].crc = c->crc_code; + s_nring[slot].seq = ++s_nring_seq; + s_nring[slot].returned = 0; + uint32_t prev_inprogress = s_native_inprogress; + s_native_inprogress = c->addr; + s_native_calls_total++; + if (s_active_depth < (int)(sizeof(s_active_stack) / sizeof(s_active_stack[0]))) + s_active_stack[s_active_depth++] = ci; + s_disp_native++; + cpu->pc = addr; /* route the func's entry-switch to the block */ + c->fn(cpu); + if (s_active_depth > 0) s_active_depth--; + s_nring[slot].returned = 1; + s_native_inprogress = prev_inprogress; + return 1; + } + /* stale code bytes: fall through to the interpreter */ + } + } + for (int i = head; i >= 0; i = s_cand[i].next) { Candidate *c = &s_cand[i]; if (c->state == ENTRY_BLACKLIST) continue; diff --git a/runtime/src/overlay_sljit.c b/runtime/src/overlay_sljit.c index 07fe405d..1e67e0a2 100644 --- a/runtime/src/overlay_sljit.c +++ b/runtime/src/overlay_sljit.c @@ -27,6 +27,13 @@ /* ---- counters ---------------------------------------------------------- */ static OverlayBackend s_active = OVERLAY_BACKEND_AUTO; +/* RECURSION_BUG.md §25 — 1 when the build is continuation-passing (set by a + * constructor in the generated CPS dispatch). Under CPS this JIT must emit the + * tail-transfer contract: jr $ra publishes cpu->pc, and call-containing + * fragments decline to the (CPS-safe, depth-1) interpreter instead of the legacy + * nested psx_sljit_call. Defined in overlay_loader.c. */ +extern int g_psx_cps_mode; + static int s_resolved = 0; static int s_selftest_ok = -1; /* -1 = not run */ static uint64_t s_compiles = 0; @@ -980,6 +987,15 @@ void overlay_sljit_try_compile(uint32_t entry, if (emit_one(C, insn, s_bd_kind[i], s_bd_cols[i]) != EMIT_OK) { aborted = 1; break; } gpr_flush(C); if (pending == PEND_RET) { + if (g_psx_cps_mode) { + /* CPS: publish $ra (jr $ra) so the flat trampoline dispatches + * the caller's continuation. gpr[] already flushed to memory + * by gpr_flush() above. */ + sljit_emit_op1(C, SLJIT_MOV32, SLJIT_R0, 0, SLJIT_MEM1(R_CPU), + (sljit_sw)(offsetof(CPUState, gpr) + 31 * sizeof(uint32_t))); + sljit_emit_op1(C, SLJIT_MOV32, SLJIT_MEM1(R_CPU), + (sljit_sw)offsetof(CPUState, pc), SLJIT_R0, 0); + } sljit_emit_return_void(C); gpr_reset(); } else if (pending == PEND_TAIL) { @@ -1000,6 +1016,15 @@ void overlay_sljit_try_compile(uint32_t entry, * the following code is dead or label-guarded — reset. */ if (!pend_cond) gpr_reset(); } else { /* PEND_CALL: psx_sljit_call(cpu, target, return_pc, check) */ + if (g_psx_cps_mode) { + /* CPS: a call's continuation (return_pc, inside this fragment) + * would need a re-entry entry-switch this JIT doesn't emit; + * and psx_sljit_call uses the legacy nested contract. Decline + * the whole fragment — it falls to the interpreter, which + * handles the call CPS-safely (depth-1). Leaf fragments (no + * calls) still JIT. */ + aborted = 1; break; + } sljit_emit_op1(C, SLJIT_MOV, SLJIT_R0, 0, R_CPU, 0); /* cpu */ if (pend_call_dynamic) sljit_emit_op1(C, SLJIT_MOV32, SLJIT_R1, 0, R_CTRL, 0); /* jalr target */ diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index a2b263ee..727ba474 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -815,8 +815,8 @@ void psx_check_interrupts(CPUState *cpu) { void gte_execute(CPUState *cpu, uint32_t cmd) { g_cbs.gte_execute(cpu, cmd); } -void psx_syscall(CPUState *cpu, uint32_t code) { - g_cbs.psx_syscall(cpu, code); +int psx_syscall(CPUState *cpu, uint32_t code) { + return g_cbs.psx_syscall(cpu, code); } void psx_unknown_dispatch(CPUState *cpu, uint32_t addr, uint32_t phys) { g_cbs.psx_unknown_dispatch(cpu, addr, phys); @@ -1129,6 +1129,10 @@ def main(): help='codegen flavor id baked into overlay_abi() (0=base/master; ' 'widescreen build passes 1). The loader rejects DLLs whose ' 'flavor != the runtime flavor, keeping caches separate.') + ap.add_argument('--cps', action='store_true', + help='continuation-passing (RECURSION_BUG.md §25): set PSX_CPS ' + 'when invoking the recompiler so overlay funcs tail-transfer ' + '+ carry an entry-switch. Must match the runtime build.') args = ap.parse_args() # Read game ID from game.toml (strip BOM if present) @@ -1259,10 +1263,13 @@ def main(): # sites that resolve into overlay code) are applied. --ws-config # only adopts the widescreen lists, not the game's exe/paths. '--ws-config', os.path.abspath(args.game_toml)] - print(f' recompile: {args.recompiler} ...') + print(f' recompile: {args.recompiler} ...{" [CPS]" if args.cps else ""}') toml_dir = os.path.dirname(os.path.abspath(args.game_toml)) + sub_env = dict(os.environ) + if args.cps: + sub_env['PSX_CPS'] = '1' # §25: emit continuation-passing overlay C r = subprocess.run(cmd, capture_output=True, text=True, - cwd=toml_dir) + cwd=toml_dir, env=sub_env) if r.returncode != 0: print(f' RECOMPILER ERROR:\n{r.stderr or r.stdout}') continue