Files
psxrecomp/runtime/src/interrupts.c
T
Matthew Stanley 4c39958226 dispatch: Class-A shell-window fix covers OVERLAY code — Tomba2 Whoopee-logo wedge
Root cause of the fresh-launch logo freeze (present at every commit in the
stack; window-capture-verified 13/13 repros, never a regression): the BIOS
dispatch normalize() maps RAM phys 0x30000-0x5AFFF to the shell ROM alias.
The Class-A fix routed post-game-start shell-window targets to the game/dirty
path first, but its gate also required psx_game_address_in_text() — which
exempts runtime-loaded OVERLAY code above the boot-EXE text end. Tomba2's
text ends at 0x38800; its post-logo main-thread resume PC 0x50B80 fell
through to normalize() and executed func_1FC38B80, a stale shell-image
data-as-code self-loop, starving the main thread forever (one VBlank
delivery per frame at the same EPC, zero main-thread instructions retired;
the game's ISR ran fine, so TCP frame counters kept ticking — the freeze
was invisible to every telemetry-only validation). Even PSX_FORCE_INTERP
missed it: normalize() shadowed the address before the interp path saw it.

Fix: post-game-start, the WHOLE shell-overlap window routes to
dirty_ram_dispatch before normalize() — real hardware executes the RAM
bytes regardless of who loaded them (boot EXE, overlay loader, DMA stream).
Pre-start shell addresses still normalize to the shell as before.

Also:
- interrupts/debug_server: nestgate_* per-guard veto-attribution counters on
  the nested-delivery gate (freeze_check), used to exonerate the IEc/SR
  guards and redirect the hunt from interrupts.c to dispatch.
- tools/window_capture.py: PrintWindow-based SDL-window capture with
  LIVE/FROZEN series verdict — window-liveness ground truth; TCP/VRAM
  screenshots provably lie about presentation (this bug's entire history).

Validated: fresh launch now runs logo -> intro FMV -> title -> attract with
sustained full-frame motion (window-level capture series).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:04:17 -07:00

1116 lines
54 KiB
C

/*
* interrupts.c — v4 interrupt delivery for recompiled BIOS.
*
* Pure hardware simulation. No BIOS state, no HLE, no interpreter.
*
* Since recompiled code runs as native C (no per-instruction stepping),
* interrupt delivery happens at dispatch loop boundaries. The dispatch
* loop calls psx_check_interrupts() after each function returns.
*
* Vblank is fired on a dispatch-count schedule to approximate 60 Hz.
*
* ReturnFromException handling:
* On real hardware, ReturnFromException (B0:0x17 or SYSCALL(3))
* restores the full register context from the TCB and jumps to the
* saved EPC. This effectively "longjmps" out of the exception
* handler, bypassing any remaining chain-walk code.
*
* In our recompiled model the exception handler runs as a nested
* psx_dispatch call. A normal function return would unwind only one
* frame, leaving the chain walker running with corrupted registers.
* We use setjmp/longjmp to model the real hardware behaviour:
* psx_check_interrupts sets a jump point before dispatching the
* handler, and psx_exception_longjmp() (called by the runtime's
* ReturnFromException implementation) longjmps back, unwinding the
* entire handler call tree in one step.
*/
#include "interrupts.h"
#include "sio.h"
#include "timers.h"
#include "gpu.h"
#include "cdrom.h"
#include "dma.h"
#include "cpu_state.h"
#include "debug_server.h"
#include "event_ring.h"
#include "lockstep.h"
#include "psx_cycles.h"
#include <stdio.h>
#include <stdlib.h>
#include <setjmp.h>
/* Event-timeline ring: execution-mode flag owned by dirty_ram_interp.c. The
* static BIOS exception handler is NOT interp code, so we clear it around the
* handler dispatch (see psx_check_interrupts). */
extern int g_dirty_interp_active;
extern uint32_t g_dirty_safe_resume_pc;
/* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt; dumped via `irqctx_ring`). */
#define IRQCTX_RING_CAP 64u
typedef struct {
uint64_t seq, cycle;
uint32_t frame, istat, imask, sr, d44, cdrom_active, is_vblank;
int dma_depth;
/* Exception-EXIT half (Tomba menu IRQ-resume wedge): filled when this
* delivery returns to the interrupted code, so the ring shows for EVERY
* delivery which escape path fired and whether the interrupted GPRs were
* restored. take_pc/real_epc are the entry-side resume selection. */
uint32_t take_pc; /* resume PC selected at entry (0 = boundary/sentinel) */
uint32_t real_epc; /* g_exception_real_epc installed at entry */
uint32_t exit_pc; /* cpu->pc at the restore decision */
uint32_t exit_reason; /* g_exc_escape_reason at exit */
uint32_t same_thread; /* same_thread_resume discriminator result */
uint32_t restored; /* saved_gpr restore fired */
uint32_t v1_exit; /* cpu->gpr[3] at exit before restore decision */
uint32_t v1_saved; /* saved_gpr[3] (interrupted code's v1) */
uint32_t ra_exit; /* cpu->gpr[31] at exit before restore decision */
uint32_t ra_saved; /* saved_gpr[31] */
uint32_t redirects; /* jmp_val==2 RestoreState redirects in this delivery */
} IrqCtxEntry;
IrqCtxEntry g_irqctx_ring[IRQCTX_RING_CAP];
uint64_t g_irqctx_seq = 0;
extern uint64_t psx_get_cycle_count(void);
/* Record an interrupt-delivery decision into the event ring. GATE outcomes are
* edge-suppressed (they repeat every block while blocked); DELIVER is always
* recorded (once per interrupt); the not-pending (idle) case emits nothing but
* still updates the edge key so the next gate/deliver is captured. */
static void irq_record_outcome(uint8_t kind, uint8_t detail, uint32_t aux) {
static uint16_t s_last = 0xFFFFu;
uint16_t key = ((uint16_t)kind << 8) | detail;
int repeat = (key == s_last);
s_last = key;
if (kind == EV_NONE) return; /* idle: update key only */
/* IRQ_DELIVER: aux carries the architectural TAKE-PC (the resume PC), so the
* interrupt-take point can be compared across backends at the SAME guest
* cycle — native takes the IRQ at a basic-block boundary, the dirty-interp
* per-instruction. A take-PC that diverges at identical cycle is the
* take-point-granularity signature (PRINCIPLES "Control Flow Semantics":
* model the interrupt frame explicitly; the class fix is precise event
* slicing). The independent Beetle oracle records the same field. */
if (kind == EV_IRQ_DELIVER) { event_ring_record_aux(EV_IRQ_DELIVER, detail, aux); return; }
if (!repeat) event_ring_record(kind, detail);/* GATE: edge only */
}
/* COP0 register indices */
#define COP0_SR 12
#define COP0_CAUSE 13
#define COP0_EPC 14
/* I_STAT and I_MASK are owned by memory.c */
extern uint32_t i_stat;
extern uint32_t i_mask;
/* Device-event cycle ring (device_trace.c): every hardware IRQ-raise edge is
* recorded here with its guest cycle. */
#include "device_trace.h"
/* Central IRQ-raise choke point. All device sources call this to set their
* I_STAT bit so the device-event ring sees every raise from one place with the
* exact guest cycle. Pure addition over `i_stat |= (1<<bit)` — identical effect
* on i_stat, plus the trace note (no-op unless the ring is armed). */
void psx_irq_raise(uint32_t bit, uint32_t detail)
{
i_stat |= (1u << bit);
device_trace_note(bit, detail);
}
/* Dispatch counter for vblank scheduling. */
#define VBLANK_INTERVAL 50000 /* legacy: dispatch-count fallback (unused for VBlank gating now) */
#define VBLANK_CYCLES 564480u /* 33.8688 MHz / 60 Hz — real PSX NTSC VBlank period */
#define VBLANK_DEFER_STALE_CYCLES (VBLANK_CYCLES * 10ull)
static uint32_t dispatch_count;
static uint64_t total_checks;
static uint32_t cycles_since_vblank; /* incremented by interrupts_advance_cycles */
extern uint64_t g_vblank_raise_count;
/* Reentrancy guard: prevent interrupt handler from triggering interrupts. */
static int in_exception;
static int post_exception_cooldown;
static uint32_t last_sio_seq_seen;
static uint64_t last_sio_progress_cycle;
#ifdef PSX_COSIM
#define COSIM_IRQ_RING_CAP 4096u
typedef struct {
uint64_t seq;
uint64_t cycle;
uint32_t kind;
uint32_t cpu_pc;
uint32_t take_pc;
uint32_t dirty_resume_pc;
uint32_t compiled_resume_pc;
uint32_t epc;
uint32_t sr_before;
uint32_t sr_after;
uint32_t cause;
uint32_t istat;
uint32_t imask;
uint32_t func;
uint32_t block;
uint32_t native;
int32_t cooldown;
int32_t dirty_site;
} CosimIrqEntry;
static CosimIrqEntry s_cosim_irq_ring[COSIM_IRQ_RING_CAP];
static uint64_t s_cosim_irq_seq;
extern int g_cosim_dirty_pump_site;
extern uint32_t g_debug_current_func_addr;
extern uint32_t cosim_last_block(void);
extern uint32_t overlay_loader_get_inprogress(void);
static void cosim_irq_note(CPUState *cpu,
uint32_t kind,
uint32_t take_pc,
uint32_t dirty_resume_pc,
uint32_t compiled_resume_pc,
uint32_t sr_before)
{
CosimIrqEntry *e = &s_cosim_irq_ring[s_cosim_irq_seq & (COSIM_IRQ_RING_CAP - 1u)];
e->seq = s_cosim_irq_seq++;
e->cycle = psx_get_cycle_count();
e->kind = kind;
e->cpu_pc = cpu ? cpu->pc : 0;
e->take_pc = take_pc;
e->dirty_resume_pc = dirty_resume_pc;
e->compiled_resume_pc = compiled_resume_pc;
e->epc = cpu ? cpu->cop0[COP0_EPC] : 0;
e->sr_before = sr_before;
e->sr_after = cpu ? cpu->cop0[COP0_SR] : 0;
e->cause = cpu ? cpu->cop0[COP0_CAUSE] : 0;
e->istat = i_stat;
e->imask = i_mask;
e->func = g_debug_current_func_addr;
e->block = cosim_last_block();
e->native = overlay_loader_get_inprogress();
e->cooldown = post_exception_cooldown;
e->dirty_site = g_cosim_dirty_pump_site;
}
void interrupts_cosim_irq_dump(char *out, int cap)
{
if (!out || cap <= 0) return;
char *p = out;
size_t rem = (size_t)cap;
uint64_t total = s_cosim_irq_seq < COSIM_IRQ_RING_CAP ? s_cosim_irq_seq : COSIM_IRQ_RING_CAP;
uint64_t count = total;
if (count > 16u) count = 16u;
int w = snprintf(p, rem, "irqtrace count %llu",
(unsigned long long)count);
if (w < 0 || (size_t)w >= rem) { out[cap - 1] = 0; return; }
p += w; rem -= (size_t)w;
for (uint64_t i = 0; i < count && rem > 1; i++) {
uint64_t seq = s_cosim_irq_seq - count + i;
CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)];
w = snprintf(p, rem,
" ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d",
(unsigned long long)e->seq,
e->kind,
(unsigned long long)e->cycle,
e->cpu_pc, e->take_pc, e->dirty_resume_pc,
e->compiled_resume_pc, e->epc, e->sr_before,
e->sr_after, e->cause, e->istat, e->imask,
e->func, e->block, e->native,
e->cooldown, e->dirty_site);
if (w < 0 || (size_t)w >= rem) { break; }
p += w; rem -= (size_t)w;
}
w = snprintf(p, rem, " | deliveries");
if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; }
uint64_t emitted = 0;
for (uint64_t scanned = 0; scanned < total && emitted < 16u && rem > 1; scanned++) {
uint64_t seq = s_cosim_irq_seq - 1u - scanned;
CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)];
if (e->kind != 1u) continue;
w = snprintf(p, rem,
" ; seq %llu cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d",
(unsigned long long)e->seq,
(unsigned long long)e->cycle,
e->cpu_pc, e->take_pc, e->dirty_resume_pc,
e->compiled_resume_pc, e->epc, e->sr_before,
e->sr_after, e->cause, e->istat, e->imask,
e->func, e->block, e->native,
e->cooldown, e->dirty_site);
if (w < 0 || (size_t)w >= rem) { break; }
p += w; rem -= (size_t)w;
emitted++;
}
uint64_t latest_delivery = UINT64_MAX;
for (uint64_t scanned = 0; scanned < total; scanned++) {
uint64_t seq = s_cosim_irq_seq - 1u - scanned;
CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)];
if (e->kind == 1u) { latest_delivery = e->seq; break; }
}
if (latest_delivery != UINT64_MAX && rem > 1) {
w = snprintf(p, rem, " | pre_delivery");
if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; }
uint64_t first = (latest_delivery >= 15u) ? (latest_delivery - 15u) : 0u;
for (uint64_t seq = first; seq <= latest_delivery && rem > 1; seq++) {
CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)];
w = snprintf(p, rem,
" ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d",
(unsigned long long)e->seq,
e->kind,
(unsigned long long)e->cycle,
e->cpu_pc, e->take_pc, e->dirty_resume_pc,
e->compiled_resume_pc, e->epc, e->sr_before,
e->sr_after, e->cause, e->istat, e->imask,
e->func, e->block, e->native,
e->cooldown, e->dirty_site);
if (w < 0 || (size_t)w >= rem) { break; }
p += w; rem -= (size_t)w;
}
}
if (rem >= 2) {
p[0] = '\n';
p[1] = 0;
} else if (cap == 1) {
out[0] = 0;
} else {
out[cap - 2] = '\n';
out[cap - 1] = 0;
}
}
#endif
static void note_sio_progress_cycle(void) {
uint32_t cur_sio_seq = sio_get_seq();
if (cur_sio_seq != last_sio_seq_seen) {
last_sio_seq_seen = cur_sio_seq;
last_sio_progress_cycle = psx_get_cycle_count();
}
}
static int should_defer_vblank_for_sio(void) {
if (!sio_card_protocol_active()) return 0;
uint64_t now = psx_get_cycle_count();
uint64_t since_progress = now >= last_sio_progress_cycle
? now - last_sio_progress_cycle
: 0;
return since_progress < VBLANK_DEFER_STALE_CYCLES;
}
static void fire_vblank_edge(void) {
/* Subtract one VBlank period rather than reset to 0 so cycle overshoot
* carries forward. Prevents long-running blocks from rounding multiple
* VBlanks together. */
cycles_since_vblank -= VBLANK_CYCLES;
dispatch_count = 0;
/* DEQUEUE: this VBlank fired. ENQUEUE: next VBlank scheduled one period out. */
event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK,
(uint32_t)psx_get_cycle_count());
event_ring_record_aux(EV_ENQ, (uint8_t)SRC_VBLANK,
(uint32_t)(psx_get_cycle_count() + VBLANK_CYCLES));
psx_irq_raise(IRQ_VBLANK, 0);
g_vblank_raise_count++;
event_ring_record(EV_ISTAT_RAISE, IRQ_VBLANK);
gpu_vblank_tick(); /* Toggle LCF (GPUSTAT bit 31) */
#ifndef PSX_ENABLE_BLOCK_CYCLES
timers_tick(33868); /* ~1 NTSC frame worth of cycles */
cdrom_tick(); /* Process pending CDROM responses */
#endif
}
void interrupts_service_scheduled_events(void) {
note_sio_progress_cycle();
if (in_exception) return;
while (cycles_since_vblank >= VBLANK_CYCLES) {
if (should_defer_vblank_for_sio()) return;
fire_vblank_edge();
}
}
uint32_t interrupts_cycles_to_vblank(void) {
if (cycles_since_vblank >= VBLANK_CYCLES) return 0;
return VBLANK_CYCLES - cycles_since_vblank;
}
void interrupts_advance_cycles(uint32_t cycles) {
cycles_since_vblank += cycles;
interrupts_service_scheduled_events();
}
/* Diagnostic: total times the exception handler was entered (in_exception
* transitioned 0->1). Diff this between two snapshots to measure handler
* dispatch rate. */
static uint64_t exception_entries_total;
/* Diagnostic: psx_check_interrupts called while already in_exception (the
* `return` path at line ~159). Real hardware can't double-fault into the
* same handler; on our model this should stay near zero — non-zero means
* something is calling psx_check_interrupts from inside the recompiled
* exception handler tree. */
static uint64_t exception_reentry_blocks;
/* Nested-exception depth (0 = not in exception). in_exception stays the
* boolean the rest of the runtime reads; this tracks how deep the
* synchronous-handler recursion is so the nested-delivery path (hardware
* IEc-re-enabled semantics, see the gate below) can cap host-stack use. */
static int exception_nest_depth;
/* Nested-delivery gate veto attribution (freeze_check). One counter per guard
* in the refinement gate below; first-true-wins ordering matches the gate. */
uint64_t g_nestgate_depth, g_nestgate_rfepend, g_nestgate_escreason, g_nestgate_iec;
/* After the exception handler returns, suppress the next interrupt delivery
* to give the interrupted code at least one block of execution — matching
* real hardware where at least one instruction runs after RFE before the
* pending interrupt can re-fire. Without this, unhandled interrupts cause
* a livelock: the handler runs, doesn't clear I_STAT, returns, and the
* very next psx_check_interrupts re-enters immediately. */
/* IRQ raise/deliver/ack telemetry (Tomba 2 exception-reentry-storm diagnosis).
* Always-on, surfaced in the freeze heartbeat. Distinguishes:
* raise≈deliver≈ack≈1/frame → healthy
* deliver≫ack → handler runs but doesn't clear i_stat (re-delivered)
* raise≫1/frame → something keeps RE-RAISING the bit (cycle/condition bug)
* g_vblank_ack_count is incremented in memory.c at the I_STAT write that clears
* the VBLANK bit. The others are incremented here. */
uint64_t g_vblank_raise_count = 0; /* bit0 set at the cycle-paced raise site */
uint64_t g_vblank_deliver_count = 0; /* VBLANK delivered to the guest (exception taken) */
uint64_t g_irq_deliver_count = 0; /* ANY hardware interrupt delivered */
extern uint64_t g_vblank_ack_count; /* defined in memory.c */
/* Blocks of guaranteed main-code forward progress imposed after a CLAIMED
* non-SIO interrupt (DMA/VBLANK/timer/...). With cooldown 0 (the prior blanket
* policy, commit 6d2cb65) the block leader at the interrupted PC re-takes the
* exception before the block body executes, so under a fast-disc DMA flood the
* main code is pinned at one PC forever (reentry-storm freeze). A few blocks
* guarantee the interrupted block — and the field loop — advance between
* deliveries. SIO is exempt (card reads need immediate back-to-back IRQs). */
#define CLAIMED_PROGRESS_QUANTUM 8
/* setjmp target for ReturnFromException during handler dispatch.
*
* IMPORTANT: longjmp(exception_jmpbuf, ...) MUST execute on the same
* Windows fiber that called setjmp; otherwise RSP is restored to that
* fiber's stack while the OS still tracks a different fiber as current,
* corrupting fiber state and eventually deadlocking SwitchToFiber.
*
* s_exception_owner_fiber records which fiber called setjmp. If a
* longjmp request originates on a different fiber, the caller must:
* (1) set s_pending_exception_longjmp = code
* (2) SwitchToFiber back to s_exception_owner_fiber
* That fiber's wrapped SwitchToFiber call will observe the flag on
* return and execute the longjmp on the correct stack. */
jmp_buf exception_jmpbuf; /* non-static so traps.c can deferred-longjmp */
/* The fiber that owns the current exception setjmp. A longjmp must run on
* that same fiber/stack, so a non-owner defers by switching back to it
* first (see deferred_exception_longjmp). Used on all platforms now that
* the thread scheduler is fiber-based everywhere. */
void* g_exception_owner_fiber = NULL;
int g_pending_exception_longjmp = 0;
extern int g_psx_dispatch_depth;
/* Set by psx_check_interrupts_at while a compiled block-leader interrupt check
* is in progress. If the delivered handler later RFEs to the sentinel outside
* the synchronous host window, dirty_ram_dispatch can resume at this guest PC
* instead of treating the sentinel as pc=0 termination. */
static uint32_t s_compiled_interrupt_resume_pc = 0;
static uint32_t s_last_interrupt_check_pc = 0;
static uint64_t s_last_interrupt_check_cycle = UINT64_MAX;
static int same_guest_pc(uint32_t a, uint32_t b) {
return (((a ^ b) & 0x1FFFFFFFu) == 0);
}
int psx_get_in_exception(void) { return in_exception; }
/* Co-sim (COSIM_ORACLE.md): fold the GENUINE guest-timing interrupt statics into the
* state hash. Deliberately EXCLUDES total_checks / dispatch_count / post_exception_
* cooldown / s_compiled_interrupt_resume_pc — those are counted in psx_check_interrupts
* CALLS or are backend-internal, so they legitimately differ between the compiled and
* interp backends by call frequency and would be a false first-divergence. The one
* quantity that is a real guest-cycle timing value is cycles_since_vblank (drives the
* VBLANK deadline); in_exception mirrors the architectural handler-active state. */
uint64_t interrupts_cosim_hash(uint64_t h) {
const uint64_t P = 1099511628211ULL;
uint32_t v = cycles_since_vblank;
for (int i = 0; i < 4; i++) { h ^= (uint8_t)(v >> (i*8)); h *= P; }
uint64_t sp = last_sio_progress_cycle;
for (int i = 0; i < 8; i++) { h ^= (uint8_t)(sp >> (i*8)); h *= P; }
h ^= (uint8_t)in_exception; h *= P;
return h;
}
/* Co-sim field dump: expose the exact irqctl-timing fields so a divergence can be
* field-diffed (is it cycles_since_vblank = VBLANK-raise-timing, or in_exception, ...). */
void interrupts_cosim_dump(uint32_t *csv, int *inexc) {
if (csv) *csv = cycles_since_vblank;
if (inexc) *inexc = in_exception;
}
/* ===== Fix B: faithful exception-return escape state (see psx_runtime.h) ===== */
int g_rfe_escape_pending = 0;
int g_exc_escape_reason = PSX_EXC_ESCAPE_NONE;
uint32_t g_exception_real_epc = 0;
extern void psx_exception_longjmp(void);
/* Called by the recompiled `rfe` opcode (after it pops the COP0 SR stack). When we
* are inside the SYNCHRONOUS exception-handler window (in_exception) AND the handler
* is returning to a REAL EPC (not the legacy boundary-sentinel fallback), arm the
* host escape so the trampoline unwinds back to psx_check_interrupts after the jr
* commits cpu->pc = real EPC. On a fiber/thread resume (in_exception==0) this is a
* no-op and the real EPC is dispatched directly — that is how a suspended thread
* resumes at its own PC. */
void psx_rfe_mark_escape(void) {
if (in_exception && g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) {
g_rfe_escape_pending = 1;
g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN;
}
}
/* Called in the dispatch trampoline after a function returns (cpu->pc holds the
* real resume EPC). If an RFE armed the escape inside the synchronous handler,
* longjmp back to psx_check_interrupts (cpu->pc preserved across the longjmp). */
void psx_rfe_escape_check(CPUState* cpu) {
(void)cpu;
/* Escape ONLY when this RFE is the synchronous handler completing on the SAME
* fiber that set up the exception (the owner of exception_jmpbuf). in_exception
* is a single global, so a thread RESUMED on a different fiber (its own real EPC
* was restored and it later RFEs) must NOT longjmp here — that would be a
* cross-fiber jump to a stale jmpbuf (crash). For that case we fall through and
* the trampoline keeps dispatching cpu->pc = the real resume EPC, which is
* exactly how a suspended thread continues at its own PC. */
extern void* psx_fiber_current(void); /* psx_fiber.h is included further down */
if (g_rfe_escape_pending && in_exception &&
psx_fiber_current() == g_exception_owner_fiber) {
g_rfe_escape_pending = 0;
longjmp(exception_jmpbuf, 1); /* same fiber: safe; lands in psx_check_interrupts */
}
}
void psx_get_freeze_diag(uint64_t *out_total_checks,
uint32_t *out_dispatch_count,
int *out_in_exception,
int *out_post_exc_cooldown,
uint64_t *out_exc_entries,
uint64_t *out_exc_reentry_blocks) {
if (out_total_checks) *out_total_checks = total_checks;
if (out_dispatch_count) *out_dispatch_count = dispatch_count;
if (out_in_exception) *out_in_exception = in_exception;
if (out_post_exc_cooldown) *out_post_exc_cooldown = post_exception_cooldown;
if (out_exc_entries) *out_exc_entries = exception_entries_total;
if (out_exc_reentry_blocks) *out_exc_reentry_blocks = exception_reentry_blocks;
}
void interrupts_init(void) {
dispatch_count = 0;
in_exception = 0;
exception_nest_depth = 0;
g_psx_dispatch_depth = 0;
total_checks = 0;
post_exception_cooldown = 0;
exception_entries_total = 0;
exception_reentry_blocks = 0;
cycles_since_vblank = 0;
last_sio_seq_seen = sio_get_seq();
last_sio_progress_cycle = psx_get_cycle_count();
}
/*
* Called by the runtime's ReturnFromException implementation (traps.c)
* when the recompiled BIOS handler or a chain callback invokes
* B0:0x17 or SYSCALL(3) during exception handling.
*
* At this point the caller has already:
* - Restored all GPRs from the TCB save area
* - Done RFE on the saved SR
* - Set cpu->pc = saved EPC
*
* We longjmp back to psx_check_interrupts, which will clear
* in_exception and return, effectively letting the interrupted
* code resume at the saved EPC through normal dispatch.
*/
#include "psx_fiber.h"
/* Defer a longjmp to the fiber that owns the current exception setjmp.
* If we're on the owning fiber already, longjmp immediately. Otherwise
* record the requested code, switch back to the owner, and the post-switch
* check in traps.c psx_change_thread_fiber executes the longjmp on the
* correct stack. */
static void deferred_exception_longjmp(int code) {
if (!g_exception_owner_fiber || psx_fiber_current() == g_exception_owner_fiber) {
longjmp(exception_jmpbuf, code);
}
g_pending_exception_longjmp = code;
psx_fiber_switch(g_exception_owner_fiber);
/* If we end up back here, the owner didn't honor the flag (bug).
* Fall through to direct longjmp as a last resort — even though
* the stack is wrong, the alternative is hanging silently. */
longjmp(exception_jmpbuf, code);
}
void psx_exception_longjmp(void) {
debug_server_log_restore_event(2, debug_cpu_ptr ? debug_cpu_ptr->pc : 0, 1);
deferred_exception_longjmp(1);
}
void psx_restore_state_escape(void) {
if (in_exception) {
debug_server_log_restore_event(1, debug_cpu_ptr ? debug_cpu_ptr->pc : 0, 2);
deferred_exception_longjmp(2);
}
/* Not in exception context — return normally, let caller's `return;` handle it. */
}
/* Cycle-budgeted precise event slicing — single source of truth.
*
* Returns the minimum guest-CPU-cycle distance to the next DELIVERABLE hardware
* interrupt: a source raises its I_STAT bit AND that bit is unmasked in i_mask
* (CPU-side SR/IE gating is checked separately at take time). UINT32_MAX means
* no maskable hardware event is currently scheduled.
*
* The two-tier block executor uses this at each block leader: if a whole block
* fits inside this distance (and the block has no IRQ-visibility side effects),
* it runs as fast compiled C; otherwise it runs through the per-instruction
* interpreter so the interrupt is taken at its exact architectural cycle (see
* PRECISE_IRQ_SLICE.md). Each peripheral owns its own next-IRQ query; this just
* takes the min. Every query is a conservative UNDER-estimate (smaller distance
* => slice more => never run past an event), so this is too. */
uint32_t cycles_to_next_event(void) {
uint32_t best = 0xFFFFFFFFu;
/* VBLANK is paced here (interrupts.c owns cycles_since_vblank). The deferred
* card-SIO case only pushes VBlank LATER, so this estimate stays a safe
* under-estimate. */
if (i_mask & (1u << IRQ_VBLANK)) {
uint32_t d = (cycles_since_vblank >= VBLANK_CYCLES)
? 0u : (VBLANK_CYCLES - cycles_since_vblank);
if (d < best) best = d;
}
uint32_t t = timers_cycles_to_irq(i_mask); if (t < best) best = t;
uint32_t c = cdrom_cycles_to_irq(i_mask); if (c < best) best = c;
uint32_t d = dma_cycles_to_irq(i_mask); if (d < best) best = d;
uint32_t s = sio_cycles_to_irq(i_mask); if (s < best) best = s;
return best;
}
void psx_check_interrupts(CPUState* cpu) {
extern int g_ls_suppress_record;
#define PSX_CHECK_INTERRUPTS_RETURN() do { if (g_ls_suppress_record > 0) g_ls_suppress_record--; return; } while (0)
#ifdef PSX_COSIM
extern uint32_t g_dirty_safe_resume_pc;
#define COSIM_IRQ_TAKE_PC() (g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc)
#define COSIM_IRQ_NOTE(kind_) cosim_irq_note(cpu, (kind_), COSIM_IRQ_TAKE_PC(), g_dirty_safe_resume_pc, s_compiled_interrupt_resume_pc, cpu->cop0[COP0_SR])
#endif
{
uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc
: s_compiled_interrupt_resume_pc;
s_last_interrupt_check_pc = check_pc;
s_last_interrupt_check_cycle = psx_get_cycle_count();
}
g_ls_suppress_record++;
total_checks++;
if ((total_checks & 0x3FFFu) == 0) {
debug_server_poll();
}
/* SIO delayed IRQ delivery removed from here.
* sio_tick() is now called only from SIO register accesses
* (sio_read/sio_write) and I_STAT reads (memory.c). The BIOS
* pad detection sequence clears I_STAT bit 7 then polls I_STAT
* waiting for it to re-appear. If we tick here, the IRQ fires
* during the delay loop BEFORE the clear, and the BIOS never
* sees it. */
interrupts_service_scheduled_events();
/* User save states: this is a block-leader boundary with a known resume PC;
* only act outside the exception handler so a restore's stack-unwind can't
* strand a half-finished handler. Near-free when nothing is staged. A load
* longjmps to the scheduler and never returns here. */
if (!in_exception) {
extern void savestate_poll(CPUState* cpu, uint32_t resume_pc);
savestate_poll(cpu, s_last_interrupt_check_pc);
}
/* Dispatch-loop maintenance only when NOT inside the exception handler. */
if (!in_exception) {
#if SIO_MODEL_CYCLE_PACED
/* Builds without block-cycle accounting need a small dispatch-loop
* SIO quantum. With PSX_ENABLE_BLOCK_CYCLES, psx_advance_cycles()
* already drives SIO timing, so the fixed quantum would double-count.
* arms shift/ack), so this gate never opens — per-call cost on
* this hot path is one volatile load + one branch. */
#ifndef PSX_ENABLE_BLOCK_CYCLES
if (g_sio_timing_active) {
sio_tick_quantum();
}
#endif
#endif
dispatch_count++;
}
/* Event ring: generic i_stat-edge backstop. Catches raises from sites we
* don't instrument precisely (memory.c MMIO acks, SIO, SPU). Bounded by
* actual transitions, not by check frequency. */
{
static uint32_t s_last_istat = 0;
if (i_stat != s_last_istat) {
s_last_istat = i_stat;
event_ring_record(EV_ISTAT_CHANGE, 0);
}
}
/* Check if any interrupts are pending. */
if ((i_stat & i_mask) == 0) { irq_record_outcome(EV_NONE, 0, 0); PSX_CHECK_INTERRUPTS_RETURN(); }
/* Nested delivery (hardware semantics). Real R3000A has no 'in exception'
* gate — delivery is governed by SR alone. The handler normally runs with
* IEc=0 (hardware bit-shift on entry), so the SR gates below block
* re-entry faithfully on their own. A game that RE-ENABLES IEc inside the
* handler is asking for a nested interrupt and real hardware delivers it:
* Tomba2's lava attract demo parks in the kernel event-poll INSIDE the
* VBlank handler waiting for the NEXT VBlank — the old unconditional
* in_exception block starved it to ~1 fps (VBlank pending+unmasked,
* ~1100 reentry blocks/s, 1 delivery/s). The synchronous-handler
* machinery below is nesting-safe: the interrupted-context save
* (saved_gpr/hi/lo) and owner state (prev_owner_fiber, prev_pending,
* dispatch depth) are host-stack locals restored on unwind, and
* in_exception is restored to its pre-delivery value at the epilogue.
* Depth cap: a pathological IEc-re-enable loop would recurse the host
* stack; real hardware would wedge the guest instead — we stop nesting
* there (counted) so the rings expose it. */
if (in_exception) {
/* Refinements over plain SR gating (first attempt wedged BOOT at
* f436): (1) never nest while an RFE/escape unwind is in flight —
* the guest's RFE pops SR (IEc back to 1) BEFORE the host escape
* completes, and a delivery in that window lands in half-unwound
* machinery; (2) one nesting level only — enough for the
* wait-inside-handler idiom, no host-stack pyramids. */
if (exception_nest_depth >= 2 ||
g_rfe_escape_pending ||
g_exc_escape_reason != PSX_EXC_ESCAPE_NONE ||
!(cpu->cop0[COP0_SR] & 0x01)) {
/* Per-guard veto attribution (always-on counters, freeze_check):
* which refinement is actually vetoing nested delivery. A wedge
* where ONE guard dominates for thousands of frames names the
* false veto (Tomba2 Whoopee-logo forensics 2026-07-03). */
if (exception_nest_depth >= 2) g_nestgate_depth++;
else if (g_rfe_escape_pending) g_nestgate_rfepend++;
else if (g_exc_escape_reason != PSX_EXC_ESCAPE_NONE) g_nestgate_escreason++;
else g_nestgate_iec++;
exception_reentry_blocks++;
irq_record_outcome(EV_IRQ_GATE, GATE_IN_EXCEPTION, 0);
#ifdef PSX_COSIM
COSIM_IRQ_NOTE(2u);
#endif
PSX_CHECK_INTERRUPTS_RETURN();
}
/* IEc re-enabled inside the handler: fall through to the SR gates
* and deliver a nested exception, as hardware would. */
}
/* Post-exception cooldown: let at least one block execute after RFE. */
if (post_exception_cooldown > 0) {
post_exception_cooldown--;
irq_record_outcome(EV_IRQ_GATE, GATE_COOLDOWN, 0);
#ifdef PSX_COSIM
COSIM_IRQ_NOTE(3u);
#endif
PSX_CHECK_INTERRUPTS_RETURN();
}
/* Check COP0 SR: IEc (bit 0) must be set, and IM2 (bit 10) must be set. */
uint32_t sr = cpu->cop0[COP0_SR];
if (!(sr & 0x01)) {
irq_record_outcome(EV_IRQ_GATE, GATE_SR_IE, 0);
#ifdef PSX_COSIM
COSIM_IRQ_NOTE(4u);
#endif
PSX_CHECK_INTERRUPTS_RETURN();
} /* Interrupts globally disabled */
if (!(sr & (1 << 10))) {
irq_record_outcome(EV_IRQ_GATE, GATE_SR_IM2, 0);
#ifdef PSX_COSIM
COSIM_IRQ_NOTE(5u);
#endif
PSX_CHECK_INTERRUPTS_RETURN();
} /* Hardware interrupt bit not enabled */
/* Architectural take-PC = the resume PC (same selection the async-RFE block
* below uses): the dirty-interp commits the exact interrupted instruction in
* g_dirty_safe_resume_pc; compiled code passes its block-entry PC via
* psx_check_interrupts_at -> s_compiled_interrupt_resume_pc. Combined with the
* event's `mode`, this is the take-point for the cross-backend diff. */
{
extern uint32_t g_dirty_safe_resume_pc;
uint32_t take_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc
: s_compiled_interrupt_resume_pc;
irq_record_outcome(EV_IRQ_DELIVER, 0, take_pc);
}
g_irq_deliver_count++;
if ((i_stat & i_mask) & (1u << IRQ_VBLANK)) g_vblank_deliver_count++;
/* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt). Capture, at every IRQ
* delivery, whether the kernel VSync callback-block word at 0x80079D44 is the
* clobbered game value AND whether a CD DMA (ch3) is mid-transfer / a DMA is
* executing. If VBlank gets delivered with d44 already==0x016F0110 while the CD
* DMA is active, that is ChatGPT's class (c)+(#2): IRQ delivered in the DMA
* clobber window because the CPU isn't stalled / the event wasn't torn down. */
{
extern int dma_cdrom_transfer_active(void);
extern int g_dma_exec_depth;
extern uint64_t s_frame_count;
g_irqctx_ring[g_irqctx_seq & (IRQCTX_RING_CAP - 1u)] = (IrqCtxEntry){
.seq = g_irqctx_seq, .cycle = psx_get_cycle_count(),
.frame = (uint32_t)s_frame_count, .istat = i_stat, .imask = i_mask,
.sr = cpu->cop0[COP0_SR], .d44 = cpu->read_word(0x80079D44u),
.cdrom_active = (uint32_t)dma_cdrom_transfer_active(),
.dma_depth = g_dma_exec_depth,
.is_vblank = (uint32_t)(((i_stat & i_mask) & (1u << IRQ_VBLANK)) != 0),
};
g_irqctx_seq++;
}
ls_note_exception_entry();
/* Nested delivery: remember whether we interrupted an OUTER handler so
* the epilogue restores in_exception to the pre-delivery value instead
* of clearing it (a nested level-2 exit must leave level-1 flagged). */
int prev_in_exception = in_exception;
in_exception = 1;
exception_nest_depth++;
exception_entries_total++;
uint32_t pre_handler_istat = i_stat; /* snapshot for cooldown decision */
/* Set COP0 Cause: ExcCode=0 (interrupt), IP2 pending. */
cpu->cop0[COP0_CAUSE] = (cpu->cop0[COP0_CAUSE] & ~0x7C) | (0 << 2);
cpu->cop0[COP0_CAUSE] |= (1 << 10);
/* Push SR exception stack: shift bits [5:0] left by 2. */
cpu->cop0[COP0_SR] = (sr & ~0x3F) | ((sr & 0x0F) << 2);
/* EPC: set to a sentinel value. The recompiled exception handler reads
* memory at [EPC] to check for COP2 branch delay. We use a dedicated
* address in the kernel scratch area. Address 0x80000048 is chosen
* because it's between the exception vectors (0x80-0xBF) and the
* kernel data pointer area (0x100+), and not used by the BIOS.
*
* The sentinel is the HOST escape token for the SYNCHRONOUS handler: when the
* recompiled handler RFEs to it while in_exception, psx_unknown_dispatch /
* dirty_ram_dispatch longjmp back here and resume the interrupted code via the
* host-GPR-restore below. This is unchanged (Tomba 1 / MMX6 / Ape rely on it). */
/* Fix B: COP0.EPC = the REAL interrupted resume PC, NOT a sentinel. The recompiled
* BIOS exception handler saves COP0.EPC into the interrupted thread's TCB EPC slot,
* so a thread suspended here (ChangeThread) resumes at its OWN real PC — never via a
* single global that the next thread's IRQs overwrite (the MMX6 cooperative-thread
* freeze). The host escape out of the nested synchronous handler is keyed on the
* RFE-pending flag (psx_rfe_mark_escape / psx_rfe_escape_check), not on pc==sentinel.
*
* real_pc = the committed interrupted PC, latched by the delivery site (the dirty
* pump exposes g_dirty_safe_resume_pc; a compiled block-leader check exposes
* s_compiled_interrupt_resume_pc). When BOTH are 0 the IRQ was taken at a clean
* trampoline boundary (the interrupted function already returned, pc was 0) — there
* is no mid-function resume PC, so fall back to the legacy sentinel + saved_gpr path
* for THIS delivery only (reason = LEGACY_SENTINEL gates the saved_gpr restore and
* disarms the RFE-flag escape). */
g_rfe_escape_pending = 0;
{
uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc
: s_compiled_interrupt_resume_pc;
if (real_pc != 0u && (real_pc & 0x3u) == 0u &&
(real_pc & 0x1FFFFFFFu) < 0x00200000u) {
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 */
} else {
uint32_t sentinel = PSX_EXC_SENTINEL_PC;
cpu->write_word(sentinel, 0x00000000u); /* NOP, read by the handler's BD check */
cpu->cop0[COP0_EPC] = sentinel;
g_exception_real_epc = sentinel;
g_exc_escape_reason = PSX_EXC_ESCAPE_LEGACY_SENTINEL;
}
#ifdef PSX_COSIM
cosim_irq_note(cpu, 1u, real_pc, g_dirty_safe_resume_pc,
s_compiled_interrupt_resume_pc, sr);
#endif
/* Ring exit-half init: record the entry-side resume selection now; the
* exit fields are filled at the restore decision below. No nesting is
* possible between here and there (in_exception blocks re-entry), so
* (g_irqctx_seq - 1) is this delivery's entry. */
{
IrqCtxEntry *e = &g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)];
e->take_pc = real_pc;
e->real_epc = g_exception_real_epc;
e->exit_pc = 0; e->exit_reason = 0; e->same_thread = 0;
e->restored = 0; e->v1_exit = 0; e->v1_saved = 0;
e->ra_exit = 0; e->ra_saved = 0; e->redirects = 0;
}
}
/* Save the interrupted code's full register state.
*
* On real hardware, the exception handler saves all GPRs to the
* TCB save area at entry, and ReturnFromException restores them
* before jumping back to EPC. The interrupted code always gets
* its exact pre-exception register values back.
*
* In our model, the recompiled handler runs as a C function and
* its `return;` goes back here — not to EPC. The handler's
* normal exit path (0xBFC10944) restores registers from the
* kernel jmpbuf (intended for longjmp to WaitEvent's caller),
* which corrupts the interrupted code's registers.
*
* We save all GPRs/HI/LO before the handler and restore them
* after, so the interrupted code resumes with its original state
* — matching real hardware behaviour. */
uint32_t saved_gpr[32];
uint32_t saved_hi, saved_lo;
for (int i = 0; i < 32; i++) saved_gpr[i] = cpu->gpr[i];
saved_hi = cpu->hi;
saved_lo = cpu->lo;
/* Same-thread discriminator input (mmx6_card_load_regression_state): the
* kernel's CURRENT-TCB pointer at exception ENTRY. PCB ptr lives at kernel
* 0x108; PCB[0] = running thread's TCB. ChangeThread inside the handler
* moves PCB[0] to the new thread, so entry-vs-exit TCB equality is the
* STRUCTURAL "no thread switch happened" test — unlike PC equality, it is
* immune to two threads parked at the SAME guest PC (shared spin loops:
* MMX6's card poll), where the PC heuristic force-restores the OLD thread's
* GPRs into the NEW thread. */
extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */
uint32_t entry_pcb = psx_read_word(0x108u);
uint32_t entry_tcb = entry_pcb ? psx_read_word(entry_pcb & 0x1FFFFFFFu) : 0u;
/* Dispatch the BIOS exception handler.
* BEV (SR bit 22) selects between 0x80000080 and 0xBFC00180.
*
* setjmp is placed here so ReturnFromException (longjmp code 1)
* and RestoreState (longjmp code 2) can escape the handler call
* tree.
*
* The loop handles the PSX VSync mechanism (SaveState/RestoreState):
* - Code 0: normal entry — dispatch the handler.
* - Code 2: RestoreState redirect — re-dispatch to cpu->pc
* (e.g. VSync callback loop at 0xBFC421D8), still in exception
* context. The redirected code eventually calls ReturnFromException.
* - Code 1: ReturnFromException — exit the loop entirely. */
uint32_t target_pc;
if (sr & 0x00400000u) {
target_pc = 0xBFC00180u;
} else {
uint32_t w0 = cpu->read_word(0x80000080u);
uint32_t w1 = cpu->read_word(0x80000084u);
uint32_t hi_val = (w0 & 0xFFFF) << 16;
int16_t lo_val = (int16_t)(w1 & 0xFFFF);
target_pc = hi_val + (uint32_t)(int32_t)lo_val;
}
/* Record which fiber owns this setjmp. Any subsequent longjmp must
* happen on this same fiber; if a non-owner fiber needs to longjmp
* it must switch back here first (see deferred_exception_longjmp). */
void *prev_owner_fiber = g_exception_owner_fiber;
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,
* so the EPC-sentinel longjmp can't leave the flag wrong. */
int prev_interp_active = g_dirty_interp_active;
g_dirty_interp_active = 0;
/* Dispatch-depth contract across the async handler (Tomba 2 splash, post
* overlay-floor fix). The interrupt can be delivered from a NESTED dispatch
* (the local-flow pump runs inside dirty_ram_dispatch at depth > 0). The
* handler must run as its OWN outermost context (so its tail-call trampoline
* flattens), and a ReturnFromException longjmp skips the handler frames'
* `--g_psx_dispatch_depth` decrements — so we cannot leave the counter at 0.
* Save the interrupted code's nesting here and RESTORE it after the handler;
* otherwise the outer frames unwind below zero (dispatch_depth -> -1), the
* `outermost` test misfires, and the top-level dispatch returns to PC=0
* ("execution completed" abnormal exit). */
int saved_dispatch_depth = g_psx_dispatch_depth;
g_psx_dispatch_depth = 0;
for (;;) {
int jmp_val = setjmp(exception_jmpbuf);
if (jmp_val == 2) {
/* RestoreState redirect: re-dispatch to cpu->pc.
* GPRs were already set by RestoreState — do NOT restore.
* Stay in exception context so ReturnFromException works. */
g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)].redirects++;
g_psx_dispatch_depth = 0;
debug_server_log_restore_event(3, cpu->pc, (uint32_t)jmp_val);
target_pc = cpu->pc;
continue;
}
if (jmp_val == 1) {
g_psx_dispatch_depth = 0;
debug_server_log_restore_event(4, cpu->pc, (uint32_t)jmp_val);
}
if (jmp_val == 0) {
/* Normal entry (or after RestoreState redirect): dispatch. */
psx_dispatch(cpu, target_pc);
}
/* jmp_val 0 (normal return) or 1 (ReturnFromException): done. */
break;
}
/* Restore previous exception-owner state. Supports nested exceptions
* if they ever arise (uncommon but harmless). */
g_exception_owner_fiber = prev_owner_fiber;
g_pending_exception_longjmp = prev_pending;
g_dirty_interp_active = prev_interp_active;
/* Restore the interrupted code's dispatch nesting (see save above). The
* handler's frames were unwound by longjmp without decrementing, so the
* live counter is meaningless here — overwrite, don't decrement. */
g_psx_dispatch_depth = saved_dispatch_depth;
/* Restore the interrupted code's registers.
*
* The handler has done its work (acknowledged i_stat, delivered
* events, etc.) via MMIO writes and RAM writes — those side
* effects are in memory. We restore GPRs so the interrupted
* code continues with its pre-exception state.
*
* For SR: if the handler did ReturnFromException (RFE already
* applied, IEc restored), we keep that. If the handler exited
* normally (jmpbuf path, no RFE), IEc is still 0 from the
* exception push — we do RFE to pop the SR stack.
*
* Fix B: only restore saved_gpr on the LEGACY (jmpbuf / boundary-sentinel)
* exit. On a real RFE/ReturnFromException the recompiled BIOS handler already
* restored the RESUMED thread's GPRs from its TCB; overwriting them with our
* saved_gpr would clobber the resumed thread with the state of the thread that
* ENTERED the exception (the cross-thread corruption fix B exists to prevent). */
/* Fix B refinement (2026-07-01, Tomba pause-menu wedge): restore the
* interrupted GPRs whenever this resume returns to the SAME thread it
* interrupted — either the LEGACY sentinel exit, OR a real-EPC RFE that
* resumes at exactly the PC we installed as EPC (no ChangeThread happened).
*
* Real hardware saves and restores ALL GPRs across every exception, so a
* transparent same-thread interrupt must give the interrupted code its exact
* pre-exception registers back. The original Fix B skipped this for all
* real-EPC resumes, trusting the recompiled BIOS handler's TCB restore — but
* for a same-thread compiled resume that restore is incomplete, so a live
* value held in a register across a block-leader IRQ check gets clobbered by
* the handler. Concretely: Tomba's pause-menu frame-wait spin holds its loop
* bound (256) in gpr[3] across 0x80016588; a VBLANK there returned gpr[3]=1,
* so the wait exited early every frame and the menu never opened.
*
* A GENUINE ChangeThread resumes a DIFFERENT thread at its own PC (!= our
* EPC); there the BIOS handler restored the NEW thread's GPRs from its TCB
* and saved_gpr holds the thread that ENTERED the exception — restoring it
* would reintroduce the MMX6 cooperative-thread corruption Fix B prevents. */
/* Discriminator selector (verification + candidate fix,
* mmx6_card_load_regression_state — the PC heuristic below was BISECTED as
* the "A game data could not be found" regression B on MMX6 while being
* the fix for Tomba's pause menu):
* PSX_SAME_THREAD_RESTORE=0 original Fix B (legacy-sentinel-only restore)
* PSX_SAME_THREAD_RESTORE=1 PC-equality heuristic (13c5e0c behavior)
* PSX_SAME_THREAD_RESTORE=2 kernel current-TCB equality (default;
* structural same-thread test — a genuine
* ChangeThread moves PCB[0] even when the new
* thread resumes at the SAME guest PC) */
static int s_str_mode = -1;
if (s_str_mode < 0) {
const char *e = getenv("PSX_SAME_THREAD_RESTORE");
s_str_mode = (e && *e) ? atoi(e) : 1; /* default = 13c5e0c behavior;
mode 2 (TCB check) is the hardening candidate, pending a Tomba
pause-menu gate. The MMX6 "not found" this selector was built to
verify turned out to be a STALE GENERATED IMAGE artifact. */
if (s_str_mode < 0 || s_str_mode > 2) s_str_mode = 1;
}
extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */
uint32_t exit_pcb = psx_read_word(0x108u);
uint32_t exit_tcb = exit_pcb ? psx_read_word(exit_pcb & 0x1FFFFFFFu) : 0u;
int same_thread_resume = 0;
if (s_str_mode == 1) {
same_thread_resume =
(g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) &&
g_exception_real_epc != 0u &&
same_guest_pc(cpu->pc, g_exception_real_epc);
} else if (s_str_mode == 2) {
same_thread_resume =
(g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) &&
g_exception_real_epc != 0u &&
same_guest_pc(cpu->pc, g_exception_real_epc) &&
entry_tcb != 0u && entry_tcb == exit_tcb;
}
int do_restore =
(g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL || same_thread_resume);
/* Ring exit-half: every delivery records which escape path it took and
* whether the interrupted GPRs were restored (Tomba menu wedge evidence). */
{
IrqCtxEntry *e = &g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)];
e->exit_pc = cpu->pc;
e->exit_reason = (uint32_t)g_exc_escape_reason;
e->same_thread = (uint32_t)same_thread_resume;
e->restored = (uint32_t)do_restore;
e->v1_exit = cpu->gpr[3];
e->v1_saved = saved_gpr[3];
e->ra_exit = cpu->gpr[31];
e->ra_saved = saved_gpr[31];
}
if (do_restore) {
for (int i = 0; i < 32; i++) cpu->gpr[i] = saved_gpr[i];
cpu->hi = saved_hi;
cpu->lo = saved_lo;
}
g_rfe_escape_pending = 0;
g_exc_escape_reason = PSX_EXC_ESCAPE_NONE;
if (!(cpu->cop0[COP0_SR] & 0x01)) {
uint32_t sr2 = cpu->cop0[COP0_SR];
cpu->cop0[COP0_SR] = (sr2 & 0xFFFFFFC0u) | ((sr2 >> 2) & 0x0Fu);
}
/* Nested delivery: a level-2 exit must leave level-1 flagged. */
in_exception = prev_in_exception;
if (exception_nest_depth > 0) exception_nest_depth--;
/* Adaptive cooldown: if the handler acknowledged the interrupt (cleared
* some I_STAT bits), the interrupt won't immediately re-fire and we need
* no cooldown. If I_STAT is unchanged (no handler claimed the interrupt),
* give the main code a generous window to make progress — e.g. to let
* the shell finish installing handlers. On real hardware, the CPU
* executes at least one instruction between exceptions; in our model
* each "block" is many instructions, but the handler also consumes
* hundreds of sub-dispatches per invocation. */
if ((i_stat & i_mask) != 0 && i_stat == pre_handler_istat) {
post_exception_cooldown = 500; /* unclaimed: give main code time */
} else {
/* Claimed: the handler acknowledged at least one I_STAT bit. Per-source
* policy (this used to be a blanket cooldown=0 — commit 6d2cb65 — which
* pins main code under a fast-disc DMA flood: the interrupted block's
* leader re-takes the exception before its body runs):
* - SIO (card reads): 128 consecutive SIO IRQs must fire within one
* blocking wait; any gap stalls the card protocol → re-fire now.
* - DMA/VBLANK/timer/etc: guarantee a few blocks of main-code
* progress between deliveries so a flood can't starve the loop. */
uint32_t claimed = pre_handler_istat & ~i_stat; /* bits handler cleared */
uint32_t sio_active = (claimed | (i_stat & i_mask)) & (1u << IRQ_SIO0);
post_exception_cooldown = sio_active ? 0 : CLAIMED_PROGRESS_QUANTUM;
}
if (g_ls_suppress_record > 0) g_ls_suppress_record--;
#ifdef PSX_COSIM
#undef COSIM_IRQ_NOTE
#undef COSIM_IRQ_TAKE_PC
#endif
#undef PSX_CHECK_INTERRUPTS_RETURN
}
/* Compatibility shim: the ape-flavored generated code calls
* psx_check_interrupts_at(cpu, resume_pc); the mmx6-fw baseline runtime delivers
* interrupts via psx_check_interrupts (cpu->pc / scratch sentinel). Forwarding
* here gives the mmx6 baseline interrupt behavior — sufficient to build+run the
* current generated code on the good baseline for instrumented comparison. */
void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc) {
uint32_t prev = s_compiled_interrupt_resume_pc;
s_compiled_interrupt_resume_pc = resume_pc;
psx_check_interrupts(cpu);
s_compiled_interrupt_resume_pc = prev;
}
int psx_interrupts_checked_at_current_cycle(uint32_t resume_pc) {
return s_last_interrupt_check_cycle == psx_get_cycle_count() &&
same_guest_pc(s_last_interrupt_check_pc, resume_pc);
}
void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc) {
if (psx_interrupts_checked_at_current_cycle(resume_pc)) {
return;
}
psx_check_interrupts_at(cpu, resume_pc);
}