From 2439d4dd44342f329f495ea98436e33d0e7e77eb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 10 Jun 2026 22:30:41 -0700 Subject: [PATCH] dispatch call contract: continuations only run when the guest really returns Bug D root cause (Tomba chest freeze, also QA issue #1): the game's own dispatcher func_80056E08 has a dead jumptable case (type 0x21 -> jal 0x80120B3C) whose target is mid-function in EVERY overlay variant on the disc (only the boot EXE references it; the real handler entry is 0x80120AC8). Entering mid-function skips the prologue, runs an epilogue for a frame that was never pushed, and returns through garbage. Our C model then resumed the suspended generated continuations on the moved guest stack -> double-executed tails -> infinite zombie loop (~3K wild calls/frame) -> MMIO fatal. Family root of Bugs A/C/D. The contract: a generated C continuation may only run if the guest actually arrived at it ($ra == call site return address, $sp == caller sp at the call). Violations begin a "bail" unwind: cpu->pc := the wild jr's true target, every generated frame returns immediately, and the unwind resolves at the first enclosing site whose contract matches (or flattens at the outermost dispatch loop, re-dispatching the wild target on a clean host stack -- matching real-HW one-stack semantics). - cpu_state.h: bail state + psx_call_contract() helper (+ overlay-DLL pointer indirection under PSX_OVERLAY_DLL_BUILD) - emitters: (ra, sp) checks at every BIOS and game jal/jalr site; psx_dispatch_impl validates pc==0 and stop-hits against (stop, sp), handles bail resolve/propagate/flatten - dirty_ram_interp: surfaced calls validate the contract on return; bail aborts the interp run; tail transfers into compiled code now run with pc cleared (closes a latent double-run on plain C returns) - overlay cache ABI v2: DLLs export overlay_abi(); loader rejects and deletes stale DLLs (autocompile regenerates with contract checks) - telemetry: bail_first/resolved/flattened/anomaly in heartbeat + dump headers; anomaly guard at exception entry; TCP_COMMANDS.md docs Verified: BIOS+Tomba regen, boot to intro at locked 60.0 fps, all bail counters 0 through boot/title/attract (no false positives). Chest-area gameplay verification pending (requires user play). Co-Authored-By: Claude Fable 5 --- TCP_COMMANDS.md | 20 ++++++ recompiler/src/code_generator.cpp | 12 +++- recompiler/src/full_function_emitter.cpp | 79 ++++++++++++++++++++++++ runtime/include/cpu_state.h | 75 ++++++++++++++++++++++ runtime/include/overlay_api.h | 15 +++++ runtime/src/dirty_ram_interp.c | 40 +++++++++++- runtime/src/freeze_heartbeat.c | 17 +++++ runtime/src/interrupts.c | 8 +++ runtime/src/overlay_loader.c | 32 ++++++++++ runtime/src/traps.c | 7 +++ tools/compile_overlays.py | 24 ++++++- 11 files changed, 324 insertions(+), 5 deletions(-) diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index 0e06696d..2e21c1c9 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -120,6 +120,26 @@ samples inside `WS2_32!send`). A slow-frames wedge with a large `tcp_send_stall_ms` delta over the same window is observer interference, not a guest bug. +## Call-contract (bail) telemetry + +The dispatch call contract (Bug D family fix, 2026-06-10) guards every +generated continuation: it may only run if the guest actually returned to +the call site ($ra == site return address, $sp == caller's sp at the +call). Violations begin a "bail" unwind that abandons stale C frames and +re-dispatches the guest's true target. Counters in +`psx_freeze_heartbeat.json` and dump headers: + +- `bail_first` — contract violations detected (wild returns). Nonzero + during gameplay means the game executed a wild control transfer (e.g. + Tomba's dead jumptable case `jal 0x80120B3C`, the chest freeze). A + small count with the game continuing normally is the fix working. +- `bail_resolved` — unwinds that resolved at an enclosing call site whose + contract matched (multi-level return). +- `bail_flattened` — unwinds that reached the outermost dispatch loop and + re-dispatched the wild target on a clean host stack. +- `bail_anomaly` — bail flag observed at exception entry (must stay 0; + anything else is a runtime bug). + --- ## Rule when the server can't answer your question diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 73e52a9c..d1e44389 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1199,13 +1199,20 @@ std::string CodeGenerator::translate_basic_block( ss << config_.indent << "return;\n"; } } else if (block.exit_instr.type == ControlFlowType::JumpLink) { - // Function call (jal) + // 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); } else { ss << config_.indent << fmt::format("call_by_address(cpu, 0x{:08X}u); /* external jal */\n", target); + /* psx_dispatch_call validated the (ra, sp) contract; + * only propagate an active bail unwind here. */ + ss << config_.indent << "if (g_psx_call_bail) return; (void)_csp; }\n"; } if (!block.successors.empty()) { ss << config_.indent @@ -1234,6 +1241,9 @@ std::string CodeGenerator::translate_basic_block( // Dispatch indirect call — target is a runtime register value ss << config_.indent << fmt::format("call_by_address(cpu, {}); /* jalr {} */\n", reg_name(rs), reg_name(rs)); + /* psx_dispatch_call validated the (ra, sp) contract; + * only propagate an active bail unwind here. */ + ss << config_.indent << "if (g_psx_call_bail) return;\n"; // Continue to successor block (instruction at return address) if (!block.successors.empty()) { diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 5111fbca..76872fe7 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -521,8 +521,11 @@ bool FullFunctionEmitter::emit_function( } uint32_t target = relocate_j_target(addr, tr.terminator_target); uint32_t return_addr = relocate_ra(addr + 8); + out += " { uint32_t _csp = cpu->gpr[29];\n"; out += fmt::format(" cpu->gpr[31] = 0x{:08X}u;\n", return_addr); out += fmt::format(" psx_dispatch(cpu, 0x{:08X}u);\n", target); + out += fmt::format(" if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return; }}\n", + return_addr); out += fmt::format(" cpu->pc = 0x{:08X}u; return;\n", return_addr); } else if (kind == "jalr") { uint32_t ds_addr = addr + 4; @@ -542,12 +545,20 @@ bool FullFunctionEmitter::emit_function( uint8_t rs = (raw >> 21) & 0x1F; uint8_t rd = (raw >> 11) & 0x1F; uint32_t return_addr = relocate_ra(addr + 8); + out += " { uint32_t _csp = cpu->gpr[29];\n"; if (rd != 0) { out += fmt::format(" cpu->gpr[{}] = 0x{:08X}u;\n", static_cast(rd), return_addr); } out += fmt::format(" psx_dispatch(cpu, cpu->gpr[{}]);\n", static_cast(rs)); + if (rd == 31) { + out += fmt::format(" if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return; }}\n", + return_addr); + } else { + /* Return register isn't $ra: only propagate an active bail. */ + out += " if (g_psx_call_bail) return; (void)_csp; }\n"; + } out += fmt::format(" cpu->pc = 0x{:08X}u; return;\n", return_addr); } } @@ -652,6 +663,7 @@ bool FullFunctionEmitter::emit_function( } else if (kind == "jal") { uint32_t target = pb.target; uint32_t return_addr = relocate_ra(pb.terminator_addr + 8); + out += " { uint32_t _csp = cpu->gpr[29];\n"; out += fmt::format(" cpu->gpr[31] = 0x{:08X}u;\n", return_addr); // Regular call: always go through psx_dispatch (handles tail-call loop). if (target == 0x00006380u) { @@ -663,6 +675,8 @@ bool FullFunctionEmitter::emit_function( out += fmt::format(" debug_server_log_probe(0x{:08X}u, cpu);\n", pb.terminator_addr + 1); } + out += fmt::format(" if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return; }}\n", + return_addr); // Safety net: if continuation falls outside this function, tail-call to it. if (!addr_to_raw.count(pb.terminator_addr + 8)) { out += fmt::format(" psx_dispatch(cpu, 0x{:08X}u); /* jal cont: outside func */\n", return_addr); @@ -683,12 +697,20 @@ bool FullFunctionEmitter::emit_function( uint8_t rs = (pb.raw >> 21) & 0x1F; uint8_t rd = (pb.raw >> 11) & 0x1F; uint32_t return_addr = relocate_ra(pb.terminator_addr + 8); + out += " { uint32_t _csp = cpu->gpr[29];\n"; if (rd != 0) { out += fmt::format(" cpu->gpr[{}] = 0x{:08X}u;\n", static_cast(rd), return_addr); } out += fmt::format(" psx_dispatch(cpu, cpu->gpr[{}]);\n", static_cast(rs)); + if (rd == 31) { + out += fmt::format(" if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return; }}\n", + return_addr); + } else { + /* Return register isn't $ra: only propagate an active bail. */ + out += " if (g_psx_call_bail) return; (void)_csp; }\n"; + } // Safety net: if continuation falls outside this function, tail-call to it. if (!addr_to_raw.count(pb.terminator_addr + 8)) { out += fmt::format(" psx_dispatch(cpu, 0x{:08X}u); /* jalr cont: outside func */\n", return_addr); @@ -1085,6 +1107,10 @@ void FullFunctionEmitter::emit_dispatch( out += " * the outermost dispatch returns, so a nested callee cannot interrupt\n"; out += " * before its generated caller runs the post-call continuation. */\n"; out += " int outermost = (g_psx_dispatch_depth++ == 0);\n"; + out += " /* Call contract (Bug D family): the guest $sp at the call. A C\n"; + out += " * continuation behind this dispatch may only run if the guest\n"; + out += " * actually returns here ($ra == stop_addr) with this $sp. */\n"; + out += " uint32_t sp_at_call = cpu->gpr[29];\n"; out += " for (;;) {\n"; out += " /* Always-on call ring: every iteration counts as a separate\n"; out += " * call (initial entry + each tail-call re-dispatch). a0..a3\n"; @@ -1133,7 +1159,53 @@ void FullFunctionEmitter::emit_dispatch( out += " psx_unknown_dispatch(cpu, addr, phys);\n"; out += " }\n"; out += " }\n"; + out += " if (g_psx_call_bail) {\n"; + out += " /* A nested generated frame began a bail unwind; cpu->pc\n"; + out += " * holds the guest's true target. Resolve here iff the\n"; + out += " * wild flow arrived exactly at this call's contract. */\n"; + out += " if (stop_addr != 0 &&\n"; + out += " ((cpu->pc ^ stop_addr) & 0x1FFFFFFFu) == 0 &&\n"; + out += " cpu->gpr[29] == sp_at_call) {\n"; + out += " g_psx_call_bail = 0;\n"; + out += " g_psx_bail_resolved++;\n"; + out += " cpu->pc = 0;\n"; + out += " --g_psx_dispatch_depth;\n"; + out += " if (outermost) {\n"; + out += " psx_check_interrupts(cpu);\n"; + out += " }\n"; + out += " return;\n"; + out += " }\n"; + out += " if (!outermost) {\n"; + out += " --g_psx_dispatch_depth;\n"; + out += " return; /* propagate to the enclosing call site */\n"; + out += " }\n"; + out += " /* Outermost: flatten — host stack above is clean, keep\n"; + out += " * executing the wild flow as a tail dispatch. */\n"; + out += " g_psx_call_bail = 0;\n"; + out += " g_psx_bail_flattened++;\n"; + out += " addr = cpu->pc;\n"; + out += " continue;\n"; + out += " }\n"; out += " if (cpu->pc == 0) {\n"; + out += " if (stop_addr != 0 &&\n"; + out += " (cpu->gpr[29] != sp_at_call ||\n"; + out += " ((cpu->gpr[31] ^ stop_addr) & 0x1FFFFFFFu) != 0)) {\n"; + out += " /* Callee C-returned but the guest did not return to\n"; + out += " * this call site ($ra holds the wild jr's target):\n"; + out += " * begin the bail unwind instead of resuming the\n"; + out += " * suspended C continuation. */\n"; + out += " g_psx_call_bail = 1;\n"; + out += " g_psx_bail_first++;\n"; + out += " cpu->pc = cpu->gpr[31];\n"; + out += " if (outermost) {\n"; + out += " g_psx_call_bail = 0;\n"; + out += " g_psx_bail_flattened++;\n"; + out += " addr = cpu->pc;\n"; + out += " continue;\n"; + out += " }\n"; + out += " --g_psx_dispatch_depth;\n"; + out += " return;\n"; + out += " }\n"; out += " --g_psx_dispatch_depth;\n"; out += " if (outermost) {\n"; out += " psx_check_interrupts(cpu);\n"; @@ -1141,6 +1213,13 @@ void FullFunctionEmitter::emit_dispatch( out += " return;\n"; out += " }\n"; out += " if (stop_addr != 0 && cpu->pc == stop_addr) {\n"; + out += " if (cpu->gpr[29] != sp_at_call) {\n"; + out += " /* Same address, different frame (recursion or a wild\n"; + out += " * arrival): not this call's return — keep executing\n"; + out += " * via tail dispatch (interior alias route). */\n"; + out += " addr = cpu->pc;\n"; + out += " continue;\n"; + out += " }\n"; out += " cpu->pc = 0;\n"; out += " --g_psx_dispatch_depth;\n"; out += " if (outermost) {\n"; diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index dbcbdfd4..e473508d 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -55,6 +55,81 @@ extern uint32_t gte_read_ctrl(CPUState* cpu, uint8_t reg); extern void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val); extern void gte_write_ctrl(CPUState* cpu, uint8_t reg, uint32_t val); +/* ============================================================================ + * Dispatch call contract (Bug D / wild-return family fix) + * ---------------------------------------------------------------------------- + * Generated C continuations mirror the guest call graph: after `jal F`, the C + * code calls F's C function and then falls into the continuation block. Real + * hardware has only one stack; if F's flow goes wild (returns to an address + * other than the call site's $ra, or with a shifted $sp — e.g. a mid-function + * entry running an epilogue for a frame it never pushed), the hardware simply + * keeps executing wherever the guest jumped, and the interrupted caller's + * code never resumes. Our C model, by contrast, resumes the suspended C + * continuation whenever the C call returns — re-executing tails on a moved + * guest stack (the Bug A/C/D zombie family). + * + * The contract: a C continuation may run ONLY if the guest actually arrived + * at it — i.e. the callee came back with $ra == the call site's return + * address and $sp == the caller's stack pointer at the call. When the + * contract is violated, we begin a "bail" unwind: cpu->pc is set to the + * guest's true target (the wild jr's destination), g_psx_call_bail is set, + * and every generated frame returns immediately without running its + * continuation. The unwind resolves at the first enclosing call site (or + * dispatch loop) whose (return address, sp) contract matches the guest's + * arrival state; if none matches, the outermost dispatch loop clears the + * flag and tail-dispatches the wild target with a clean host stack. + * + * g_psx_call_bail is defined in runtime/src/traps.c. Overlay DLLs share + * the runtime's state through pointers wired by overlay_init (the + * PSX_OVERLAY_DLL_BUILD branch; see overlay_api.h / compile_overlays.py). + * ========================================================================== */ +#ifdef PSX_OVERLAY_DLL_BUILD +extern int *g_psx_call_bail_p; +extern uint64_t *g_psx_bail_first_p; +extern uint64_t *g_psx_bail_resolved_p; +#define g_psx_call_bail (*g_psx_call_bail_p) +#define g_psx_bail_first (*g_psx_bail_first_p) +#define g_psx_bail_resolved (*g_psx_bail_resolved_p) +#else +extern int g_psx_call_bail; +extern uint64_t g_psx_bail_first; /* contract violations detected */ +extern uint64_t g_psx_bail_resolved; /* unwinds resolved at a call site */ +extern uint64_t g_psx_bail_flattened; /* unwinds flattened at outermost */ +extern uint64_t g_psx_bail_anomaly; /* bail flag seen where impossible */ +#endif + +/* Validate a direct call site after the callee's C return. + * Returns 1 if the caller must `return;` immediately (bail in progress), + * 0 if the continuation is valid. site_ra = the call's return address, + * site_sp = guest $sp recorded immediately before the call. */ +static inline int psx_call_contract(CPUState* cpu, uint32_t site_ra, + uint32_t site_sp) { + if (g_psx_call_bail) { + /* An inner frame began a bail unwind. Resolve here iff the guest's + * arrival state matches this site's contract. */ + if (((cpu->pc ^ site_ra) & 0x1FFFFFFFu) == 0 && + cpu->gpr[29] == site_sp) { + g_psx_call_bail = 0; + g_psx_bail_resolved++; + cpu->pc = 0; + return 0; + } + return 1; + } + if (cpu->gpr[29] != site_sp || + ((cpu->gpr[31] ^ site_ra) & 0x1FFFFFFFu) != 0) { + /* First detection: the callee C-returned but the guest did not + * return here. $ra holds the wild jr's true destination (the + * longjmp-return emission sets cpu->pc = $ra before returning, + * which is the same value). */ + g_psx_call_bail = 1; + g_psx_bail_first++; + cpu->pc = cpu->gpr[31]; + return 1; + } + return 0; +} + #ifdef __cplusplus } #endif diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index c158bfa5..6131e4de 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -10,6 +10,15 @@ #include "cpu_state.h" #include +/* ABI version exported by every overlay DLL as `overlay_abi()`. The loader + * rejects (and deletes, so autocompile regenerates) any DLL whose version + * doesn't match — including all pre-versioning DLLs, which lack the export. + * + * v2: dispatch call-contract (Bug D family) — DLL call sites carry (ra, sp) + * contract checks and share the runtime's bail state via the appended + * callback pointers below. */ +#define PSX_OVERLAY_ABI_VERSION 2 + typedef struct { /* Core dispatch: routes call_by_address() and out-of-overlay jal */ void (*dispatch_call)(CPUState *cpu, uint32_t addr, uint32_t ra); @@ -29,6 +38,12 @@ typedef struct { * overlay_init copies the struct by value, so older DLLs built against * the shorter struct simply never read this member. */ void (*psx_restore_state_escape)(void); + /* Call-contract state shared with the runtime (ABI v2; see the contract + * model in cpu_state.h). DLL code reads the bail flag and bumps the + * counters through these pointers. */ + int *call_bail_flag; + uint64_t *bail_first; + uint64_t *bail_resolved; } OverlayCallbacks; #ifdef __cplusplus diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 9d5aad21..7893e033 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -350,6 +350,9 @@ static int dispatch_nonlocal_call(CPUState *cpu, uint32_t target, uint32_t *next_pc_out) { cpu->pc = 0; psx_dispatch_call(cpu, target, return_pc); + /* psx_dispatch_call validated the (return_pc, sp) contract; a bail + * unwind in progress surfaces with cpu->pc = the guest's true target. */ + if (g_psx_call_bail) return 1; if (cpu->pc != 0) return 1; *next_pc_out = return_pc; return 0; @@ -445,10 +448,15 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { cpu->gpr[rd ? rd : 31] = return_pc; cpu->gpr[0] = 0; exec_delay_slot(cpu, pc + 4); + uint32_t site_sp = cpu->gpr[29]; /* call contract: sp at the call */ #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (psx_dispatch_game_compiled(cpu, target)) { + if (g_psx_call_bail) return 1; /* wild unwind: cpu->pc = true target */ if (cpu->pc != 0) return 1; + if (rd == 0 || rd == 31) { + if (psx_call_contract(cpu, return_pc, site_sp)) return 1; + } *next_pc_out = return_pc; return 0; } @@ -462,7 +470,11 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { extern int overlay_loader_call_native(CPUState *cpu, uint32_t addr); cpu->pc = 0; if (overlay_loader_call_native(cpu, target)) { + if (g_psx_call_bail) return 1; if (cpu->pc != 0) return 1; + if (rd == 0 || rd == 31) { + if (psx_call_contract(cpu, return_pc, site_sp)) return 1; + } *next_pc_out = return_pc; return 0; } @@ -582,10 +594,13 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { uint32_t return_pc = pc + 8; cpu->gpr[31] = return_pc; exec_delay_slot(cpu, pc + 4); + uint32_t site_sp = cpu->gpr[29]; /* call contract: sp at the call */ #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (psx_dispatch_game_compiled(cpu, target)) { + if (g_psx_call_bail) return 1; /* wild unwind: cpu->pc = true target */ if (cpu->pc != 0) return 1; + if (psx_call_contract(cpu, return_pc, site_sp)) return 1; *next_pc_out = return_pc; return 0; } @@ -600,7 +615,9 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { extern int overlay_loader_call_native(CPUState *cpu, uint32_t addr); cpu->pc = 0; if (overlay_loader_call_native(cpu, target)) { + if (g_psx_call_bail) return 1; if (cpu->pc != 0) return 1; + if (psx_call_contract(cpu, return_pc, site_sp)) return 1; *next_pc_out = return_pc; return 0; } @@ -989,13 +1006,30 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ g_dirty_ram_insns_run++; insns_executed++; if (transferred) { - uint32_t target = cpu->pc; -#ifdef PSX_HAS_GAME_DISPATCH - if (target != 0 && psx_dispatch_game_compiled(cpu, target)) { + if (g_psx_call_bail) { + /* A bail unwind began inside a surfaced call: stop the interp + * run and hand the wild target (cpu->pc) up to the dispatch + * loop's bail handling. */ g_dirty_ram_blocks_run++; if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; OV_FPLOG_RET1(); } + uint32_t target = cpu->pc; +#ifdef PSX_HAS_GAME_DISPATCH + if (target != 0) { + /* Tail transfer into compiled code: run with pc cleared so a + * plain C return surfaces as a genuine return (pc==0) to the + * dispatch loop's contract checks, instead of leaving the + * stale target in pc (which would re-dispatch — and re-run — + * the same function). */ + cpu->pc = 0; + if (psx_dispatch_game_compiled(cpu, target)) { + g_dirty_ram_blocks_run++; + if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; + OV_FPLOG_RET1(); + } + cpu->pc = target; /* not compiled: restore surfaced target */ + } #endif uint32_t target_phys = target & 0x1FFFFFFFu; if (allow_local_dirty_flow && target != 0 && diff --git a/runtime/src/freeze_heartbeat.c b/runtime/src/freeze_heartbeat.c index 0b99c80c..84066d4a 100644 --- a/runtime/src/freeze_heartbeat.c +++ b/runtime/src/freeze_heartbeat.c @@ -3,6 +3,7 @@ #include "freeze_heartbeat.h" #include "debug_server.h" #include "crash_trace.h" /* g_psx_fatal_reason */ +#include "cpu_state.h" /* g_psx_bail_* call-contract counters */ #include #include @@ -329,6 +330,10 @@ static void freeze_dump_write(long long wall, uint64_t frame, uint64_t cyc, " \"dirty_ram_insns\":%llu,\n" " \"tcp_send_stall_ms\":%llu,\n" " \"tcp_clients_dropped\":%u,\n" + " \"bail_first\":%llu,\n" + " \"bail_resolved\":%llu,\n" + " \"bail_flattened\":%llu,\n" + " \"bail_anomaly\":%llu,\n" " \"present_slow_count\":%u,\n" " \"present_vsync_disabled\":%d,\n" " \"wedge_kind\":%u,\n" @@ -351,6 +356,10 @@ static void freeze_dump_write(long long wall, uint64_t frame, uint64_t cyc, (unsigned long long)g_dirty_ram_insns_run, (unsigned long long)debug_server_get_tcp_stall_ms(), debug_server_get_tcp_drops(), + (unsigned long long)g_psx_bail_first, + (unsigned long long)g_psx_bail_resolved, + (unsigned long long)g_psx_bail_flattened, + (unsigned long long)g_psx_bail_anomaly, g_present_slow_count, g_present_vsync_disabled, wedge_kind, @@ -620,6 +629,10 @@ static void heartbeat_write(void) { " \"dirty_ram_insns\":%llu,\n" " \"tcp_send_stall_ms\":%llu,\n" " \"tcp_clients_dropped\":%u,\n" + " \"bail_first\":%llu,\n" + " \"bail_resolved\":%llu,\n" + " \"bail_flattened\":%llu,\n" + " \"bail_anomaly\":%llu,\n" " \"fatal\":%s%s%s\n" "}\n", s_backend, @@ -646,6 +659,10 @@ static void heartbeat_write(void) { (unsigned long long)g_dirty_ram_insns_run, (unsigned long long)debug_server_get_tcp_stall_ms(), debug_server_get_tcp_drops(), + (unsigned long long)g_psx_bail_first, + (unsigned long long)g_psx_bail_resolved, + (unsigned long long)g_psx_bail_flattened, + (unsigned long long)g_psx_bail_anomaly, g_psx_fatal_reason ? "\"" : "", g_psx_fatal_reason ? g_psx_fatal_reason : "null", g_psx_fatal_reason ? "\"" : ""); diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index a430040b..0f722bd6 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -399,6 +399,14 @@ void psx_check_interrupts(CPUState* cpu) { int prev_pending = g_pending_exception_longjmp; g_exception_owner_fiber = psx_fiber_current(); g_pending_exception_longjmp = 0; + /* A bail unwind can never be in flight at exception entry: bail-mode + * returns skip every block leader, so psx_check_interrupts is never + * reached while g_psx_call_bail is set. If it ever is, count the + * anomaly and clear so the handler dispatch isn't poisoned. */ + if (g_psx_call_bail) { + g_psx_bail_anomaly++; + g_psx_call_bail = 0; + } /* The static BIOS exception handler is not dirty-RAM-interp code. Clear the * interp mode flag across the handler dispatch so events recorded inside it * are tagged STATIC. The restore sits after the loop the longjmp lands in, diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 3db838f1..9a6c2661 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -408,6 +408,11 @@ static void init_callbacks(void) { s_callbacks.psx_unknown_dispatch = psx_unknown_dispatch; s_callbacks.log_call_entry = debug_server_log_call_entry; s_callbacks.psx_restore_state_escape = psx_restore_state_escape; + /* Call-contract state (ABI v2): DLL code shares the runtime's bail + * flag and counters through these pointers. */ + s_callbacks.call_bail_flag = &g_psx_call_bail; + s_callbacks.bail_first = &g_psx_bail_first; + s_callbacks.bail_resolved = &g_psx_bail_resolved; } /* ---- DLL loading and export enumeration -------------------------------- */ @@ -419,6 +424,21 @@ static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll loader_log("LoadLibrary(%s) failed: %lu", dll_path, GetLastError()); return 0; } + /* ABI gate: reject any DLL whose contract ABI doesn't match this + * runtime (pre-versioning DLLs lack the export entirely). Delete the + * stale file so the autocompile path regenerates it with the current + * emitter. */ + typedef int (*AbiFn)(void); + AbiFn abi_fn = (AbiFn)GetProcAddress(h, "overlay_abi"); + int abi = abi_fn ? abi_fn() : 0; + if (abi != PSX_OVERLAY_ABI_VERSION) { + loader_log("ABI mismatch in %s: dll=%d runtime=%d — rejecting and " + "deleting stale cache entry", dll_path, abi, + PSX_OVERLAY_ABI_VERSION); + FreeLibrary(h); + DeleteFileA(dll_path); + return 0; + } typedef void (*InitFn)(const OverlayCallbacks *); InitFn init_fn = (InitFn)GetProcAddress(h, "overlay_init"); if (!init_fn) { @@ -472,6 +492,18 @@ static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll (void)man; (void)man_n; (void)dll; void *h = dlopen(dll_path, RTLD_NOW | RTLD_LOCAL); if (!h) { loader_log("dlopen(%s) failed: %s", dll_path, dlerror()); return 0; } + /* ABI gate (see the _WIN32 branch). */ + typedef int (*AbiFn)(void); + AbiFn abi_fn = (AbiFn)dlsym(h, "overlay_abi"); + int abi = abi_fn ? abi_fn() : 0; + if (abi != PSX_OVERLAY_ABI_VERSION) { + loader_log("ABI mismatch in %s: dll=%d runtime=%d — rejecting and " + "deleting stale cache entry", dll_path, abi, + PSX_OVERLAY_ABI_VERSION); + dlclose(h); + remove(dll_path); + return 0; + } typedef void (*InitFn)(const OverlayCallbacks *); InitFn init_fn = (InitFn)dlsym(h, "overlay_init"); if (!init_fn) { loader_log("no overlay_init in %s", dll_path); dlclose(h); return 0; } diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 7bdc77bd..a059a1d6 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -17,6 +17,13 @@ int psx_get_in_exception(void); void psx_exception_longjmp(void); +/* Dispatch call contract state (see cpu_state.h for the model). */ +int g_psx_call_bail = 0; +uint64_t g_psx_bail_first = 0; +uint64_t g_psx_bail_resolved = 0; +uint64_t g_psx_bail_flattened = 0; +uint64_t g_psx_bail_anomaly = 0; + static void trap_crash(const char* msg) { FILE* cf = fopen("psx_crash.txt", "w"); if (cf) { fprintf(cf, "%s\n", msg); fclose(cf); } diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index d7371f6e..9ed174ed 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -742,12 +742,33 @@ DISPATCH_PREAMBLE = """\ /* ---- Overlay dispatch shim (inserted by compile_overlays.py) ----------- */ static OverlayCallbacks g_cbs; +/* Call-contract state (ABI v2): generated code reads/bumps the runtime's + * bail state through these pointers (cpu_state.h PSX_OVERLAY_DLL_BUILD + * branch). They start at local dummies so a pre-init call can't fault. */ +static int s_bail_dummy; +static uint64_t s_ctr_dummy0, s_ctr_dummy1; +int *g_psx_call_bail_p = &s_bail_dummy; +uint64_t *g_psx_bail_first_p = &s_ctr_dummy0; +uint64_t *g_psx_bail_resolved_p = &s_ctr_dummy1; + #ifdef _WIN32 __declspec(dllexport) #else __attribute__((visibility("default"))) #endif -void overlay_init(const OverlayCallbacks *cbs) { g_cbs = *cbs; } +int overlay_abi(void) { return PSX_OVERLAY_ABI_VERSION; } + +#ifdef _WIN32 +__declspec(dllexport) +#else +__attribute__((visibility("default"))) +#endif +void overlay_init(const OverlayCallbacks *cbs) { + g_cbs = *cbs; + if (g_cbs.call_bail_flag) g_psx_call_bail_p = g_cbs.call_bail_flag; + if (g_cbs.bail_first) g_psx_bail_first_p = g_cbs.bail_first; + if (g_cbs.bail_resolved) g_psx_bail_resolved_p = g_cbs.bail_resolved; +} void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t ra) { (void)ra; @@ -1010,6 +1031,7 @@ def compile_dll(c_path: str, out_dll: str, include_dirs: list[str], pic_flag = [] if platform.system() == 'Windows' else ['-fPIC'] cmd = [ gcc, '-shared', *pic_flag, '-O2', + '-DPSX_OVERLAY_DLL_BUILD', c_path, '-o', out_dll, *includes,