Files
psxrecomp/runtime/src/interrupts.c
T
2026-10-22 14:23:14 -07:00

2255 lines
115 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 "mod_runtime.h"
#include "psx_memory.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 "psx_cycle_freeze.h"
#include "psx_scheduler.h"
#include "psx_video_timing.h"
#include "spu.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.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 4096u
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 v0_exit; /* cpu->gpr[2] at exit before restore decision */
uint32_t v0_saved; /* saved_gpr[2] (interrupted code's v0) */
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 */
/* Ape memcard native<->interp resume-desync probe: the live sp and the
* dirty pump-site at IRQ ENTRY. The interrupted-thread TCB save captures
* exactly this (resume_pc=take_pc, sp=entry_sp); a pair where take_pc is a
* dirty return point but entry_sp belongs to a native callee's frame is the
* corrupt snapshot (0x801384AC paired with 0x801EFE80). */
uint32_t entry_sp; /* cpu->gpr[29] at exception entry */
uint32_t pump_site; /* g_cosim_dirty_pump_site at entry (which delivery path) */
uint64_t exit_cycle; /* guest cycle at the exit restore decision (0 = not exited) */
} IrqCtxEntry;
IrqCtxEntry g_irqctx_ring[IRQCTX_RING_CAP];
uint64_t g_irqctx_seq = 0;
/* Always-on per-source exception accounting (irq_exc_stats). A delivery is
* charged to every I_STAT&I_MASK bit pending at entry (index 11 = software
* interrupt only); cycles are guest cycles from entry to the exit restore
* decision, so a handler that stalls (e.g. a held DMA kick) shows up here. */
uint64_t g_exc_src_deliveries[12];
uint64_t g_exc_src_cycles[12];
uint64_t g_exc_cycles_total;
uint64_t g_exc_exits_total;
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"
/* ---- CAUSE.IP2 is COMBINATIONAL, not latched ---------------------------
*
* On R3000A the Cause.IP field is not storage: it reflects the current state
* of the interrupt input pins. On the PSX only IP2 (bit 10) is wired, and it
* carries the interrupt controller's output line, i.e. (I_STAT & I_MASK) != 0.
* It therefore RISES when a device raises and FALLS the instant the guest acks
* I_STAT or masks the source — with no CPU involvement either way.
*
* This runtime previously only ever OR'd bit 10 in at delivery and never
* cleared it, leaving a phantom IP2 in COP0.CAUSE. A kernel exception
* dispatcher that loops on CAUSE.IP & SR.IM to decide whether to service
* again sees a pending interrupt that no longer exists and can spin in its
* event scan forever.
*
* PSX-SPX interrupts, "PSX specific COP0 Notes": cop0r13.bit10 "is NOT a
* latch, ie. it gets automatically cleared as soon as (I_STAT AND
* I_MASK)=zero", and "Interrupt Request / Execution": it is set while
* (I_STAT AND I_MASK) is nonzero. So the bit is recomputed at every point
* (I_STAT & I_MASK) can change: a raise, a Status ack, a Mask write and
* power-on, which is exactly the set of call sites below. The Beetle
* reference behaves the same.
*
* Ownership: this function is the ONLY writer of CAUSE bit 10. The delivery
* path no longer ORs it in separately — one owner, no divergence.
*
* Derived from PR #102 by Alexandros Mandravillis; the mirror call sites and
* the single-owner refactor are ours. */
static uint32_t *s_cause_ptr;
void psx_irq_refresh_cause_ip2(void)
{
if (!s_cause_ptr) return;
if ((i_stat & i_mask & 0x7FFu) != 0u)
*s_cause_ptr |= (1u << 10);
else
*s_cause_ptr &= ~(1u << 10);
}
void psx_irq_set_cause_ptr(uint32_t *p)
{
s_cause_ptr = p;
/* Power-on recompute (the Beetle reference also recomputes here). Without
* this the first mirror only happens at the first raise/ack, so a CAUSE
* read before any interrupt activity would show a stale bit. */
psx_irq_refresh_cause_ip2();
}
/* 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. */
void psx_irq_raise(uint32_t bit, uint32_t detail)
{
i_stat |= (1u << bit);
psx_irq_refresh_cause_ip2();
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_DEFER_STALE_CYCLES (g_psx_vblank_cycles * 10ull)
/* The VBlank period follows the GPU's live video standard (GP1(08h) bit 3):
* NTSC 564480 cycles (60 Hz), PAL 677376 (50 Hz). See psx_video_timing.h. */
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;
extern int g_cosim_dirty_pump_site;
/* Reentrancy guard: prevent interrupt handler from triggering interrupts. */
static int in_exception;
/* Guest-cycle deadline: IRQ delivery is blocked while psx_get_cycle_count() is
* below this. 0 = no cooldown active. Counted in guest cycles (not calls) so both
* backends make the identical delivery decision — see the cooldown constants. */
static uint64_t post_exception_cooldown_until;
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();
{ /* remaining cooldown cycles (0 = inactive) for the cosim ring display */
uint64_t now = psx_get_cycle_count();
e->cooldown = (post_exception_cooldown_until > now)
? (int32_t)(post_exception_cooldown_until - now) : 0;
}
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;
}
/* ---- Mid-dispatch audio pump -------------------------------------------
*
* The SPU is autonomous on real hardware: it keeps consuming samples and
* advancing voice positions while the CPU busy-waits. Our audio pump is driven
* from the main loop between presented frames, so a guest busy-wait that never
* completes a frame starves it and freezes SPU time. That is self-deadlocking
* for any game that waits on an SPU-generated condition — the SPU IRQ it waits
* for needs SPU time to advance, and SPU time only advances when the wait ends.
*
* The VBlank edge is the right place to also pump because it is derived from
* the guest cycle counter, not from host presentation, so it keeps firing
* through such a wait. The pump itself is guest-cycle-budgeted (it renders
* elapsed_cycles/768 frames and carries the remainder), so pumping from both
* here and the main loop produces the same total sample count — the second
* caller simply finds little or no debt outstanding. Both callers are the main
* loop thread, so this is not concurrent with the SDL audio callback, which
* only drains an already-filled ring.
*
* Called at the END of the edge so the VBlank's own IRQ raise and ring records
* complete first: the pump can itself raise an SPU IRQ, and that should land
* after the VBlank edge it followed rather than interleaved into it.
*
* From PR #102 by Alexandros Mandravillis. */
static void (*s_midframe_audio_pump)(void);
/* First-divergence telemetry for the per-sample SPU scheduler. */
uint64_t g_spu_sample_deadline_queries;
uint64_t g_spu_sample_service_checks;
uint64_t g_spu_sample_service_pumps;
uint64_t g_spu_sample_raw_boundaries;
uint64_t g_spu_sample_deferred_mismatches;
uint64_t g_spu_sample_enabled_queries;
uint64_t g_spu_sample_enabled_services;
uint32_t g_spu_sample_last_query_phase;
uint32_t g_spu_sample_last_query_delta;
uint32_t g_spu_sample_last_service_phase;
uint64_t g_spu_sample_mode_rejects;
uint64_t g_spu_sample_pump_null_rejects;
uint64_t g_spu_sample_ctrl_rejects;
void psx_set_midframe_audio_pump(void (*fn)(void)) { s_midframe_audio_pump = fn; }
/* While SPU IRQ9 is enabled, expose each 44.1-kHz sample as a device deadline
* so guest code can acknowledge and re-arm an IRQ-address hit before the next
* sample. Rendering a whole VBlank's accumulated samples as one chunk collapses
* multiple hardware IRQ edges into one latch, slowing IRQ-driven audio engines
* and blocking cutscene synchronization. Keep an explicit opt-out for bisecting
* old captures; faithful per-sample scheduling is the production default. */
static int spu_sample_event_mode(void) {
static int enabled = -1;
if (enabled < 0) {
const char *e = getenv("PSX_SPU_SAMPLE_EVENTS");
enabled = (!e || !*e || strcmp(e, "0") != 0) ? 1 : 0;
}
return enabled;
}
uint32_t psx_spu_sample_event_cycles_to_next(void) {
g_spu_sample_deadline_queries++;
if (!spu_sample_event_mode()) {
g_spu_sample_mode_rejects++;
return UINT32_MAX;
}
if (!s_midframe_audio_pump) {
g_spu_sample_pump_null_rejects++;
return UINT32_MAX;
}
/* Gate on SPUCNT alone — this runs on every deadline recompute, and the
* full SpuGlobalState snapshot here dominated the emu thread under an
* MMIO-polling guest loop (Capcom FMV, gdb-sampled 2026-09-01). */
if ((spu_ctrl_read() & 0x0040u) == 0) {
g_spu_sample_ctrl_rejects++;
return UINT32_MAX;
}
g_spu_sample_enabled_queries++;
/* The PS1 CPU/SPU ratio is exactly 768 CPU cycles per 44.1-kHz sample.
* Cycle zero is the common hardware epoch; a value exactly on a sample
* boundary names the following event, not an already-serviced event. */
const uint32_t phase = (uint32_t)(psx_get_cycle_count() % 768u);
const uint32_t delta = phase ? (768u - phase) : 768u;
g_spu_sample_last_query_phase = phase;
g_spu_sample_last_query_delta = delta;
return delta;
}
void psx_spu_sample_event_service(void) {
g_spu_sample_service_checks++;
if (!spu_sample_event_mode() || !s_midframe_audio_pump)
return;
/* SPUCNT alone — same hot-gate rationale as the deadline query above. */
const uint16_t ctrl = spu_ctrl_read();
if ((ctrl & 0x0040u) != 0) {
g_spu_sample_enabled_services++;
g_spu_sample_last_service_phase = (uint32_t)(psx_cycle_count % 768u);
}
if ((psx_cycle_count % 768u) == 0) {
g_spu_sample_raw_boundaries++;
if ((psx_get_cycle_count() % 768u) != 0)
g_spu_sample_deferred_mismatches++;
}
if ((ctrl & 0x0040u) != 0 &&
(psx_get_cycle_count() % 768u) == 0) {
g_spu_sample_service_pumps++;
s_midframe_audio_pump();
}
}
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. */
psx_vblank_consume_edge(&cycles_since_vblank);
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() + g_psx_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) */
/* [timing] guest_cycle_scale_gate: RAM predicates are judged here, at a
* guest-time point (no-op without one). */
psx_guest_cycle_scale_vblank();
#ifndef PSX_ENABLE_BLOCK_CYCLES
timers_tick(33868); /* ~1 NTSC frame worth of cycles */
cdrom_tick(); /* Process pending CDROM responses */
#endif
/* Keep SPU time flowing across guest busy-waits (see comment above). */
if (s_midframe_audio_pump) s_midframe_audio_pump();
}
void interrupts_service_scheduled_events(void) {
note_sio_progress_cycle();
if (in_exception) return;
while (psx_vblank_edge_due(cycles_since_vblank)) {
if (should_defer_vblank_for_sio()) return;
fire_vblank_edge();
}
}
uint32_t interrupts_cycles_to_vblank(void) {
return psx_vblank_cycles_to_edge(cycles_since_vblank);
}
uint32_t interrupts_get_cycles_since_vblank(void) {
return cycles_since_vblank;
}
void interrupts_set_cycles_since_vblank(uint32_t v) {
cycles_since_vblank = v;
}
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 */
uint64_t g_cdrom_deliver_count = 0; /* CD IRQ (IRQ_CDROM) delivered to the guest — FMV dispatch probe */
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).
*
* FAITHFULNESS (MMX5 cp-3259 fork, 2026-07-04): the cooldown window is counted
* in GUEST CYCLES, not psx_check_interrupts CALLS. A call is a backend-dependent
* unit — the compiled path checks at block edges, the dirty-RAM interp checks
* per-instruction, and compiled has extra check sites (e.g. at a `jr ra` return
* transition) the interp lacks. A per-call countdown therefore reached zero at a
* DIFFERENT guest cycle in each backend → IRQ delivered at a different cycle/EPC
* → the two backends forked (cosim first-divergence cp 3259 @ cyc ~213.5M). Guest
* cycles are advanced identically by both backends (shared cycle-cost model, ruler-
* validated), so a guest-cycle deadline makes the delivery decision backend-
* independent. Magnitudes below preserve the old behavior's breathing room: the
* claimed window ~= the old "8 blocks", the unclaimed ~= the old "500 blocks"
* (a compiled block edge ~= one psx_check_interrupts call ~= ~15 guest cycles). */
#define POST_EXC_CLAIMED_COOLDOWN_CYCLES 128u /* claimed non-SIO: a few blocks */
#define POST_EXC_UNCLAIMED_COOLDOWN_CYCLES 8192u /* unclaimed: generous boot window */
/* 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 */
/* Monotonic epoch of the exception setjmp frame: bumped each time the setjmp
* loop is (re)armed at exception entry. Lets an observer holding a frame on
* the C stack (the overlay shadow-diff, run_shadow_diff) decide whether a
* longjmp targets a frame BELOW it (armed before it — the unwind escapes its
* frame and skips its epilogue) or a frame armed after it (contained). The
* single global exception_jmpbuf always holds the latest-armed frame, so the
* current epoch identifies every longjmp's target. */
uint64_t g_exc_setjmp_epoch = 0;
uint64_t psx_exception_setjmp_epoch(void) { return g_exc_setjmp_epoch; }
/* 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;
/* Poll-throttle counter for fast IRQ paths — host-only; must not survive
* rewind or load#N can hit the 16K poll edge at a different guest point. */
static uint32_t s_fast_maintenance = 0;
uint32_t psx_last_irq_check_pc(void) { return s_last_interrupt_check_pc; }
uint32_t psx_compiled_irq_resume_pc(void) { return s_compiled_interrupt_resume_pc; }
uint64_t psx_last_irq_check_cycle(void) { return s_last_interrupt_check_cycle; }
uint64_t psx_interrupt_total_checks(void) { return total_checks; }
uint32_t psx_interrupt_fast_maintenance(void) { return s_fast_maintenance; }
int psx_irq_resume_context_snapshot_site(void)
{
return g_cosim_dirty_pump_site;
}
static unsigned s_snapshot_host_call_depth;
void psx_snapshot_host_call_begin(void) { ++s_snapshot_host_call_depth; }
void psx_snapshot_host_call_end(void) {
if (s_snapshot_host_call_depth) --s_snapshot_host_call_depth;
}
void psx_snapshot_host_call_reset(void) { s_snapshot_host_call_depth = 0; }
unsigned psx_snapshot_host_call_depth(void) { return s_snapshot_host_call_depth; }
void psx_snapshot_host_call_restore(unsigned depth) { s_snapshot_host_call_depth = depth; }
uint32_t psx_irq_resume_context_snapshot_pc(void)
{
return g_dirty_safe_resume_pc;
}
int psx_irq_resume_context_snapshot_safe_at(uint32_t resume_pc)
{
extern int psx_mod_function_entry_active(void);
if (psx_mod_function_entry_active()) return 0;
if (s_snapshot_host_call_depth) return 0;
if (g_cosim_dirty_pump_site == 0)
return 1;
/* Dirty interpreter pump sites are IRQ-precise, but only a subset are
* whole-machine snapshot-safe: the interpreted instruction stream has fully
* retired into CPUState, no host call unit is active, and the published
* dirty resume PC is the same PC the caller intends to serialize. Keep
* call-return and precise-IRQ pump sites rejected; those are the contexts
* that can pair a plausible resume PC with stale nested-call registers. */
extern int g_call_unit_depth;
if (in_exception || g_call_unit_depth != 0 || g_psx_dispatch_depth > 1)
return 0;
if (resume_pc == 0u || (resume_pc & 3u) != 0u)
return 0;
if (g_dirty_safe_resume_pc == 0u ||
((g_dirty_safe_resume_pc ^ resume_pc) & 0x1FFFFFFFu) != 0u)
return 0;
switch (g_cosim_dirty_pump_site) {
case 1: /* transfer surface: target PC is materialized in CPUState */
case 2: /* stop_addr reached: straight-line dirty flow fully retired */
case 3: /* left dirty page: next dispatch PC is materialized */
case 4: /* interpreter guard-yield: CPUState PC was flushed */
case 6: /* public dirty dispatch pump after handled block */
return 1;
default:
return 0;
}
}
int psx_irq_resume_context_snapshot_safe(void)
{
return psx_irq_resume_context_snapshot_safe_at(g_dirty_safe_resume_pc);
}
void psx_irq_clear_resume_latches(void)
{
s_compiled_interrupt_resume_pc = 0;
s_last_interrupt_check_pc = 0;
s_last_interrupt_check_cycle = UINT64_MAX;
}
/* Publish the known resume PC before the first post-load / flush_resume
* dispatch. interrupts_resync zeros the latches; a sticky I_STAT.VBlank can
* then deliver with take_pc=0 → LEGACY_SENTINEL / same_thr=0 and fork peers
* (MotK loading-screen tip+1 @1480, Win irqctx reason=3 epc=sentinel). */
void psx_irq_arm_compiled_resume_pc(uint32_t pc)
{
if (pc == 0u || (pc & 3u) != 0u)
return;
s_compiled_interrupt_resume_pc = pc;
s_last_interrupt_check_pc = pc;
}
/* Deferred cooperative thread switch from nested exception delivery.
*
* A genuine in-exception ChangeThread (kind-30, escape site below) must be
* honored at the OUTERMOST dispatch boundary, never mid-nested-host-dispatch.
* Only at the outermost boundary is the outgoing thread's guest state fully
* materialized in CPUState (its true block PC is live, GPRs current); nested
* inside a call unit / dirty pump the PC lives only in the resume-PC latch,
* which desyncs from the live GPRs when the IRQ was taken deep inside a nested
* call (the async-poll callback), so switching there saves a poisoned context
* (latched PC ahead of the callback's registers) → the resumed thread dispatches
* a jumptable with a smeared index → misaligned DISPATCH FATAL. When a switch is
* detected nested, we record it here, let the outgoing thread resume
* transparently, and honor it at the next outermost block-leader boundary (top
* of psx_check_interrupts) where the thread can be re-saved cleanly. */
static int s_defer_switch_pending = 0;
static uint32_t s_defer_switch_target = 0; /* TCB PCB[0] should name after the switch */
static uint32_t s_defer_switch_from = 0; /* the interrupted thread to re-save cleanly */
/* A/B toggle: PSX_DEFER_SWITCH=0 forces the legacy immediate-switch behavior
* (longjmp the instant a mid-exception ChangeThread is detected, regardless of
* nesting) so the deferred-switch fix can be compared against the baseline
* without a rebuild. Default ON. Latched at first query. */
static int defer_switch_enabled(void) {
static int s = -1;
if (s < 0) { const char *e = getenv("PSX_DEFER_SWITCH"); s = (e && e[0] == '0') ? 0 : 1; }
return s;
}
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) return;
/* Arm on EVERY handler-window RFE, including LEGACY_SENTINEL deliveries.
* The old blanket decline assumed a sentinel delivery always resumes AT
* the sentinel — but a handler that switches threads (the guest's own
* cooperative scheduler moving PCB[0] in its VBlank callback) RFEs to the
* NEW thread's real EPC instead; with nothing armed, no exit path ever
* fires and in_exception wedges the VBlank authority forever (Tomba 2
* splash livelock). The take site keeps the legacy contract intact: a
* resume that DOES land on the sentinel still declines there and exits
* through the sentinel detector's saved-GPR restore. */
g_rfe_escape_pending = 1;
if (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL)
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) {
/* 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 &&
cpu->pc != PSX_EXC_SENTINEL_PC &&
psx_fiber_current() == g_exception_owner_fiber) {
g_rfe_escape_pending = 0;
/* A LEGACY_SENTINEL delivery escaping to a NON-sentinel PC means the
* handler switched threads and RFE'd to the new thread's real EPC.
* Record the completion truthfully as an RFE return so the epilogue's
* restore discriminator does NOT overwrite the kernel's freshly
* restored TCB registers with the ENTERING thread's saved_gpr (the
* cross-thread corruption Fix B exists to prevent). A resume that
* lands ON the sentinel never reaches this longjmp (declined above):
* it exits through the legacy sentinel detector, whose saved-GPR
* restore contract stays keyed on the untouched LEGACY reason. */
if (g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL)
g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN;
/* Same shadow-frame hardening as deferred_exception_longjmp. */
extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch);
overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch);
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) {
uint64_t now = psx_get_cycle_count();
*out_post_exc_cooldown = (post_exception_cooldown_until > now)
? (int)(post_exception_cooldown_until - now) : 0;
}
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_until = 0;
exception_entries_total = 0;
exception_reentry_blocks = 0;
cycles_since_vblank = 0;
/* Rematch / session_reboot re-enters bring-up without process exit.
* Stale I_STAT/I_MASK from the prior match (e.g. VBlank pending + game
* mask) makes boot take a phantom IRQ at ~cycle 4 and wedge under
* netplay lockstep. Cold boot also wants zeros (BSS already is). */
i_stat = 0;
i_mask = 0;
g_vblank_raise_count = 0;
last_sio_seq_seen = sio_get_seq();
last_sio_progress_cycle = psx_get_cycle_count();
s_defer_switch_pending = 0;
s_defer_switch_target = 0;
s_defer_switch_from = 0;
/* Rematch soft-return: sticky BB/IRQ resume latches survive process lifetime.
* pick_snap_resume_pc prefers them for tick-0 snaps → dig0 RAM + prior-match
* game PC (host baseline pc=0x8006… vs guest BIOS 0xbfc0…) → hc-fork abort. */
psx_irq_clear_resume_latches();
/* Same host-only ambient as interrupts_resync_after_restore — soft-return
* rematch is not a snap load, so resync is not called; cold peers start
* with BSS zeros here. */
g_exception_real_epc = 0;
g_exc_escape_reason = PSX_EXC_ESCAPE_NONE;
g_rfe_escape_pending = 0;
g_pending_exception_longjmp = 0;
s_fast_maintenance = 0;
{
extern uint32_t g_dirty_safe_resume_pc;
g_dirty_safe_resume_pc = 0;
}
}
void interrupts_resync_after_restore(void) {
/* Absolute guest-cycle timestamps from the pre-load timeline are invalid
* once psx_cycle_count rewinds (typical: save → play N seconds → load).
* Leaving post_exception_cooldown_until in the future blocks every IRQ
* delivery — including VBlank — until the restored clock catches up,
* freezing the picture for those N seconds while host FPS stays at 60. */
post_exception_cooldown_until = 0;
/* Do NOT zero cycles_since_vblank: boot_state restores it from the snap
* (BS_SEC_IRQ +4). Zeroing rebased every warm tip to phase 0 while timers /
* LCF stayed at the snap's mid-frame phase — MotK wait-loop resim then
* delivered VBlank on opposite CD54↔CDA0 edges (cyc±3 / core fork). Legacy
* 8-byte IRQ sections still set csv=0 in boot_state_load. */
dispatch_count = 0;
in_exception = 0;
exception_nest_depth = 0;
last_sio_seq_seen = sio_get_seq();
last_sio_progress_cycle = psx_get_cycle_count();
s_defer_switch_pending = 0;
s_defer_switch_target = 0;
s_defer_switch_from = 0;
/* Host-only IRQ/escape ambient — not in the snap. Stale RFE/SENTINEL
* escape flags from the pre-load timeline can bias the first same-thread
* restore decision after rewind. Also zero resume-PC latches: the poll
* that triggered this load left the PRE-LOAD timeline's BB PC in
* s_compiled_interrupt_resume_pc; if sticky I_STAT delivers before the
* first post-resume BB edge rewrites it, EPC/saved_gpr fork warm
* resim#2 vs #3 (selfcheck MotK attract win#70/#73, matched clocks). */
g_exception_real_epc = 0;
g_exc_escape_reason = PSX_EXC_ESCAPE_NONE;
g_rfe_escape_pending = 0;
g_pending_exception_longjmp = 0;
s_compiled_interrupt_resume_pc = 0;
s_last_interrupt_check_pc = 0;
/* De-dupe key for dispatch_entry: a stale absolute cycle from the pre-load
* timeline can equal the restored tip and skip the first post-resume IRQ
* check at that PC (warm selfcheck MotK #2≠#3 at matched clocks). */
s_last_interrupt_check_cycle = UINT64_MAX;
s_fast_maintenance = 0;
total_checks = 0;
{
extern uint32_t g_dirty_safe_resume_pc;
g_dirty_safe_resume_pc = 0;
}
/* Load longjmp from mid-path abandons PSX_CHECK_INTERRUPTS_RETURN's
* g_ls_suppress_record-- — clamp so suppress cannot accumulate. */
{
extern int g_ls_suppress_record;
g_ls_suppress_record = 0;
}
/* Drop mid-quantum present armed before the rewind — do not finish_frame
* against the restored tip with a stale pending count. */
gpu_vblank_clear_deferred_present();
}
void interrupts_log_last_vblank_irqctx(const char *tag)
{
uint64_t i;
if (g_irqctx_seq == 0)
return;
/* Walk newest→oldest for the last completed VBLANK delivery. */
for (i = 0; i < g_irqctx_seq && i < IRQCTX_RING_CAP; i++) {
uint64_t idx = g_irqctx_seq - 1u - i;
const IrqCtxEntry *e = &g_irqctx_ring[idx & (IRQCTX_RING_CAP - 1u)];
if (!e->is_vblank)
continue;
fprintf(stderr,
"psxrecomp: rb irqctx %s vb seq=%llu cyc=%llu restored=%u "
"same_thr=%u reason=%u exit_pc=%08x epc=%08x "
"v0_exit=%08x v0_saved=%08x v1_exit=%08x v1_saved=%08x\n",
tag ? tag : "?",
(unsigned long long)e->seq, (unsigned long long)e->cycle,
(unsigned)e->restored, (unsigned)e->same_thread,
(unsigned)e->exit_reason, (unsigned)e->exit_pc,
(unsigned)e->real_epc, (unsigned)e->v0_exit,
(unsigned)e->v0_saved, (unsigned)e->v1_exit,
(unsigned)e->v1_saved);
fflush(stderr);
return;
}
}
/*
* 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 this unwind blows through a live shadow-diff frame (a frame armed
* BEFORE the shadow started), restore the shadow-scoped globals its
* skipped epilogue would have restored — otherwise the diff instrument
* silently dies for the rest of the run (s_in_shadow stuck). No-op when
* no shadow is live or the target frame was armed inside the shadow. */
extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch);
overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch);
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 = psx_vblank_cycles_to_edge(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;
}
static uint64_t s_need_defer, s_need_irq, s_skip_none, s_skip_sr;
static uint64_t s_skip_cooldown, s_skip_nested;
void psx_interrupt_delivery_diag(uint64_t *need_defer, uint64_t *need_irq,
uint64_t *skip_none, uint64_t *skip_sr,
uint64_t *skip_cooldown, uint64_t *skip_nested) {
if (need_defer) *need_defer = s_need_defer;
if (need_irq) *need_irq = s_need_irq;
if (skip_none) *skip_none = s_skip_none;
if (skip_sr) *skip_sr = s_skip_sr;
if (skip_cooldown) *skip_cooldown = s_skip_cooldown;
if (skip_nested) *skip_nested = s_skip_nested;
}
/* Hot-path attribution for post-load freeze probe (see psx_interrupt_check_path_diag). */
static uint64_t s_irq_path_entry;
static uint64_t s_irq_path_fast_sr;
static uint64_t s_irq_path_fast_none;
static uint64_t s_irq_path_eval;
void psx_interrupt_check_path_diag(uint64_t *entry, uint64_t *fast_sr,
uint64_t *fast_none, uint64_t *mid,
uint64_t *eval, uint64_t *irq_deliv) {
if (entry) *entry = s_irq_path_entry;
if (fast_sr) *fast_sr = s_irq_path_fast_sr;
if (fast_none) *fast_none = s_irq_path_fast_none;
if (mid) *mid = total_checks;
if (eval) *eval = s_irq_path_eval;
if (irq_deliv) *irq_deliv = g_irq_deliver_count;
}
int psx_interrupt_delivery_needed(const CPUState* cpu) {
/* Render passes and uncharged calls run in frozen guest time: nothing
* is delivered. */
if (g_psx_guest_time_frozen) return 0;
if (s_defer_switch_pending) { s_need_defer++; return 1; }
if ((i_stat & i_mask) == 0) { s_skip_none++; return 0; }
uint32_t sr = cpu->cop0[COP0_SR];
if (!(sr & 0x01u) || !(sr & (1u << 10))) { s_skip_sr++; return 0; }
if (post_exception_cooldown_until != 0 &&
psx_get_cycle_count() < post_exception_cooldown_until) {
s_skip_cooldown++;
return 0;
}
if (in_exception &&
(exception_nest_depth >= 2 ||
g_rfe_escape_pending ||
g_exc_escape_reason != PSX_EXC_ESCAPE_NONE)) {
s_skip_nested++;
return 0;
}
s_need_irq++;
return 1;
}
void psx_check_interrupts(CPUState* cpu) {
/* Frozen guest time (psx_cycle_freeze.h): no delivery, and no
* check-boundary bookkeeping that the stock timeline would not do. */
if (g_psx_guest_time_frozen) return;
psx_cyc_batch_flush();
extern int g_ls_suppress_record;
extern int psx_netplay_active(void);
const int np_active = psx_netplay_active();
/* Publish this edge's resume PC BEFORE deferred present flush. The MotK
* CDA0 gate reads s_last_interrupt_check_pc; leaving it at the previous
* BB (CD54) made every CDA0 entry flush look like CD54 and no-op. Present
* then only drained on post-IRQ at a CDA0 delivery — if the first post-arm
* VBlank was taken at CD54, peers waited a full extra VB (soak ep9: arm+2
* vs arm+1). Offline keeps master's later publish (idle-skip / mid-path). */
if (np_active) {
uint32_t edge_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc
: s_compiled_interrupt_resume_pc;
if (edge_pc != 0u)
s_last_interrupt_check_pc = edge_pc;
}
/* Netplay-only: deferred sdl_vblank_present at BB edge (not mid-block
* fire_vblank_edge). Prefer flush AFTER delivery so finish_frame digests
* post-RFE GPRs — but also attempt flush at entry when delivery is due.
* MotK's CDA0 gate no-ops the entry attempt on CD54; without it, sticky
* I_STAT kept skipping entry flush while post-IRQ also skipped on CD54,
* stacking multiple VBlanks into one drain (double finish_frame @ same
* guest cyc → clk/tim ±9 on the next IRQ). Post-IRQ retry still runs.
* Selfcheck stays on immediate present (see gpu.c).
*
* Never flush while in_exception: handler BB edges see IEc clear so
* delivery_needed is false and an older else-branch called finish_frame
* mid-BIOS-handler. Soak: irqctx left restored=0/reason=0, peers forked
* dig_cpu at v0=1 vs countdown (cyc±14) on sealed Cross resim. Outer
* delivery keeps np_present_after_irq and flushes on its RETURN.
*
* Offline must NOT flush here — master never did. BB-edge finish_frame
* during Ape Escape's memcard busy-wait wedges the card-check scene
* (empty starfield hang). Netplay MotK still needs the drain — but
* gpu_vblank_flush_present holds while sio_hold_present_for_card(), and
* we also skip while a deferred cooperative ChangeThread is pending
* (Ape memcard fix #2) so finish_frame cannot run on a smeared TCB. */
int np_present_after_irq = 0;
if (!in_exception && np_active && !s_defer_switch_pending) {
gpu_vblank_flush_present(); /* CDA0 / card gates inside; CD54 no-ops */
if (psx_interrupt_delivery_needed(cpu))
np_present_after_irq = 1;
}
#define PSX_CHECK_INTERRUPTS_RETURN() do { \
if (np_present_after_irq && !s_defer_switch_pending) \
gpu_vblank_flush_present(); \
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
/* MotK wait CD54 / post-FMV 768C8 + VBlank-only I_STAT: never deliver at
* the "A" edge in netplay — hold until the canonical B edge. Delivering at
* A leaves v0=slt(1) while a peer that hit B first delivers with countdown
* in v0 (soak: non-det fin@946/@902 CD54 vs CDA0; post-FMV tip+1 @871
* 768C8 vs 76880, cyc ±1). UNCONDITIONAL on the edge PC — the earlier gate
* also required gpu_vblank_present_pending(), but s_present_pending is
* host-only state (not in the snap), so replay delivery timing forked
* across peers. Edge PC + I_STAT are guest-deterministic; the wait
* ping-pong reaches B a few instructions later, so no starvation. */
if (!in_exception && psx_netplay_active()) {
const uint32_t wait_a = 0x8006CD54u;
const uint32_t wait2_a = 0x800768C8u;
uint32_t edge = s_last_interrupt_check_pc;
uint32_t pend = i_stat & i_mask;
if ((edge == wait_a || edge == wait2_a) && pend != 0u &&
(pend & ~(1u << IRQ_VBLANK)) == 0u) {
PSX_CHECK_INTERRUPTS_RETURN();
}
}
s_irq_path_entry++;
/* MotK VLC / FMV hot edge: sticky unmasked I_STAT (CD/VBlank) while
* IEc or IM2 is clear — no architectural delivery possible. Skip the
* mid-path bookkeeping / irq_deliver_eval that used to run every BB.
* Guest-visible timing unchanged (same non-delivery). LTO can collapse
* this into the VLC call sites. */
if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) {
uint32_t sr = cpu->cop0[COP0_SR];
if (!(sr & 0x01u) || !(sr & (1u << 10))) {
s_irq_path_fast_sr++;
if ((++s_fast_maintenance & 0x3FFFu) == 0) {
extern void savestate_poll(CPUState* cpu, uint32_t resume_pc);
extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc);
extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc);
extern void psx_rewind_poll(CPUState* cpu, uint32_t resume_pc);
savestate_poll(cpu, s_compiled_interrupt_resume_pc);
/* MotK FMV/VLC live here — must flush pending RB snaps too. */
psx_netplay_poll_snap(cpu, s_compiled_interrupt_resume_pc);
psx_selfcheck_poll(cpu, s_compiled_interrupt_resume_pc);
psx_rewind_poll(cpu, s_compiled_interrupt_resume_pc);
debug_server_poll();
}
PSX_CHECK_INTERRUPTS_RETURN();
}
}
/* Genuine entry fast paths. Generated resident code calls this at every
* basic-block edge, which can mean tens of millions of calls inside an FMV
* polling loop. psx_advance_cycles() has already serviced every device
* deadline before this point, so no-pending and IEc-clear checks have no
* architectural work to do. The old fast paths came after diagnostics and
* savestate_poll, making a harmless callback expensive enough to reduce
* Tomba 2's Whoopee FMV to ~1 guest fps.
*
* Idle-skip used to force the slow path on every edge so
* psx_idle_note_check could observe poll PCs. That made MotK's VLC
* (branchy, changing resume PCs, idle_skip=true) pay full IRQ bookkeeping
* millions of times per second for zero skips. Keep the fast path; run the
* idle note here when enabled, then re-check I_STAT in case a skip raised
* an event. Guest timing is unchanged — skip still advances via
* psx_advance_cycles.
*
* Poll host maintenance periodically so save/load and the debug socket stay
* responsive even when the guest spends a long time in this path. At the
* observed 1M+ block edges/s this is sub-frame latency. */
if (g_idle_skip_enabled < 0) (void)psx_idle_skip_is_enabled();
/* COP0 software interrupts (CAUSE.IP0/IP1 vs SR.IM0/IM1, bits 8-9): raised
* purely by guest mtc0 to CAUSE, with no INTC involvement. Games use this
* as a re-entrant exception-dispatch mechanism (Jackie Chan Stuntmaster's
* handler saves SR/EPC, sets CAUSE=0x100 + SR=0x101, and relies on the
* immediate sw-int to chain into its second stage; without it the CD INT3
* ack never runs and the machine deadlocks with SR.IM2 stripped). */
uint32_t sw_pending = cpu->cop0[COP0_CAUSE] & cpu->cop0[COP0_SR] & 0x0300u;
if (!in_exception && (i_stat & i_mask) == 0 && sw_pending == 0 &&
!s_defer_switch_pending) {
if (g_idle_skip_enabled > 0) {
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();
psx_idle_note_check(cpu, check_pc);
}
if ((i_stat & i_mask) == 0 && sw_pending == 0) {
s_irq_path_fast_none++;
if ((++s_fast_maintenance & 0x3FFFu) == 0) {
extern void savestate_poll(CPUState* cpu, uint32_t resume_pc);
extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc);
extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc);
extern void psx_rewind_poll(CPUState* cpu, uint32_t resume_pc);
savestate_poll(cpu, s_compiled_interrupt_resume_pc);
psx_netplay_poll_snap(cpu, s_compiled_interrupt_resume_pc);
psx_selfcheck_poll(cpu, s_compiled_interrupt_resume_pc);
psx_rewind_poll(cpu, s_compiled_interrupt_resume_pc);
debug_server_poll();
}
PSX_CHECK_INTERRUPTS_RETURN();
}
/* Idle skip advanced time and a device raised I_STAT — deliver below. */
}
if (in_exception && !(cpu->cop0[COP0_SR] & 0x01u))
PSX_CHECK_INTERRUPTS_RETURN();
/* Mid path: unmasked IRQ already pending, ordinary compiled edge.
* MotK VLC / FMV spend most BB edges here (sticky CD/VBlank bits).
* Devices are already caught up via psx_advance_cycles, so skip
* service/idle/savestate housekeeping and go straight to delivery. */
if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) {
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) {
extern void savestate_poll(CPUState* cpu, uint32_t resume_pc);
extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc);
extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc);
extern void psx_rewind_poll(CPUState* cpu, uint32_t resume_pc);
savestate_poll(cpu, check_pc);
/* Sticky CD/VBlank mid-path is MotK's FMV hot edge — without this
* the RB snap ring never fills (pending save never polled). */
psx_netplay_poll_snap(cpu, check_pc);
psx_selfcheck_poll(cpu, check_pc);
psx_rewind_poll(cpu, check_pc);
debug_server_poll();
}
goto irq_deliver_eval;
}
{
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();
/* Idle-loop cycle skip (psx_cycles.c): detect a side-effect-free poll
* loop by its repeated same-PC checks and fast-forward guest time to the
* next internal device event in whole loop quanta. Runs BEFORE the
* deliverability evaluation below so an event raised by the skip is
* delivered in this same check, exactly at the boundary real execution
* would have taken it. Skip when an unmasked IRQ is already pending —
* that is not an idle poll (FMV/VLC edges with sticky CD/VBlank bits),
* and the fast path already noted when I_STAT was clear. */
if ((i_stat & i_mask) == 0)
psx_idle_note_check(cpu, s_last_interrupt_check_pc);
/* 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);
extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc);
extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc);
extern void psx_rewind_poll(CPUState* cpu, uint32_t resume_pc);
savestate_poll(cpu, s_last_interrupt_check_pc);
psx_netplay_poll_snap(cpu, s_last_interrupt_check_pc);
psx_selfcheck_poll(cpu, s_last_interrupt_check_pc);
psx_rewind_poll(cpu, s_last_interrupt_check_pc);
}
/* Deferred cooperative thread switch: honor at the next real thread-save
* boundary (see s_defer_switch_* above). A
* genuine in-exception ChangeThread was deferred because it was detected at a
* poisoned point (the IRQ fired deep inside a nested call, e.g. the
* async-poll callback, so the resume-PC latch desynced from the live GPRs).
*
* The dirty-RAM interpreter also pumps interrupts at synthetic transfer and
* call-return sites. Those sites are precise enough to deliver hardware IRQs,
* but not necessarily safe to snapshot a cooperative thread: the interpreter
* may have committed a candidate resume PC while the live CPUState stack/GPRs
* still describe the previous local frame. Keep the deferral pending there and
* honor it only once execution reaches a normal interrupt poll with site 0.
* Near-free when nothing is pending. */
if (s_defer_switch_pending && !in_exception) {
if (psx_hle_scheduler_enabled()) {
uint32_t from_tcb = s_defer_switch_from;
uint32_t to_tcb = s_defer_switch_target;
uint32_t resume_pc = s_last_interrupt_check_pc; /* materialized block PC */
if (from_tcb == 0u || to_tcb == 0u ||
psx_sched_current_tcb(cpu) != from_tcb) {
/* STALE: the guest already re-scheduled cooperatively (PCB[0] no
* longer names the deferred thread) - abandon the deferral. This is
* the ONLY case we clear pending without honoring; dropping when a
* clean resume PC merely isn't available yet would LOSE the guest's
* ChangeThread and wedge (the bug this replaces). */
s_defer_switch_pending = 0;
s_defer_switch_target = 0;
s_defer_switch_from = 0;
debug_server_log_thread_event(33, cpu, from_tcb, to_tcb, resume_pc);
} else if (g_cosim_dirty_pump_site != 0) {
/* Not a real suspend boundary: dirty interpreter pump sites expose
* committed PCs for IRQ timing, but the matching CPUState may not
* be materialized yet. */
} else if (resume_pc != 0u && (resume_pc & 0x3u) == 0u &&
psx_is_dispatchable(resume_pc)) {
/* Materialized clean boundary: re-save the deferred thread cleanly
* (this OVERWRITES any poisoned/sentinel EPC the guest handler wrote
* for it while nested), re-point PCB[0], and honor the switch. */
extern int overlay_loader_shadow_native_thread_switch_bail(void);
if (overlay_loader_shadow_native_thread_switch_bail())
PSX_CHECK_INTERRUPTS_RETURN();
s_defer_switch_pending = 0;
s_defer_switch_target = 0;
s_defer_switch_from = 0;
psx_sched_save_context(cpu, from_tcb, resume_pc);
psx_sched_set_current_tcb(cpu, to_tcb);
debug_server_log_thread_event(32, cpu, from_tcb, to_tcb, resume_pc);
g_dirty_interp_active = 0;
s_compiled_interrupt_resume_pc = 0;
g_sched_escape.target_tcb = to_tcb;
g_sched_escape.resume_pc = 0;
g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB;
longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run */
}
/* else: KEEP pending - this outermost boundary has no materialized
* resume PC (both latches 0, e.g. a scheduled-event check not tied to a
* block leader). Wait for the next block-leader boundary that does. */
}
}
/* 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);
}
}
irq_deliver_eval:
s_irq_path_eval++;
/* Check if any interrupts are pending (INTC hardware or COP0 software). */
if ((i_stat & i_mask) == 0 && sw_pending == 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 of guest time elapse after
* RFE before the next delivery. Gated on the guest-cycle deadline (not a
* per-call countdown) so compiled and interp agree on the delivery cycle. */
if (post_exception_cooldown_until != 0) {
if (psx_get_cycle_count() < post_exception_cooldown_until) {
irq_record_outcome(EV_IRQ_GATE, GATE_COOLDOWN, 0);
#ifdef PSX_COSIM
COSIM_IRQ_NOTE(3u);
#endif
PSX_CHECK_INTERRUPTS_RETURN();
}
post_exception_cooldown_until = 0; /* window elapsed */
}
/* 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 */
/* Deliverability per source: the INTC line needs SR.IM2; software
* interrupts need only their own IM bit (already folded into sw_pending,
* recomputed here against the CURRENT sr — the fast-path snapshot above
* may predate an SR write earlier in this same check). */
sw_pending = cpu->cop0[COP0_CAUSE] & sr & 0x0300u;
int hw_deliverable = ((i_stat & i_mask) != 0) && ((sr & (1 << 10)) != 0);
if (!hw_deliverable && sw_pending == 0) {
irq_record_outcome(EV_IRQ_GATE, GATE_SR_IM2, 0);
#ifdef PSX_COSIM
COSIM_IRQ_NOTE(5u);
#endif
PSX_CHECK_INTERRUPTS_RETURN();
} /* No deliverable interrupt source (hw masked and no sw pending) */
/* 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++;
if ((i_stat & i_mask) & (1u << IRQ_CDROM)) g_cdrom_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). The ~0x7C mask deliberately
* preserves the whole IP field, because a pure software interrupt must
* present the guest-written IP0/IP1 bits unmodified (the guest's dispatcher
* discriminates stages by exactly those bits — see the sw_pending rationale
* at the top of this function).
*
* IP2 specifically is NOT set here. It is combinational and has a single
* owner, psx_irq_refresh_cause_ip2(), which already tracks the INTC line at
* every point that line can move. Refreshing rather than OR-ing means a
* delivery that races an ack cannot leave a stale bit behind, and a
* software-interrupt delivery gets IP2 reflecting the true line state
* instead of whatever bit 10 happened to be left as. */
cpu->cop0[COP0_CAUSE] = (cpu->cop0[COP0_CAUSE] & ~0x7C) | (0 << 2);
psx_irq_refresh_cause_ip2();
/* 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;
/* Top-level flush_resume / savestate: resync cleared the latches and
* the first IRQ may fire before any BB edge republishes them. Prefer
* cpu->pc (already set to the resume target) over the sentinel so
* EPC/saved_gpr stay on the real same-thread path. */
if (real_pc == 0u) {
extern int psx_scheduler_top_level_resume_active(void);
if (psx_scheduler_top_level_resume_active() &&
cpu->pc != 0u && (cpu->pc & 3u) == 0u) {
uint32_t phys = cpu->pc & 0x1FFFFFFFu;
if (phys < psx_ram_live_bytes() ||
(phys >= 0x1FC00000u && phys < 0x1FC80000u))
real_pc = cpu->pc;
}
}
/* Accept the real resume PC from live guest RAM OR the BIOS ROM
* window. The old RAM-only guard rejected ROM-space block leaders
* (e.g. OpenBIOS mcWaitForStatus spinning at 0xBFC076xx during a
* card op), forcing EVERY such delivery onto the legacy sentinel.
* A compiled handler escapes the sentinel host-side, so SCPH1001
* never noticed — but an INTERPRETED handler (OpenBIOS: the patch
* slots unbless the exception handler) saves/restores EPC
* architecturally through the TCB, churning the sentinel through
* guest state thousands of times per frame until one interleaving
* leaves it unrepaired (observed as a torn IntRP walk and a wild
* dispatch to 0x54000000 during the first memcard write at boot).
* ROM acceptance is gated on psx_is_dispatchable so a non-leader
* ROM pc still falls back to the sentinel (pre-fix behavior);
* RAM acceptance is unchanged byte-for-byte. */
uint32_t real_phys = real_pc & 0x1FFFFFFFu;
int resume_in_ram = real_phys < psx_ram_live_bytes();
int resume_in_rom = real_phys >= 0x1FC00000u && real_phys < 0x1FC80000u &&
psx_is_dispatchable(real_pc);
if (real_pc != 0u && (real_pc & 0x3u) == 0u &&
(resume_in_ram || resume_in_rom)) {
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;
psx_host_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;
e->entry_sp = cpu->gpr[29];
{ extern int g_cosim_dirty_pump_site;
e->pump_site = (uint32_t)g_cosim_dirty_pump_site; }
}
}
/* Keep this delivery's EPC stable across nested handler dispatches. */
const uint32_t delivered_epc = g_exception_real_epc;
/* Save the interrupted code's full register state.
*
* The BIOS handler saves the interrupted context to the TCB and restores
* it before returning to EPC. Kernel scratch registers may instead retain
* the handler's architectural result: the qualified real-RFE path below
* keeps its post-handler k0. Other host-restored values use this snapshot.
*
* 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.
*
* Save all GPRs/HI/LO before the handler for the host restore policy
* below. Its qualified RFE path preserves the actual handler k0 result
* rather than overwriting it with the interrupted snapshot. */
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;
/* The RAM exception vector is a LUI/ADDIU pair that materializes the
* installed handler address in $k0 before transferring to it. The
* host-side fast path decodes that pair and dispatches straight to the
* target, so it must also commit the pair's architectural register
* result. Vigilante 8's handler uses $k0 as its table base on its very
* first instruction; leaving the interrupted value in $k0 made it load
* a BIOS instruction word (0xAD400000) as a jump target. */
uint32_t vector_reg = (w1 >> 16) & 31u;
if (vector_reg != 0u) cpu->gpr[vector_reg] = target_pc;
}
/* 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;
/* Ape memcard fix #3 (2026-07-07): clear the dirty-interp resume-PC latch
* across the handler dispatch, exactly like g_dirty_interp_active above.
*
* The interrupted dirty block set g_dirty_safe_resume_pc = its committed PC
* and left it LIVE across its own psx_check_interrupts call (the dirty pump
* only restores it AFTER that call returns). Without clearing it here, a
* NESTED interrupt taken inside the handler — e.g. a VBLANK delivered while
* the kernel's async-poll memory-card callback runs (in_exception, IEc
* re-enabled) — inherits the OUTER interrupted block's stale resume PC as its
* EPC. same-thread-restore then matches (cpu->pc == that stale EPC) and
* resumes the OUTER block with the CALLBACK's registers: the Ape memcard
* consumer at 0x801382CC gets v1 (jumptable index) = the callback's pointer
* 0x800B4E30, bounds-check already passed, `jr jumptable[garbage]` faults
* (DISPATCH FATAL misaligned 0x3) — or, in the soft variant, drives a wrong
* branch that skips DeliverEvent(0xF0000011,4) and the card scene wedges.
* (irqctx_ring proof: exit_pc=0x801382CC, ra_saved=0x80010094 [callback],
* v1_saved=0x800B4E30, restored=1.)
*
* Cleared here so a nested delivery latches the ACTUALLY-running code's PC:
* the callback's own dirty pump re-sets g_dirty_safe_resume_pc for its
* blocks, and compiled handler code uses s_compiled_interrupt_resume_pc.
* Restored after the dispatch loop (below), which the RFE/escape longjmp
* lands in — so the escape can't leave the latch wrong. A/B off:
* PSX_EXC_CLEAR_RESUME_LATCH=0. */
static int s_clear_resume_latch = -1;
if (s_clear_resume_latch < 0) {
const char *e = getenv("PSX_EXC_CLEAR_RESUME_LATCH");
s_clear_resume_latch = (e && *e) ? atoi(e) : 1;
}
uint32_t saved_dirty_resume_pc = g_dirty_safe_resume_pc;
if (s_clear_resume_latch) g_dirty_safe_resume_pc = 0;
/* Wall-time sampler phase: the handler dispatch runs recompiled static
* BIOS text. Same save/restore contract as g_dirty_interp_active above —
* the restore sits after the setjmp loop, covering longjmp landings. */
extern int g_exec_phase;
int prev_exec_phase = g_exec_phase;
g_exec_phase = 3;
/* 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;
/* Nested delivery clobber guard: exception_jmpbuf is a single global, so a
* nested exception's setjmp overwrites the OUTER frame's context. The outer
* handler's later RFE would then longjmp into the dead inner frame (host
* stack corruption — first hit by Jackie Chan Stuntmaster's software-
* interrupt dispatcher, which nests by design). Save the armed frame here
* and restore it in the epilogue so each nesting level's RFE lands in its
* own live frame. */
jmp_buf saved_exception_jmpbuf;
memcpy(&saved_exception_jmpbuf, &exception_jmpbuf, sizeof(jmp_buf));
ModFunctionEntryContext saved_mod_callback;
mod_runtime_function_entry_context_save(&saved_mod_callback);
const unsigned saved_host_call_depth = psx_snapshot_host_call_depth();
g_exc_setjmp_epoch++; /* new setjmp frame armed (see decl above) */
for (;;) {
int jmp_val = setjmp(exception_jmpbuf);
if (jmp_val != 0) {
/* RFE/RestoreState can skip a callback and its guest-call adapter.
* Preserve callbacks below this handler; discard only its scopes. */
mod_runtime_function_entry_context_restore(&saved_mod_callback);
psx_snapshot_host_call_restore(saved_host_call_depth);
}
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;
/* Re-arm the OUTER frame's jmpbuf (see save at entry): after a nested
* delivery returns, the outer handler is live again and its RFE must land
* in the outer setjmp frame, not this exited one. */
memcpy(&exception_jmpbuf, &saved_exception_jmpbuf, sizeof(jmp_buf));
/* Ape memcard fix #3: restore the resume-PC latch cleared at handler entry
* (see above). Sits with the other post-dispatch-loop restores so an
* RFE/RestoreState longjmp — which lands in the setjmp loop above — can't
* strand it at 0. */
if (s_clear_resume_latch) g_dirty_safe_resume_pc = saved_dirty_resume_pc;
g_exec_phase = prev_exec_phase;
/* 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).
*
* The host restore preserves the interrupted context across a transparent
* same-thread interrupt, except for architectural post-handler k0 on the
* qualified real-RFE path below. 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 (+ PC match)
* Offline default remains mode 1. Netplay (env unset) uses mode 3:
* same-TCB RFE/SYSCALL always restores — MotK menu Replay forked only
* v0 when one peer's PC heuristic missed and left BIOS v0=1 while the
* other restored the wait-loop load (0x5bd2) with matched RAM/cycles. */
static int s_str_mode_env = -2; /* -2 unset; -1 = auto */
int s_str_mode;
if (s_str_mode_env == -2) {
const char *e = getenv("PSX_SAME_THREAD_RESTORE");
s_str_mode_env = (e && *e) ? atoi(e) : -1;
if (s_str_mode_env < -1 || s_str_mode_env > 3) s_str_mode_env = -1;
}
{
extern int psx_netplay_active(void);
extern int psx_selfcheck_enabled(void);
if (s_str_mode_env >= 0)
s_str_mode = s_str_mode_env;
else if (psx_netplay_active() || psx_selfcheck_enabled())
s_str_mode = 3; /* netplay/selfcheck: TCB-stable always restore */
else
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;
} else if (s_str_mode == 3) {
/* Netplay: PCB[0] unmoved ⇒ same thread. Ignore exit-PC noise that
* made mode-1 restore asymmetrically across peers (v0-only MotK
* Replay forks). Genuine ChangeThread still skips (TCB moved). */
same_thread_resume =
(g_exc_escape_reason == PSX_EXC_ESCAPE_RFE_RETURN ||
g_exc_escape_reason == PSX_EXC_ESCAPE_SYSCALL_RETURN) &&
entry_tcb != 0u && entry_tcb == exit_tcb;
}
/* Same-thread completion of a SENTINEL (legacy) delivery via an explicit
* guest RFE / ReturnFromException. The guest TCB never holds the host
* sentinel (psx_assert_no_sentinel_pc), so the kernel's restore resumed
* cpu->pc at an APPROXIMATE saved PC (typically a stale earlier dirty
* boundary) — but with PCB[0] unmoved the TRUE continuation of the
* interrupted code is the LIVE host chain below this frame. Exit exactly
* like the legacy sentinel detector: restore the interrupted GPRs and
* return pc=0 so the interrupted compiled leader continues natively.
* Without this the approximate PC surfaces up the trampoline, severs the
* live chain, and one hop later lands on an un-re-enterable mid-function
* PC — the top-level "execution completed, PC=0" abnormal exit (Tomba 2
* splash, frame 1385). A GENUINE in-handler switch (PCB[0] moved) skips
* this and is honored/deferred by the scheduler block below.
*
* Exception: after netplay RB flush_resume, dispatch is top-level — there
* is no live native chain. Publishing pc=0 there is GUEST_EXIT. Prefer the
* compiled IRQ resume PC (or keep the post-RFE guest PC). */
if (g_exception_real_epc == (uint32_t)PSX_EXC_SENTINEL_PC &&
entry_tcb != 0u && entry_tcb == exit_tcb &&
(g_exc_escape_reason == PSX_EXC_ESCAPE_RFE_RETURN ||
g_exc_escape_reason == PSX_EXC_ESCAPE_SYSCALL_RETURN)) {
same_thread_resume = 1;
{
extern int psx_scheduler_top_level_resume_active(void);
if (psx_scheduler_top_level_resume_active()) {
uint32_t resume = s_compiled_interrupt_resume_pc;
if (resume == 0u)
resume = s_last_interrupt_check_pc;
if (resume != 0u)
cpu->pc = resume;
/* else keep post-RFE cpu->pc — never publish 0 */
} else {
cpu->pc = 0; /* continue the interrupted live chain */
}
}
}
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->v0_exit = cpu->gpr[2];
e->v0_saved = saved_gpr[2];
e->v1_exit = cpu->gpr[3];
e->v1_saved = saved_gpr[3];
e->ra_exit = cpu->gpr[31];
e->ra_saved = saved_gpr[31];
e->exit_cycle = psx_get_cycle_count();
{
const uint64_t spent = e->exit_cycle - e->cycle;
const uint32_t pend = (e->istat & e->imask) & 0x7FFu;
g_exc_cycles_total += spent;
g_exc_exits_total++;
if (!pend) { g_exc_src_deliveries[11]++; g_exc_src_cycles[11] += spent; }
for (int b = 0; b < 11; b++) {
if (pend & (1u << b)) {
g_exc_src_deliveries[b]++;
g_exc_src_cycles[b] += spent;
}
}
}
}
/* A real same-thread RFE leaves the handler's jump register live.
* Preserve its actual result only for the primary mode-1 return; legacy,
* syscall, nested, changed-target/global-EPC and other restore modes keep
* the old copy. A RestoreState redirect that returns to the original EPC
* with the same TCB can still qualify. */
const int keep_handler_k0 = do_restore && s_str_mode == 1 &&
same_thread_resume && g_exc_escape_reason == PSX_EXC_ESCAPE_RFE_RETURN &&
prev_in_exception == 0 && entry_tcb != 0u && entry_tcb == exit_tcb &&
delivered_epc != 0u && delivered_epc != (uint32_t)PSX_EXC_SENTINEL_PC &&
same_guest_pc(cpu->pc, delivered_epc) &&
same_guest_pc(g_exception_real_epc, delivered_epc);
if (do_restore) {
for (int i = 0; i < 32; i++)
if (i != 26 || !keep_handler_k0) 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) {
/* unclaimed: give main code guest-time to install handlers */
post_exception_cooldown_until = psx_get_cycle_count() + POST_EXC_UNCLAIMED_COOLDOWN_CYCLES;
} 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_until = sio_active
? 0 /* SIO: no cooldown — card reads need immediate back-to-back IRQs */
: psx_get_cycle_count() + POST_EXC_CLAIMED_COOLDOWN_CYCLES;
}
if (g_ls_suppress_record > 0) g_ls_suppress_record--;
/* In-exception thread switch (Ape Escape NEW GAME memcard scene).
*
* A game can run its OWN cooperative scheduler inside the kernel exception
* handler: its VBlank/exception callback moves dword_108->entry (PCB[0], the
* current-thread pointer) to the next thread, and ReturnFromException (0xF40)
* then restores THAT thread. On real hardware the RFE atomically switches
* execution to the new thread. (docs/psx_bios_disasm.txt: both ExceptionHandler
* 0xC80 and ReturnFromException 0xF40 key off dword_108->entry.)
*
* Under the HLE TCB scheduler the interrupted thread is running via a
* structured dispatch on the host stack. Returning normally here does NOT
* transfer to the new thread — the generated block leader just falls through
* to the interrupted thread's next block (it never re-reads cpu->pc). The
* interrupted thread therefore keeps physically executing while PCB[0] already
* names a different thread; a subsequent exception then saves the running
* (old) thread's EPC into the NEW thread's TCB, corrupting it. That is the
* Ape "Checking… MEMORY CARD" wedge: the transition thread's ec8 PC is written
* into main's TCB EPC, main is later RFE'd there with main's regs, and its
* store lands on the transition thread's TCB status word (kill → target_missing
* forever). See ape_thread_smear_rootcause.md.
*
* Honor the switch exactly as a cooperative ChangeThread is honored: unwind to
* psx_scheduler_run and dispatch whatever PCB[0] now points at, from its
* committed TCB context (the guest handler already saved the interrupted
* thread into ITS own TCB before the switch). Gated to the OUTERMOST exception
* (prev_in_exception==0 → no live outer exception_jmpbuf frame to skip) and to
* the HLE scheduler (the legacy fiber bridge has no g_scheduler_jmpbuf and
* switches threads by fiber). */
if (prev_in_exception == 0 && psx_hle_scheduler_enabled() &&
entry_tcb != 0u && exit_tcb != 0u && entry_tcb != exit_tcb) {
extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */
uint32_t new_state = psx_read_word(exit_tcb & 0x1FFFFFFFu);
if (new_state == 0x4000u) { /* the new current thread must be runnable */
/* A native-only shadow pass must never commit the handler's TCB/RAM
* changes or escape past the authoritative restore. Bail before
* configuring either immediate or deferred scheduler state. */
extern int overlay_loader_shadow_native_thread_switch_bail(void);
if (overlay_loader_shadow_native_thread_switch_bail())
PSX_CHECK_INTERRUPTS_RETURN();
/* Fix #2: honor the switch ONLY at the outermost dispatch boundary,
* where the outgoing thread's guest state is fully materialized in
* CPUState. Nested inside a host call unit / dirty pump, the outgoing
* thread's PC lives only in the resume-PC latch, which desyncs from the
* live GPRs when the IRQ was taken deep inside a nested call (the
* async-poll callback) — switching there commits a poisoned TCB context
* and the resumed thread faults on a smeared jumptable index. */
extern int g_call_unit_depth;
int at_outermost = (g_psx_dispatch_depth == 0 && g_call_unit_depth == 0);
/* Defer only when nested AND the outgoing thread has a valid resume PC
* of its OWN (g_exception_real_epc — the architectural EPC latched for
* THIS delivery). The guest handler's in-exception ReturnFromException
* (traps.c syscall 3, in_exception path) has already restored the TARGET
* thread's GPRs and set cpu->pc to the target's EPC; to resume the
* OUTGOING thread transparently we must put cpu->pc back to ITS resume PC
* as well, not leave it pointing at the target. If we have no clean
* outgoing resume PC (sentinel / clean-trampoline boundary), or the
* toggle is off, or we are already outermost, switch immediately. */
/* Low BIOS/kernel code is already scheduler code; deferring it can
* starve a target thread by re-entering the same VBlank EPC forever. */
uint32_t epc_phys = g_exception_real_epc & 0x1FFFFFFFu;
int low_kernel_epc = (epc_phys < 0x00010000u);
/* Low BIOS/kernel code is already scheduler code; deferring it can
* starve a target thread by re-entering the same VBlank EPC forever.
* (Card-guard overrides were tried for Ape LOAD and did not help —
* tip already defers like master; the hang is post-probe arming.) */
int can_defer = defer_switch_enabled() && !low_kernel_epc && !at_outermost &&
g_exception_real_epc != 0u &&
(g_exception_real_epc & 0x3u) == 0u &&
g_exception_real_epc != (uint32_t)PSX_EXC_SENTINEL_PC &&
psx_is_dispatchable(g_exception_real_epc);
if (!can_defer) {
/* Outermost / no clean deferral: honor immediately — the guest
* handler's save of the outgoing thread is consistent here (or this
* is the pre-existing baseline path). */
debug_server_log_thread_event(30, cpu, entry_tcb, exit_tcb, cpu->pc);
/* The longjmp skips psx_check_interrupts_at's restore of the compiled
* resume-PC latch; clear it so the next thread's block-leader delivery
* recomputes real_pc cleanly rather than inheriting a stale value. */
s_compiled_interrupt_resume_pc = 0;
/* This escape uses g_scheduler_jmpbuf (unwinding to psx_scheduler_run),
* NOT exception_jmpbuf — so it BYPASSES the landing above that restores
* g_dirty_interp_active. From the dirty-RAM entry poll the flag is 1
* and would LEAK across the unwind; we abandon the dirty-interp
* dispatch here, so it MUST be 0 (dirty_ram_interp.c ~205-210 contract;
* no-op from compiled code where the flag was already 0). */
g_dirty_interp_active = 0;
g_sched_escape.target_tcb = exit_tcb;
g_sched_escape.resume_pc = 0;
g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB;
longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run; never returns */
}
/* Nested in host dispatch: DEFER. The guest handler has already saved a
* POISONED context for the outgoing thread (latched PC ahead of the live
* registers), but we do NOT switch off it. Instead resume the outgoing
* thread transparently — restoring its clean pre-exception GPRs/HI/LO/SR
* (do_restore above ran false for this genuine cross-thread case) — so it
* finishes its nested calls; record the switch as pending and honor it at
* the next outermost boundary (top of this function), re-saving the thread
* cleanly there. Keep PCB[0] naming the STILL-RUNNING thread during the
* interim so any interim exception saves into the right TCB (closes the
* mis-save window the immediate switch used to avoid); the guest's intent
* to run exit_tcb is preserved as the pending switch. Coalesces: the newest
* guest ChangeThread wins the target. */
for (int i = 0; i < 32; i++) cpu->gpr[i] = saved_gpr[i];
cpu->hi = saved_hi;
cpu->lo = saved_lo;
cpu->cop0[COP0_SR] = sr; /* pre-exception SR (IEc was set — gate above) */
cpu->pc = g_exception_real_epc; /* resume the OUTGOING thread at ITS own PC,
* not the target PC the in-exception RFE
* left in cpu->pc — else it runs the target's
* code under the outgoing thread and never
* reaches its own block leaders to be honored. */
psx_sched_set_current_tcb(cpu, entry_tcb);
s_defer_switch_from = entry_tcb;
s_defer_switch_target = exit_tcb;
s_defer_switch_pending = 1;
s_compiled_interrupt_resume_pc = 0;
debug_server_log_thread_event(31, cpu, entry_tcb, exit_tcb, cpu->pc);
/* fall through: return normally — the outgoing thread resumes. */
}
}
#ifdef PSX_COSIM
#undef COSIM_IRQ_NOTE
#undef COSIM_IRQ_TAKE_PC
#endif
if (np_present_after_irq && !s_defer_switch_pending)
gpu_vblank_flush_present();
#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); /* flushes load-charge batch on entry */
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);
}