recursion(stage3): overlay (sljit + gcc-DLL) CPS parity — full native overlay coverage under CPS

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 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-18 13:35:31 -07:00
parent 94a1966444
commit 2d468ee5b8
7 changed files with 158 additions and 10 deletions
+32 -4
View File
@@ -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.
+9
View File
@@ -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";
}
}
// ---------------------------------------------------------------------------
+10
View File
@@ -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();
+5 -2
View File
@@ -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 */
+66
View File
@@ -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;
+25
View File
@@ -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 */
+11 -4
View File
@@ -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