recompiler/runtime: honor IRQ redirects at compiled leaders; revive async-RFE latch; pc0 escape journal

Fixes the deterministic top-level "execution completed, PC=0" ~10 s
into WipEout 3 boot with the static bake linked (the 120 Hz core bug).

Root cause (CPS-contract audit, H1): every emitted block-leader IRQ
check was `psx_check_interrupts_at(cpu, X); cpu->pc = X;` — whatever
resume PC the exception epilogue published (guest RFE to a non-EPC
target: longjmp-style card-ISR handlers, SetCustomExitFromException,
thread switch) was unconditionally overwritten by the static transfer.
The dirty interpreter honors these redirects ("Handler resumed
elsewhere — surface to dispatch"); the static bake moved the IRQ
take-points from interp pumps onto compiled leaders, where the redirect
was dropped — the guest's continuation vanished, downstream state was
never established, and a later jr through a null pointer surfaced as a
silent top-level PC=0 exit.

- code_generator/full_function_emitter: both check emitters (and the
  install-slot hook) now emit the compiled analogue of the interp
  contract: `if (cpu->pc != 0 && ((cpu->pc ^ X) & 0x1FFFFFFF)) return;`
  after the check, surfacing the redirect to the trampoline. Applies to
  branch/jr/jalr/jal/split sites in the AOT text, the recompiled BIOS,
  and every overlay shard/static bake on regeneration.
- interrupts.c: the async-RFE resume latch (g_async_rfe_resume_pc,
  "Tomba 2 frame-1997 fix") was documented but NEVER ASSIGNED — the
  whole rescue in the traps/dirty sentinel gates was inert (audit H3).
  Latch it at every exception entry that installs a real EPC.
- pc0 escape journal (always-on, play builds): every runtime site that
  publishes/rescues a null PC records {site, frame, dispatch depth, ra,
  epc, extras} in a 64-entry ring, dumped at the abnormal-exit print.
  The play build's exit trace was blind (fntrace is debug-only:
  fntrace_seq=0 in the death artifact); publishes are rare so the
  always-on cost is a few stores.
- compile_overlays.py: PSX_STATIC_NO_ISOLATED=1 A/B guard to skip the
  isolated-fragment pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tetrisgm
2026-08-19 10:39:34 -07:00
parent ada331a401
commit 6ead85c9da
7 changed files with 102 additions and 6 deletions
+18 -2
View File
@@ -140,18 +140,34 @@ std::string CodeGenerator::emit_mid_block_cycle_charge(uint32_t addr,
return ss.str();
}
/* Both check emitters honor an exception REDIRECT: when the delivery's
* epilogue publishes a resume PC different from this site's static
* continuation (guest RFE to a non-EPC target — longjmp-style handlers,
* SetCustomExitFromException, thread switch), the compiled code must
* surface it to the trampoline instead of overwriting it with the static
* transfer. This is the compiled analogue of the dirty interpreter's
* "Handler resumed elsewhere — surface to dispatch" contract; without it a
* baked/compiled leader silently drops the guest's continuation (WipEout 3
* static bake: deterministic top-level "execution completed, PC=0" ~10 s
* into boot, root-caused to redirects dropped at compiled leaders). A
* published pc equal to the static continuation falls through (same-thread
* restore publishes 0; the depth-guard rescue publishes the site PC). */
std::string CodeGenerator::emit_interrupt_check(uint32_t resume_pc,
const std::string& indent) const {
return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent +
"psx_cyc_bb_defer_flush();\n#endif\n" + indent +
fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc);
fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc) + indent +
fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n",
resume_pc);
}
std::string CodeGenerator::emit_interrupt_check_expr(const std::string& resume_pc_expr,
const std::string& indent) const {
return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent +
"psx_cyc_bb_defer_flush();\n#endif\n" + indent +
fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr);
fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr) + indent +
fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ ({})) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n",
resume_pc_expr);
}
std::string CodeGenerator::reg_name(int reg_num) {
+13 -4
View File
@@ -171,17 +171,25 @@ bool FullFunctionEmitter::emit_function(
const char* e = std::getenv("PSX_CPS");
return e == nullptr || e[0] != '0';
}();
/* Redirect-honoring checks — see CodeGenerator::emit_interrupt_check:
* an exception epilogue that publishes a resume PC different from this
* site's static continuation must surface to the trampoline, never be
* overwritten by the static transfer (the dropped-continuation class
* behind the WipEout 3 static-bake top-level PC=0 exit). */
auto emit_irq_check = [](uint32_t resume_pc, const std::string& indent = " ") {
uint32_t rt = bios_runtime_pc(resume_pc);
return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent +
"psx_cyc_bb_defer_flush();\n#endif\n" + indent +
fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n",
bios_runtime_pc(resume_pc));
fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", rt) + indent +
fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", rt);
};
auto emit_irq_check_expr = [](const std::string& resume_pc_expr,
const std::string& indent = " ") {
return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent +
"psx_cyc_bb_defer_flush();\n#endif\n" + indent +
fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr);
fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr) + indent +
fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ ({})) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n",
resume_pc_expr);
};
auto emit_cosim_instr = [](uint32_t pc, const std::string& indent = " ") {
return "#ifdef PSX_COSIM\n" + indent +
@@ -1202,9 +1210,10 @@ bool FullFunctionEmitter::emit_function(
" * back here as a registered continuation target. */\n"
" if (cpu->read_word(0x{:08X}u) != 0u) {{\n"
" psx_check_interrupts_at(cpu, 0x{:08X}u);\n"
" if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n"
" cpu->pc = 0x{:08X}u; return;\n"
" }}\n",
addr, ram_pc, ram_pc + 0x10u, ram_pc, ram_pc, ram_pc);
addr, ram_pc, ram_pc + 0x10u, ram_pc, ram_pc, ram_pc, ram_pc);
}
// Non-terminator: emit normally — unless this load's successor reads
+5
View File
@@ -2682,6 +2682,9 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
g_sentinel_reach_dirty++;
if (!psx_get_in_exception()) g_sentinel_reach_traps++; /* reuse: in_exc==0 dirty reaches */
g_sentinel_reach_async = g_async_rfe_resume_pc;
psx_pc0_journal_note(PSX_PC0J_DIRTY_SENTINEL, cpu, addr,
(psx_get_in_exception() ? 1u : 0u) |
(g_async_rfe_resume_pc ? 2u : 0u));
cpu->pc = 0;
if (psx_get_in_exception()) {
psx_exception_longjmp(); /* does not return */
@@ -3032,6 +3035,8 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
g_dirty_ram_last_unsupported_pc = g_unsupported_pc;
g_dirty_ram_last_unsupported_insn = g_unsupported_insn;
g_dirty_ram_last_unsupported_reason = g_unsupported_reason;
psx_pc0_journal_note(PSX_PC0J_DIRTY_UNSUPPORTED, cpu,
g_unsupported_pc, g_unsupported_insn);
cpu->pc = 0;
OV_FPLOG_RET1();
}
+16
View File
@@ -1471,6 +1471,8 @@ irq_deliver_eval:
* disarms the RFE-flag escape). */
g_rfe_escape_pending = 0;
{
extern uint32_t g_async_rfe_resume_pc; /* dirty_ram_interp.c */
extern uint64_t g_async_rfe_set_count;
uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc
: s_compiled_interrupt_resume_pc;
/* Top-level flush_resume / savestate: resync cleared the latches and
@@ -1514,6 +1516,15 @@ irq_deliver_eval:
cpu->cop0[COP0_EPC] = real_pc; /* architectural: the real resume PC */
g_exception_real_epc = real_pc;
g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; /* set at the actual RFE/SYSCALL return */
/* Async-RFE latch (Tomba 2 frame-1997 fix): persist the most
* recent real interruption PC so a game-driven asynchronous
* ReturnFromException (sentinel RFE with in_exception==0, e.g. a
* card-ISR longjmp installed via HookEntryInt) resumes the guest
* here instead of resolving to pc=0 (abnormal top-level exit).
* The latch was documented but never assigned — the whole rescue
* in the traps/dirty sentinel gates was inert. */
g_async_rfe_resume_pc = real_pc;
g_async_rfe_set_count++;
} else {
uint32_t sentinel = PSX_EXC_SENTINEL_PC;
cpu->write_word(sentinel, 0x00000000u); /* NOP, read by the handler's BD check */
@@ -1862,7 +1873,12 @@ irq_deliver_eval:
if (resume != 0u)
cpu->pc = resume;
/* else keep post-RFE cpu->pc — never publish 0 */
psx_pc0_journal_note(PSX_PC0J_IRQ_RESCUE, cpu, resume,
g_exc_escape_reason);
} else {
psx_pc0_journal_note(PSX_PC0J_IRQ_SAME_THREAD, cpu,
s_compiled_interrupt_resume_pc,
g_exc_escape_reason);
cpu->pc = 0; /* continue the interrupted live chain */
}
}
+18
View File
@@ -14412,6 +14412,24 @@ session_reboot:
(unsigned long long)g_dirty_ram_insns_run, g_slice_exit_pc, g_slice_exit_reason,
g_slice_exit_iter, g_slice_exit_dispatchable, g_slice_exit_dirty,
g_slice_exit_in_text, g_slice_exit_want); }
/* pc=0 escape journal tail: names the exact runtime site that published
* (or rescued) each recent null PC, with frame/depth/ra/epc context —
* the play-build replacement for the debug-only fntrace ring, which is
* compiled out here (the exit trace JSON was blind: fntrace_seq=0). */
{
uint64_t n = g_pc0j_seq < PSX_PC0J_CAP ? g_pc0j_seq : PSX_PC0J_CAP;
std::fprintf(stdout, "psxrecomp runtime: [pc0 journal] %llu total, last %llu:\n",
(unsigned long long)g_pc0j_seq, (unsigned long long)n);
for (uint64_t i = 0; i < n; i++) {
const Pc0JournalEntry *e =
&g_pc0j_ring[(g_pc0j_seq - n + i) % PSX_PC0J_CAP];
std::fprintf(stdout,
" seq=%llu site=%u frame=%u depth=%d a=0x%08X "
"ra=0x%08X epc=0x%08X d=0x%08X\n",
(unsigned long long)e->seq, e->site, e->frame,
e->depth, e->a, e->b, e->c, e->d);
}
}
shutdown_runtime();
if (g_gl_active) gl_renderer_shutdown();
+28
View File
@@ -240,6 +240,24 @@ enum {
};
uint32_t g_pc0_reason = PSX_PC0_GUEST_RETURN;
/* pc=0 escape journal — see cpu_state.h for the site enum and rationale. */
Pc0JournalEntry g_pc0j_ring[PSX_PC0J_CAP];
uint64_t g_pc0j_seq = 0;
void psx_pc0_journal_note(uint32_t site, CPUState *cpu, uint32_t a, uint32_t d)
{
extern int g_psx_dispatch_depth;
extern uint64_t s_frame_count; /* present frame counter (debug_server.c) */
Pc0JournalEntry *e = &g_pc0j_ring[g_pc0j_seq % PSX_PC0J_CAP];
e->seq = g_pc0j_seq++;
e->site = site;
e->frame = (uint32_t)s_frame_count;
e->depth = g_psx_dispatch_depth;
e->a = a;
e->b = cpu->gpr[31];
e->c = cpu->cop0[14];
e->d = d;
}
static uint32_t psx_current_tcb_ptr(CPUState* cpu)
{
uint32_t tcbh = cpu->read_word(0x00000108u);
@@ -555,6 +573,7 @@ static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb)
if (current_tcb == target_tcb) {
debug_server_log_thread_event(5, cpu, current_tcb, target_tcb, cpu->gpr[31]);
g_pc0_reason = PSX_PC0_CHANGE_SELF;
psx_pc0_journal_note(PSX_PC0J_TRAP_CHANGE_SELF, cpu, target_tcb, 0);
cpu->pc = 0;
return 1;
}
@@ -625,6 +644,7 @@ static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb)
debug_server_log_thread_event(9, cpu, target_tcb, current_tcb, 0);
g_pc0_reason = PSX_PC0_FIBER_SWITCH;
psx_pc0_journal_note(PSX_PC0J_TRAP_FIBER_SWITCH, cpu, 0, 0);
cpu->pc = 0;
return 1;
}
@@ -943,6 +963,7 @@ int psx_syscall(CPUState* cpu, uint32_t code) {
cpu->cop0[12] = sr & ~1u; /* clear IEc (bit 0) */
cpu->gpr[2] = sr & 1u; /* return old IEc */
g_pc0_reason = PSX_PC0_CRIT_SECTION;
psx_pc0_journal_note(PSX_PC0J_TRAP_CRIT_ENTER, cpu, sr, 0);
cpu->pc = 0;
/* Directly handled "void" syscall. Return 0: under CPS the caller
* (`if (psx_syscall(...)) return;`) falls through to the inline
@@ -955,6 +976,7 @@ int psx_syscall(CPUState* cpu, uint32_t code) {
cpu->cop0[12] = sr | 0x0401u; /* set IEc (bit 0) + IM[2] (bit 10) */
cpu->gpr[2] = 0;
g_pc0_reason = PSX_PC0_CRIT_SECTION;
psx_pc0_journal_note(PSX_PC0J_TRAP_CRIT_EXIT, cpu, sr, 0);
cpu->pc = 0;
return 0;
@@ -1106,6 +1128,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) {
* or uninitialized callback slot is dispatched. */
if (addr == 0) {
g_pc0_reason = PSX_PC0_DISPATCH_MISS; /* guest jumped/returned to a null (0) target */
psx_pc0_journal_note(PSX_PC0J_TRAP_NULL_TARGET, cpu, addr, 0);
cpu->pc = 0;
return;
}
@@ -1117,6 +1140,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) {
* the exception handler, longjmp back to psx_check_interrupts to
* properly unwind the handler call tree. */
if (addr == 0x80000048u && psx_get_in_exception()) {
psx_pc0_journal_note(PSX_PC0J_TRAP_SENTINEL, cpu, addr, 1);
cpu->pc = 0;
psx_exception_longjmp(); /* does not return */
}
@@ -1136,6 +1160,8 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) {
g_sentinel_reach_async = g_async_rfe_resume_pc;
if (g_async_rfe_resume_pc != 0u) {
g_async_rfe_fire_count++;
psx_pc0_journal_note(PSX_PC0J_TRAP_ASYNC_RFE, cpu,
g_async_rfe_resume_pc, 0);
cpu->pc = g_async_rfe_resume_pc;
return;
}
@@ -1151,6 +1177,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) {
* the psx_dispatch tail-call loop picking up $ra), it's a no-op — the
* continuation was already handled by the merged function. */
if (phys == 0x00000E10u) {
psx_pc0_journal_note(PSX_PC0J_TRAP_E10_NOOP, cpu, addr, 0);
cpu->pc = 0;
return;
}
@@ -1422,6 +1449,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) {
* ring-recorded above. Stale registers WILL corrupt the caller —
* diagnostic use only. */
g_pc0_reason = PSX_PC0_DISPATCH_MISS;
psx_pc0_journal_note(PSX_PC0J_TRAP_STALE_MISS, cpu, addr, 0);
cpu->pc = 0;
}
}
+4
View File
@@ -6598,6 +6598,10 @@ def main():
# interiors. Compile each as an isolated dispatch-root shard, then give
# every block in those fragments the same universal resume treatment.
unresolved = sorted(static_requested_entries - existing_entries)
# PSX_STATIC_NO_ISOLATED=1: A/B guard — skip the isolated-fragment pass
# entirely (its universal-resume treatment is the newest machinery).
if os.environ.get('PSX_STATIC_NO_ISOLATED'):
unresolved = []
fragment_built = 0
new_fragment_parts = []
for entry in unresolved: