Files
psxrecomp/runtime/src/traps.c
T

1428 lines
62 KiB
C

/* traps.c — Phase 2 trap handlers.
*
* All traps abort with a diagnostic message.
* Phase 2 expects MMIO abort before any trap fires.
*/
#include "cpu_state.h"
#include "psx_bss.h"
#include "psx_runtime.h" /* fix B: psx_exc_escape_reason_t + g_exc_escape_reason */
#include "debug_server.h"
#include "crash_trace.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <setjmp.h>
#include "psx_fiber.h" /* cross-platform fibers (Win32 fibers / POSIX ucontext) */
#include "psx_scheduler.h" /* deterministic TCB scheduler carve-out (scaffolding) */
#include "parity_trace.h" /* general two-process control-flow parity ring */
/* RAM reader adapter for the parity trace (cpu->read_word takes only addr). */
static uint32_t traps_parity_rw(void* ctx, uint32_t addr) {
return ((CPUState*)ctx)->read_word(addr);
}
/* Forward declarations from interrupts.c */
int psx_get_in_exception(void);
void psx_exception_longjmp(void);
void psx_irq_refresh_cause_ip2(void);
/* ── Deterministic TCB scheduler — SCAFFOLDING (plan steps 1-2, INERT) ───────
* These definitions back psx_scheduler.h. They are NOT yet wired into the live
* thread-switch path: psx_change_thread_fiber (the host-fiber bridge) below is
* still authoritative. Later steps add the outer scheduler loop, route
* ChangeThread / ReturnFromException through structured escapes (longjmp to
* g_scheduler_jmpbuf with a psx_run_reason_t), and remove the cpu->pc=0
* host-control signalling + per-frame fiber recreation. Exported (external
* linkage) so the inert symbols don't trip unused-symbol checks. */
jmp_buf g_scheduler_jmpbuf;
psx_sched_escape_t g_sched_escape;
/* Single-level "switch back to the yielder" safety net. The old fiber bridge
* remembered each thread's return_fiber so a target whose dispatch RETURNS
* (rather than yielding back) resumed its yielder instead of killing the run.
* In the redispatch model we keep the same one-level notion: the yielder's TCB
* latched at the last YIELD_TO_TCB, restored if the target's top-level dispatch
* returns. Zero = no pending return target (top-level pc==0 is a real exit). */
static uint32_t g_sched_return_tcb = 0;
/* sched_escape_ring (plan step 8) — always-on record of every structured
* scheduler escape, queryable via the `sched_escape_ring` debug command. This
* is the deterministic replacement for the fiber thread_event kinds 3-12. */
typedef struct SchedEscapeEntry {
uint32_t seq;
uint32_t frame;
uint32_t reason; /* psx_run_reason_t */
uint32_t current_tcb;
uint32_t target_tcb;
uint32_t resume_pc;
uint32_t pc;
uint32_t ra;
uint32_t sp;
} SchedEscapeEntry;
#define SCHED_ESCAPE_RING_CAP 256u
PSX_BSS SchedEscapeEntry g_sched_escape_ring[SCHED_ESCAPE_RING_CAP];
uint64_t g_sched_escape_seq = 0;
static void sched_escape_ring_log(CPUState* cpu, uint32_t reason,
uint32_t current_tcb, uint32_t target_tcb,
uint32_t resume_pc)
{
extern uint64_t s_frame_count;
SchedEscapeEntry* e =
&g_sched_escape_ring[g_sched_escape_seq & (SCHED_ESCAPE_RING_CAP - 1u)];
e->seq = (uint32_t)g_sched_escape_seq;
e->frame = (uint32_t)s_frame_count;
e->reason = reason;
e->current_tcb = current_tcb;
e->target_tcb = target_tcb;
e->resume_pc = resume_pc;
e->pc = cpu->pc;
e->ra = cpu->gpr[31];
e->sp = cpu->gpr[29];
g_sched_escape_seq++;
}
int psx_is_dispatchable(uint32_t pc)
{
/* Fail closed on the known-bad resume PCs that the old pc=0 sentinel +
* sentinel-EPC pathologies produced. A nonzero, non-sentinel PC is treated
* as potentially dispatchable for now; a later step tightens this to "is a
* registered function entry / re-enterable block leader" via the dispatch
* tables (psx_game_is_function_entry + the BIOS dispatch table). */
if (pc == 0u) return 0;
if (pc == PSX_EXC_SENTINEL_PC) return 0;
return 1;
}
/* Dispatch call contract state (see cpu_state.h for the model). */
int g_psx_call_bail = 0;
uint64_t g_psx_bail_first = 0;
uint64_t g_psx_bail_resolved = 0;
uint64_t g_psx_bail_flattened = 0;
uint64_t g_psx_bail_anomaly = 0;
/* ---- Deduped bail-source ledger (Tomba 2 wild-return / splash-spin diag) ----
* psx_call_contract (cpu_state.h) calls psx_bail_record() at each FIRST-detected
* contract violation with the call site's expected return address (site_ra — the
* caller's continuation, i.e. the GAME wait-site that called into the kernel) and
* the guest's actual wild target ($ra). We aggregate by (site_ra, wild_pc) and
* surface the dominant key + unique-key count in the freeze heartbeat, so the
* wild-return SOURCE is readable even when the bail storm saturates the main
* thread and the window goes "Not Responding". unique_keys==1 ⇒ one bad call site
* (a real contract bug); many ⇒ normal trampolines (just the cost of the spin). */
extern uint64_t s_frame_count;
#define BAIL_LEDGER_SLOTS 1024u
typedef struct {
uint32_t site_ra; /* expected return = caller continuation (game wait-site) */
uint32_t wild_pc; /* guest's actual $ra = wild-return destination */
uint32_t site_sp;
uint32_t guest_sp;
uint64_t count;
uint32_t first_frame;
uint8_t used;
} BailLedgerSlot;
static BailLedgerSlot s_bail_ledger[BAIL_LEDGER_SLOTS];
static uint32_t s_bail_unique = 0;
/* First-ever wild return, latched (never overwritten) — the trigger instance. */
uint32_t g_bail_first_site_ra = 0, g_bail_first_wild_pc = 0;
uint32_t g_bail_first_site_sp = 0, g_bail_first_guest_sp = 0, g_bail_first_frame = 0;
/* Exception context at the first bail — confirms the "async interrupt delivered
* mid-dirty-call" mechanism: in_exc=1 means the trigger bail happened while an
* exception was being handled (= interrupt landed mid-overlay-call). */
uint32_t g_bail_first_in_exc = 0;
static int s_bail_first_latched = 0;
void psx_bail_record(uint32_t site_ra, uint32_t site_sp,
uint32_t wild_pc, uint32_t guest_sp) {
if (!s_bail_first_latched) {
s_bail_first_latched = 1;
g_bail_first_site_ra = site_ra; g_bail_first_wild_pc = wild_pc;
g_bail_first_site_sp = site_sp; g_bail_first_guest_sp = guest_sp;
g_bail_first_frame = (uint32_t)s_frame_count;
g_bail_first_in_exc = (uint32_t)psx_get_in_exception();
}
uint32_t h = (site_ra * 2654435761u) ^ (wild_pc * 40503u);
uint32_t start = h & (BAIL_LEDGER_SLOTS - 1u);
for (uint32_t i = 0; i < 16u; i++) {
BailLedgerSlot *s = &s_bail_ledger[(start + i) & (BAIL_LEDGER_SLOTS - 1u)];
if (s->used && s->site_ra == site_ra && s->wild_pc == wild_pc) {
s->count++;
return;
}
if (!s->used) {
s->used = 1; s->site_ra = site_ra; s->wild_pc = wild_pc;
s->site_sp = site_sp; s->guest_sp = guest_sp;
s->count = 1; s->first_frame = (uint32_t)s_frame_count;
s_bail_unique++;
return;
}
}
/* Probe window exhausted (>16 collisions): fold into the hashed slot so the
* total stays honest even if the precise key is lost. */
s_bail_ledger[start].count++;
}
/* Read the highest-count ledger entry (the dominant wild-return source). */
void psx_bail_ledger_top(uint32_t *site_ra, uint32_t *wild_pc, uint32_t *site_sp,
uint32_t *guest_sp, uint64_t *count, uint32_t *unique) {
uint64_t best = 0; const BailLedgerSlot *bs = NULL;
for (uint32_t i = 0; i < BAIL_LEDGER_SLOTS; i++) {
if (s_bail_ledger[i].used && s_bail_ledger[i].count > best) {
best = s_bail_ledger[i].count; bs = &s_bail_ledger[i];
}
}
if (site_ra) *site_ra = bs ? bs->site_ra : 0;
if (wild_pc) *wild_pc = bs ? bs->wild_pc : 0;
if (site_sp) *site_sp = bs ? bs->site_sp : 0;
if (guest_sp) *guest_sp = bs ? bs->guest_sp : 0;
if (count) *count = best;
if (unique) *unique = s_bail_unique;
}
/* ---- $ra->1 corruption tripwire (Tomba 2 freeze confirm-first probe) ----
* Latch the EXACT first moment guest $ra transitions to 0x00000001 (the degenerate
* spin state $ra=$sp=1), tagged with the SITE that did it. Pins whether the clobber
* is an overlay instruction (interp), the exception GPR-restore (exc-exit), or the
* bail-flatten. Surfaced in the heartbeat. Site: 0=INTERP 1=EXC_EXIT 2=other. */
uint32_t g_ra_tw_latched = 0;
uint32_t g_ra_tw_site = 0; /* which site clobbered $ra->1 */
uint32_t g_ra_tw_pc = 0; /* guest PC at the clobber */
uint32_t g_ra_tw_prev_ra = 0; /* $ra value immediately before */
uint32_t g_ra_tw_frame = 0;
uint32_t g_ra_tw_in_exc = 0;
uint32_t g_ra_tw_sp = 0;
void psx_ra_tripwire(CPUState *cpu, uint32_t prev_ra, uint32_t pc, uint32_t site) {
if (g_ra_tw_latched) return;
if (cpu->gpr[31] == 1u && prev_ra != 1u) {
g_ra_tw_latched = 1;
g_ra_tw_site = site;
g_ra_tw_pc = pc;
g_ra_tw_prev_ra = prev_ra;
g_ra_tw_frame = (uint32_t)s_frame_count;
g_ra_tw_in_exc = (uint32_t)psx_get_in_exception();
g_ra_tw_sp = cpu->gpr[29];
}
}
static void trap_crash(const char* msg) {
FILE* cf = fopen("psx_crash.txt", "w");
if (cf) { fprintf(cf, "%s\n", msg); fclose(cf); }
/* Full ring dump, then halt-and-serve (debug builds) / exit(1)
* (release). Never returns — call sites' trailing exit(1) are
* unreachable belt-and-braces. */
psx_fatal_halt(msg);
}
static int psx_is_valid_tcb(CPUState* cpu, uint32_t tcb)
{
uint32_t base = cpu->read_word(0x00000110u);
uint32_t size = cpu->read_word(0x00000114u);
if (base == 0 || size == 0) return 0;
uint32_t offset = tcb - base;
return offset < size && (offset % 0xC0u) == 0;
}
/* ChatGPT-conferred 2026-06-29 "slice-exit provenance": records WHY cpu->pc became 0
* when a thread fiber's psx_dispatch returns (fiber_dispatch_exit). Distinguishes a
* legitimate GUEST return (the guest's own jr/function-return left pc=0 — normal
* per-frame recreate, fiber theory dead) from a HOST artifact (fiber-switch resume,
* dispatch miss, etc.). Reset to GUEST_RETURN before each psx_dispatch slice; the
* artifact sites override it. Logged as the value of the kind-13/26 fiber_dispatch_exit
* thread event (was cpu->pc, which is always 0 there and uninformative). */
enum {
PSX_PC0_GUEST_RETURN = 0, /* default: guest jr/function-return set pc=0 */
PSX_PC0_CHANGE_SELF = 1, /* psx_change_thread_fiber current==target */
PSX_PC0_FIBER_SWITCH = 2, /* psx_change_thread_fiber after psx_fiber_switch resume */
PSX_PC0_CRIT_SECTION = 3, /* EnterCriticalSection/ExitCriticalSection */
PSX_PC0_DISPATCH_MISS = 4, /* psx_unknown_dispatch couldn't resolve a target */
};
uint32_t g_pc0_reason = PSX_PC0_GUEST_RETURN;
static uint32_t psx_current_tcb_ptr(CPUState* cpu)
{
uint32_t tcbh = cpu->read_word(0x00000108u);
return tcbh ? cpu->read_word(tcbh) : 0;
}
static void psx_set_current_tcb(CPUState* cpu, uint32_t tcb)
{
uint32_t tcbh = cpu->read_word(0x00000108u);
if (tcbh) cpu->write_word(tcbh, tcb);
}
/* Save/restore PC ring — diagnoses thread-resume drift.
*
* Each thread switch logs the (op, tcb, pc, sp, ra) tuple at save and
* restore. Read back via debug_server "thread_ctx_ring" to see whether
* a thread's saved resume_pc matches the one we later restore. If a
* save/restore pair for the same TCB shows different resume_pc with no
* intervening progress, the recompiled code never advanced and we have
* a thread-state corruption signal. */
typedef struct ThreadCtxRingEntry {
uint32_t seq;
uint32_t frame;
uint8_t op; /* 0=save 1=restore */
uint8_t pad0[3];
uint32_t tcb;
uint32_t resume_pc;/* save: passed-in resume_pc; restore: read from TCB+128 */
uint32_t gpr_29; /* sp */
uint32_t gpr_31; /* ra */
uint32_t cop0_sr;
uint32_t cop0_epc;
} ThreadCtxRingEntry;
#define THREAD_CTX_RING_CAP 256u
PSX_BSS ThreadCtxRingEntry g_thread_ctx_ring[THREAD_CTX_RING_CAP];
uint64_t g_thread_ctx_ring_seq = 0;
extern uint64_t s_frame_count;
static void thread_ctx_ring_log(CPUState* cpu, uint32_t tcb,
uint32_t resume_pc, uint8_t op)
{
ThreadCtxRingEntry* e = &g_thread_ctx_ring[g_thread_ctx_ring_seq & (THREAD_CTX_RING_CAP - 1u)];
e->seq = (uint32_t)g_thread_ctx_ring_seq;
e->frame = (uint32_t)s_frame_count;
e->op = op;
e->tcb = tcb;
e->resume_pc = resume_pc;
e->gpr_29 = cpu->gpr[29];
e->gpr_31 = cpu->gpr[31];
e->cop0_sr = cpu->cop0[12];
e->cop0_epc = cpu->cop0[14];
g_thread_ctx_ring_seq++;
}
/* Fail-closed: the host exception sentinel must NEVER be persisted into a TCB as a
* thread's resume PC, nor read back out of one. Fix B made the interrupted resume PC
* the REAL guest PC; if a sentinel ever lands in a TCB EPC slot again it means the
* legacy boundary-fallback (interrupts.c) leaked into a SAVED (cross-thread) context,
* which would silently mis-resume that thread through stale host escape state. Halt
* loudly instead of resuming wrong. Never returns on a hit. */
static void psx_assert_no_sentinel_pc(const char* where, uint32_t tcb, uint32_t pc)
{
if (pc == PSX_EXC_SENTINEL_PC) {
char buf[160];
snprintf(buf, sizeof(buf),
"fatal: exception sentinel 0x%08X persisted in TCB 0x%08X resume PC (%s)",
(unsigned)pc, (unsigned)tcb, where);
trap_crash(buf);
}
}
static void psx_save_context_to_tcb(CPUState* cpu, uint32_t tcb, uint32_t resume_pc)
{
psx_assert_no_sentinel_pc("save_context_to_tcb", tcb, resume_pc);
uint32_t save = tcb + 8u;
uint32_t sr = cpu->cop0[12];
for (int i = 1; i < 32; i++) {
if (i == 26) continue; /* k0 is the BIOS restore jump register. */
cpu->write_word(save + (uint32_t)i * 4u, cpu->gpr[i]);
}
cpu->write_word(save + 128u, resume_pc);
cpu->write_word(save + 132u, cpu->hi);
cpu->write_word(save + 136u, cpu->lo);
cpu->write_word(save + 140u, (sr & ~0x3Fu) | ((sr & 0x0Fu) << 2));
cpu->write_word(save + 144u, cpu->cop0[13]);
thread_ctx_ring_log(cpu, tcb, resume_pc, 0);
debug_server_log_thread_event(1, cpu, tcb, tcb, resume_pc);
}
static uint32_t psx_restore_context_from_tcb(CPUState* cpu, uint32_t tcb)
{
uint32_t save = tcb + 8u;
for (int i = 1; i < 32; i++) {
if (i == 26) continue;
cpu->gpr[i] = cpu->read_word(save + (uint32_t)i * 4u);
}
cpu->hi = cpu->read_word(save + 132u);
cpu->lo = cpu->read_word(save + 136u);
{
uint32_t saved_sr = cpu->read_word(save + 140u);
cpu->cop0[12] = (saved_sr & 0xFFFFFFC0u) | ((saved_sr >> 2) & 0x0Fu);
}
/* Restore the saved CAUSE, then re-derive IP2. On hardware the IP field is
* live interrupt-line state and is never storage, so it cannot meaningfully
* be saved and restored: a context captured while an interrupt was pending
* and restored after the ack would re-latch a phantom IP2 out of guest
* memory. Reload the architecturally-saved bits, then let the single owner
* recompute bit 10 from the actual line. */
cpu->cop0[13] = cpu->read_word(save + 144u);
psx_irq_refresh_cause_ip2();
cpu->gpr[26] = cpu->read_word(save + 128u);
psx_assert_no_sentinel_pc("restore_context_from_tcb", tcb, cpu->gpr[26]);
thread_ctx_ring_log(cpu, tcb, cpu->gpr[26], 1);
debug_server_log_thread_event(2, cpu, psx_current_tcb_ptr(cpu), tcb, cpu->gpr[26]);
return cpu->gpr[26];
}
/* Deferred cooperative thread switch (Ape Escape memcard fix #2).
*
* A genuine in-exception ChangeThread (interrupts.c kind-30) must be honored at
* the OUTERMOST dispatch boundary, never mid-nested-host-dispatch: only at the
* outermost boundary is the interrupted thread's guest state fully materialized
* in CPUState (cpu->pc == the true block PC, GPRs live) rather than split across
* a stale resume-PC latch + the host C call stack. Switching mid-nest saves a
* poisoned TCB context (latched PC ahead of the live registers) → the resumed
* thread dispatches a jumptable with a smeared index → misaligned DISPATCH FATAL.
*
* These thin exports let the interrupt path (interrupts.c) re-save the deferred
* thread cleanly and re-point PCB[0] at the outermost boundary, using the same
* TCB layout the scheduler save/restore already owns. */
void psx_sched_save_context(CPUState* cpu, uint32_t tcb, uint32_t resume_pc)
{
psx_save_context_to_tcb(cpu, tcb, resume_pc);
}
void psx_sched_set_current_tcb(CPUState* cpu, uint32_t tcb)
{
psx_set_current_tcb(cpu, tcb);
}
uint32_t psx_sched_current_tcb(CPUState* cpu)
{
return psx_current_tcb_ptr(cpu);
}
typedef struct HostThreadFiber {
uint32_t tcb;
psx_fiber_t fiber;
psx_fiber_t return_fiber;
uint32_t return_tcb;
CPUState* cpu;
int used;
int owned;
int closed;
} HostThreadFiber;
static HostThreadFiber s_host_threads[32];
static psx_fiber_t s_main_fiber;
static uint32_t psx_tcb_state(CPUState* cpu, uint32_t tcb)
{
return psx_is_valid_tcb(cpu, tcb) ? cpu->read_word(tcb) : 0;
}
static psx_fiber_t psx_current_host_fiber(void)
{
if (!s_main_fiber) {
s_main_fiber = psx_fiber_convert_thread();
if (!s_main_fiber) {
trap_crash("psx_fiber_convert_thread failed");
exit(1);
}
}
return psx_fiber_current();
}
static HostThreadFiber* psx_find_host_thread(uint32_t tcb)
{
for (size_t i = 0; i < sizeof(s_host_threads) / sizeof(s_host_threads[0]); i++) {
if (s_host_threads[i].used && s_host_threads[i].tcb == tcb) {
return &s_host_threads[i];
}
}
return NULL;
}
static HostThreadFiber* psx_alloc_host_thread(void)
{
for (size_t i = 0; i < sizeof(s_host_threads) / sizeof(s_host_threads[0]); i++) {
if (!s_host_threads[i].used) {
memset(&s_host_threads[i], 0, sizeof(s_host_threads[i]));
s_host_threads[i].used = 1;
return &s_host_threads[i];
}
}
trap_crash("BIOS thread fiber table full");
exit(1);
}
static HostThreadFiber* psx_bind_current_host_thread(CPUState* cpu, uint32_t tcb)
{
psx_fiber_t fiber = psx_current_host_fiber();
HostThreadFiber* slot = psx_find_host_thread(tcb);
if (!slot) {
slot = psx_alloc_host_thread();
}
slot->tcb = tcb;
slot->fiber = fiber;
slot->cpu = cpu;
slot->used = 1;
slot->owned = (fiber != s_main_fiber);
slot->closed = 0;
return slot;
}
static void psx_thread_fiber_entry(void* param)
{
HostThreadFiber* slot = (HostThreadFiber*)param;
CPUState* cpu = slot->cpu;
debug_server_log_thread_event(10, cpu, psx_current_tcb_ptr(cpu), slot->tcb, 0);
psx_set_current_tcb(cpu, slot->tcb);
uint32_t target_pc = psx_restore_context_from_tcb(cpu, slot->tcb);
psx_assert_no_sentinel_pc("thread_fiber_entry dispatch", slot->tcb, target_pc);
g_pc0_reason = PSX_PC0_GUEST_RETURN; /* default; artifact sites override during dispatch */
if (target_pc != 0) {
psx_dispatch(cpu, target_pc);
}
/* Tomba2 loader-thread diagnosis (Patch 1, trace-only): record the dispatch
* EXIT state for this thread's fiber. kind 13 = psx_dispatch returned with
* in_exception==0; kind 26 = with in_exception==1. target_pc field carries the
* FINAL cpu->pc the dispatch returned with (0 = sentinel/clean-exit path; any
* non-zero = a surfaced transfer). This is the decisive "exit reason" (vs the
* garbage ra): if the worker thread's dispatch returns with cpu->pc==0 / a
* surfaced transfer rather than a verified thread-close, closing the fiber here
* is wrong. */
/* value = g_pc0_reason (slice-exit provenance) instead of cpu->pc (always 0 here). */
debug_server_log_thread_event(psx_get_in_exception() ? 26u : 13u, cpu,
psx_current_tcb_ptr(cpu), slot->tcb, g_pc0_reason);
debug_server_log_thread_event(11, cpu, psx_current_tcb_ptr(cpu), slot->tcb, target_pc);
slot->closed = 1;
/* Guest BIOS code owns the TCB state. A host fiber can finish because a
* thread returned through the BIOS scheduler path, but changing the BIOS
* TCB here races with games that keep that thread runnable. */
if (slot->return_fiber) {
if (psx_is_valid_tcb(cpu, slot->return_tcb)) {
psx_set_current_tcb(cpu, slot->return_tcb);
(void)psx_restore_context_from_tcb(cpu, slot->return_tcb);
}
debug_server_log_thread_event(12, cpu, slot->tcb, slot->return_tcb, 0);
psx_fiber_switch(slot->return_fiber);
}
trap_crash("BIOS thread fiber returned with no scheduler target");
exit(1);
}
static HostThreadFiber* psx_get_or_create_host_thread(CPUState* cpu, uint32_t tcb)
{
HostThreadFiber* slot = psx_find_host_thread(tcb);
if (psx_tcb_state(cpu, tcb) != 0x4000u) {
return NULL;
}
if (slot && slot->fiber && !slot->closed) {
return slot;
}
if (!slot) {
slot = psx_alloc_host_thread();
} else if (slot->fiber && slot->owned && slot->fiber != psx_fiber_current()) {
psx_fiber_destroy(slot->fiber);
slot->fiber = NULL;
}
slot->tcb = tcb;
slot->cpu = cpu;
slot->return_fiber = NULL;
slot->return_tcb = 0;
slot->owned = 1;
slot->closed = 0;
/* Guest-thread fiber stack. Default 1 MB; PSX_FIBER_STACK_KB overrides it so
* the long-run-freeze repro can be run at 0.5x/2x stack (RECURSION_BUG.md §17
* orthogonal test): if the overflow frame scales ~linearly with stack size,
* the freeze is gradual host-stack accumulation, not a frame-50k trigger. */
static long s_fiber_kb = -1;
if (s_fiber_kb < 0) {
const char *e = getenv("PSX_FIBER_STACK_KB");
s_fiber_kb = (e && *e) ? atol(e) : 1024;
if (s_fiber_kb < 256) s_fiber_kb = 256;
}
slot->fiber = psx_fiber_create((size_t)s_fiber_kb * 1024u, psx_thread_fiber_entry, slot);
if (!slot->fiber) {
trap_crash("psx_fiber_create failed for BIOS thread");
exit(1);
}
return slot;
}
static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb)
{
uint32_t current_tcb = psx_current_tcb_ptr(cpu);
/* data-shard purity: a green-thread switch inside a capture window would
* record OTHER threads' work into the shard — poison the capture. */
{ extern void ds_note_thread_switch(void); ds_note_thread_switch(); }
debug_server_log_thread_event(3, cpu, current_tcb, target_tcb, 0);
if (!psx_is_valid_tcb(cpu, current_tcb) || !psx_is_valid_tcb(cpu, target_tcb)) {
debug_server_log_thread_event(4, cpu, current_tcb, target_tcb, 0);
return 0;
}
if (current_tcb == target_tcb) {
debug_server_log_thread_event(5, cpu, current_tcb, target_tcb, cpu->gpr[31]);
g_pc0_reason = PSX_PC0_CHANGE_SELF;
cpu->pc = 0;
return 1;
}
/* Never let a speculative native shadow pass switch host/guest threads.
* The authoritative interpreter pass would already have escaped if this
* switch were real; reaching it only in native code proves divergence. */
{
extern int overlay_loader_shadow_native_thread_switch_bail(void);
if (overlay_loader_shadow_native_thread_switch_bail()) return -1;
}
HostThreadFiber* current = psx_bind_current_host_thread(cpu, current_tcb);
int saved_current_context = 0;
if (psx_tcb_state(cpu, current_tcb) == 0x4000u) {
psx_save_context_to_tcb(cpu, current_tcb, cpu->gpr[31]);
saved_current_context = 1;
} else {
/* 0x1000 is the BIOS closed/free state and must retire the host fiber.
* Other non-runnable states can still have a suspended generated C
* stack that must be resumed later. */
if (psx_tcb_state(cpu, current_tcb) == 0x1000u) {
current->closed = 1;
}
debug_server_log_thread_event(6, cpu, current_tcb, target_tcb, 0);
}
HostThreadFiber* target = psx_get_or_create_host_thread(cpu, target_tcb);
if (!target) {
debug_server_log_thread_event(7, cpu, current_tcb, target_tcb, 0);
return 0;
}
target->return_fiber = current->fiber;
target->return_tcb = current_tcb;
psx_set_current_tcb(cpu, target_tcb);
(void)psx_restore_context_from_tcb(cpu, target_tcb);
debug_server_log_thread_event(8, cpu, current_tcb, target_tcb, 0);
psx_fiber_switch(target->fiber);
/* The switch returns on the original native stack, but CPUState is
* shared globally and still contains the fiber that just yielded back.
* Restore the TCB we saved above before continuing on this stack. */
if (saved_current_context && psx_is_valid_tcb(cpu, current_tcb)) {
psx_set_current_tcb(cpu, current_tcb);
(void)psx_restore_context_from_tcb(cpu, current_tcb);
}
/* If a non-owner fiber requested an exception longjmp while we were
* suspended, it deferred it by SwitchToFiber'ing back to us (the
* owner). Honor it now from the correct stack. */
extern void* g_exception_owner_fiber;
extern int g_pending_exception_longjmp;
extern jmp_buf exception_jmpbuf;
if (g_pending_exception_longjmp && psx_fiber_current() == g_exception_owner_fiber) {
int code = g_pending_exception_longjmp;
g_pending_exception_longjmp = 0;
/* Same shadow-frame hardening as deferred_exception_longjmp: this is
* the deferred-honor site — the actual unwind of the OWNER fiber's
* frames happens here, so a live shadow frame on this stack must have
* its globals restored before the jump. */
extern uint64_t g_exc_setjmp_epoch;
extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch);
overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch);
longjmp(exception_jmpbuf, code);
}
debug_server_log_thread_event(9, cpu, target_tcb, current_tcb, 0);
g_pc0_reason = PSX_PC0_FIBER_SWITCH;
cpu->pc = 0;
return 1;
}
/* ── Deterministic TCB scheduler — LIVE (carve-out, replaces the fiber bridge) ─
*
* psx_request_thread_switch performs a cooperative thread switch by committing
* the yielding thread's full context to its TCB, repointing dword_108->entry at
* the target, and longjmp-ing to the outer scheduler loop (psx_scheduler_run),
* which restores the target TCB and re-dispatches its EPC. No host fibers, no
* per-thread native stacks, no cpu->pc=0 host signal — thread selection is a
* pure function of guest TCB state (HLE_SCHEDULER_CARVEOUT_PLAN.md §9).
*
* This only runs with in_exception==0 (psx_syscall case 3 gates it), so the
* longjmp never unwinds through a live setjmp(exception_jmpbuf) frame — the sole
* native state skipped is nested psx_dispatch_impl frames, whose only invariant
* (g_psx_dispatch_depth + g_psx_call_bail) the scheduler loop resets on landing.
*
* INVARIANT (psx_scheduler.h): all guest thread context lives in CPUState / RAM
* / the TCB reg array — never in a host stack. The yielding thread resumes by
* re-dispatching its saved EPC, which therefore MUST be a dispatchable block
* leader (CLASS-B every-leader re-entry guarantees this); psx_is_dispatchable
* fails loud if it ever is not. */
static int psx_request_thread_switch(CPUState* cpu, uint32_t target_tcb)
{
uint32_t current_tcb = psx_current_tcb_ptr(cpu);
debug_server_log_thread_event(3, cpu, current_tcb, target_tcb, 0);
if (!psx_is_valid_tcb(cpu, current_tcb) || !psx_is_valid_tcb(cpu, target_tcb)) {
debug_server_log_thread_event(4, cpu, current_tcb, target_tcb, 0);
return 0; /* invalid TCB(s): caller falls back to the manual-RFE path */
}
if (current_tcb == target_tcb) {
/* Same thread — not a switch. The syscall wrapper returns 0 and falls
* through to its own jr $ra (CPS); the thread simply continues. No
* cpu->pc=0 host signal (removed with the fiber bridge). */
debug_server_log_thread_event(5, cpu, current_tcb, target_tcb, cpu->gpr[31]);
return 1;
}
/* The target's entire context already lives in its TCB; it must be runnable
* (0x4000). A non-runnable target is not a cooperative yield — let the
* manual-RFE fallback handle it rather than dispatch a stale EPC. */
if (psx_tcb_state(cpu, target_tcb) != 0x4000u) {
debug_server_log_thread_event(7, cpu, current_tcb, target_tcb, 0);
return 0;
}
/* Contain a native-only shadow divergence before it mutates TCB/RAM,
* scheduler targets, or crosses run_shadow_diff with a longjmp. */
{
extern int overlay_loader_shadow_native_thread_switch_bail(void);
if (overlay_loader_shadow_native_thread_switch_bail()) return -1;
}
/* Commit the yielding thread's context so it can be re-dispatched later.
* resume PC = its return address (resume right after the ChangeThread call).
* Only a still-running (0x4000) thread keeps a live context; a closed/free
* (0x1000) thread is being torn down and must not be saved. */
if (psx_tcb_state(cpu, current_tcb) == 0x4000u) {
psx_save_context_to_tcb(cpu, current_tcb, cpu->gpr[31]);
}
psx_set_current_tcb(cpu, target_tcb);
g_sched_return_tcb = current_tcb; /* one-level switch-back safety net */
g_sched_escape.target_tcb = target_tcb;
g_sched_escape.resume_pc = 0;
g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB;
sched_escape_ring_log(cpu, PSX_RUN_YIELD_TO_TCB, current_tcb, target_tcb, 0);
debug_server_log_thread_event(8, cpu, current_tcb, target_tcb, 0);
if (parity_trace_is_armed())
parity_trace_record(PARITY_KIND_YIELD, cpu->pc, cpu->gpr[31],
cpu->gpr[29], target_tcb, traps_parity_rw, cpu);
longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run; never returns */
return 1; /* unreachable */
}
/* Save-state restore hook: unwind the entire guest native stack back to
* psx_scheduler_run and re-dispatch at `resume_pc` (the PC stored in the just-
* restored CPUState). Reuses the same structured-escape mechanism a same-thread
* RFE uses (PSX_RUN_RESUME_CURRENT) — abandons every suspended CPS continuation
* on the old stack (safe: the restored state is complete in CPUState/RAM/devices
* and every block leader is re-enterable). MUST be called on the scheduler fiber
* (HLE mode, default) at a block-leader boundary with in_exception == 0. */
/* Set by resume_at; cleared on first post-resume finish_frame (or abort). */
static int g_sched_top_level_resume;
int psx_scheduler_top_level_resume_active(void)
{
return g_sched_top_level_resume;
}
void psx_scheduler_top_level_resume_clear(void)
{
g_sched_top_level_resume = 0;
}
void psx_scheduler_resume_at(uint32_t resume_pc)
{
if (!psx_is_dispatchable(resume_pc)) {
char b[96];
snprintf(b, sizeof(b),
"savestate restore: non-dispatchable resume PC 0x%08X", (unsigned)resume_pc);
trap_crash(b);
return;
}
g_sched_escape.target_tcb = 0;
g_sched_escape.resume_pc = resume_pc;
g_sched_escape.reason = PSX_RUN_RESUME_CURRENT;
/* longjmp abandons every mid-block CPS frame — sentinel RFE must not
* treat the new top-level dispatch as a continuable live chain. */
g_sched_top_level_resume = 1;
/* Publish before the longjmp lands in dispatch: sticky I_STAT can deliver
* on the first edge with take_pc=0 otherwise (LEGACY_SENTINEL fork). */
{
extern void psx_irq_arm_compiled_resume_pc(uint32_t pc);
psx_irq_arm_compiled_resume_pc(resume_pc);
}
longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run; never returns */
}
/* Scheduler mode. HLE = the deterministic TCB scheduler
* (psx_request_thread_switch, default); LLE = the legacy host-fiber bridge
* (psx_change_thread_fiber). This is the HLE tier's standing SUBSYSTEM
* REPLACEMENT (CLAUDE.md §0 amendments 2026-06-29 + 2026-07-02): unlike the
* opt-in call-HLE in bios_hle.c it defaults ON in BOTH backends, because the
* LLE path it replaces (host fibers) has a genuine landmine — host-side
* non-determinism with no hardware analog. Config default via
* psx_hle_scheduler_set_default ([runtime] hle_scheduler, main.cpp);
* PSX_HLE_SCHEDULER env wins over config. Latched at first query so the mode
* can't flip mid-run — a thread suspended under one scheduler cannot be
* resumed under the other (fibers keep a native stack; the TCB scheduler
* keeps none). */
static int s_hle_sched_default = 1;
void psx_hle_scheduler_set_default(int on)
{
s_hle_sched_default = on ? 1 : 0;
}
int psx_hle_scheduler_enabled(void)
{
static int s_mode = -1;
if (s_mode < 0) {
const char* e = getenv("PSX_HLE_SCHEDULER");
if (e && e[0]) s_mode = (e[0] == '0') ? 0 : 1;
else s_mode = s_hle_sched_default;
}
return s_mode;
}
static int psx_change_thread(CPUState* cpu, uint32_t target_tcb)
{
if (psx_hle_scheduler_enabled())
return psx_request_thread_switch(cpu, target_tcb);
return psx_change_thread_fiber(cpu, target_tcb);
}
static int g_in_scheduler_run = 0;
static int g_return_to_lobby_req = 0;
void psx_clear_return_to_lobby(void)
{
g_return_to_lobby_req = 0;
}
int psx_return_to_lobby_requested(void)
{
return g_return_to_lobby_req;
}
void psx_request_return_to_lobby(void)
{
g_return_to_lobby_req = 1;
if (!g_in_scheduler_run) {
return;
}
g_sched_escape.target_tcb = 0;
g_sched_escape.resume_pc = 0;
g_sched_escape.reason = PSX_RUN_RETURN_TO_LOBBY;
longjmp(g_scheduler_jmpbuf, 1);
}
void psx_scheduler_run(CPUState* cpu)
{
extern int g_psx_dispatch_depth;
g_in_scheduler_run = 1;
g_sched_escape.reason = PSX_RUN_CONTINUE;
for (;;) {
if (setjmp(g_scheduler_jmpbuf) != 0) {
/* A structured escape unwound the native stack to here. Reset the
* invariants the skipped psx_dispatch_impl frames would otherwise
* own. No exception_jmpbuf frame is ever skipped (switch fires only
* with in_exception==0), so interrupt state needs no fixup here. */
extern void overlay_loader_shadow_scheduler_escape_fixup(void);
overlay_loader_shadow_scheduler_escape_fixup();
g_psx_dispatch_depth = 0;
g_psx_call_bail = 0;
/* Soft-exit / yield longjmps out of vblank inside
* psx_devices_service_to_now; without this the re-entrancy guard
* stays set and every later advance skips device service forever. */
{ extern int psx_in_device_service; psx_in_device_service = 0; }
/* A longjmp-out through a nested call unit (overlay_loader_call_native
* or dispatch_nonlocal_call) would skip its depth restore; clear the
* nested-unit gate so IRQ checks are not wedged-off after the escape
* (backstop for the Ape memcard native<->interp fix). */
{ extern int g_call_unit_depth; g_call_unit_depth = 0; }
/* flush_resume / savestate longjmp skips generated bb_defer cleanup. */
{
extern int g_psx_cyc_bb_defer;
extern uint32_t g_psx_cyc_batch;
extern uint32_t *g_psx_cyc_local_acc;
if (g_psx_cyc_local_acc) *g_psx_cyc_local_acc = 0;
g_psx_cyc_local_acc = NULL;
g_psx_cyc_bb_defer = 0;
g_psx_cyc_batch = 0;
}
}
switch (g_sched_escape.reason) {
case PSX_RUN_FATAL:
g_in_scheduler_run = 0;
trap_crash("scheduler: structured FATAL escape");
return;
case PSX_RUN_GUEST_EXIT:
g_in_scheduler_run = 0;
return; /* abnormal top-level exit — main.cpp dumps diagnostics */
case PSX_RUN_RETURN_TO_LOBBY:
g_in_scheduler_run = 0;
return; /* netplay soft-exit — main.cpp reopens lobby UI */
default:
break; /* CONTINUE / YIELD_TO_TCB / RESUME_CURRENT */
}
uint32_t run_pc;
int from_top_resume = 0;
if (g_sched_escape.reason == PSX_RUN_RESUME_CURRENT &&
g_sched_escape.resume_pc != 0u) {
/* Same-thread RFE: GPRs already committed by the RFE; just re-dispatch. */
run_pc = g_sched_escape.resume_pc;
from_top_resume = 1;
} else {
uint32_t cur = psx_current_tcb_ptr(cpu);
if (psx_is_valid_tcb(cpu, cur)) {
run_pc = psx_restore_context_from_tcb(cpu, cur);
if (!psx_is_dispatchable(run_pc)) {
char b[112];
snprintf(b, sizeof(b),
"scheduler: TCB 0x%08X has non-dispatchable resume PC 0x%08X",
(unsigned)cur, (unsigned)run_pc);
trap_crash(b);
return;
}
} else {
run_pc = cpu->pc; /* boot / pre-TCB: run the raw PC */
}
}
cpu->pc = run_pc;
g_sched_escape.reason = PSX_RUN_CONTINUE;
/* Re-arm on the dispatch that consumes RESUME_CURRENT (covers
* recover_null_pc continues that skip resume_at). */
if (from_top_resume) {
extern void psx_irq_arm_compiled_resume_pc(uint32_t pc);
psx_irq_arm_compiled_resume_pc(run_pc);
}
psx_dispatch(cpu, run_pc);
/* psx_dispatch returned with NO structured escape => the outermost
* dispatch saw cpu->pc==0. If a thread yielded to us and then ran to
* completion, hand control back to its yielder (one-level safety net)
* instead of tearing down the whole run. Otherwise it's the legacy
* top-level abnormal exit — unless an RB top-level resume can recover
* at $ra / sticky BB (returning-leaf snap after flush_resume). */
if (psx_is_valid_tcb(cpu, g_sched_return_tcb) &&
g_sched_return_tcb != psx_current_tcb_ptr(cpu)) {
uint32_t yielder = g_sched_return_tcb;
g_sched_return_tcb = 0;
psx_set_current_tcb(cpu, yielder);
g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB;
sched_escape_ring_log(cpu, PSX_RUN_YIELD_TO_TCB,
psx_current_tcb_ptr(cpu), yielder, 0);
continue;
}
{
extern int psx_netplay_rb_recover_null_pc(CPUState *c, uint32_t *out_pc);
uint32_t recover_pc = 0;
if (psx_netplay_rb_recover_null_pc(cpu, &recover_pc) && recover_pc) {
g_sched_escape.target_tcb = 0;
g_sched_escape.resume_pc = recover_pc;
g_sched_escape.reason = PSX_RUN_RESUME_CURRENT;
continue;
}
}
g_sched_escape.reason = PSX_RUN_GUEST_EXIT;
g_in_scheduler_run = 0;
return;
}
}
int psx_syscall(CPUState* cpu, uint32_t code) {
/*
* PS1 BIOS SYSCALL convention:
* $a0 = 1: EnterCriticalSection — disable interrupts, return old SR
* $a0 = 2: ExitCriticalSection — enable interrupts, return old SR
* $a0 = 3: ReturnFromException — restore full TCB state + RFE
*
* Syscalls 1 and 2 are always handled directly — they only touch IEc
* in SR and don't need the full exception mechanism.
*
* Syscall 3 and unknown numbers route through the real BIOS exception
* handler once it's installed, because ReturnFromException must restore
* the full register state from the current thread's TCB (including the
* saved SR which carries IM[2]).
*/
uint32_t func = cpu->gpr[4]; /* $a0 = syscall function number */
uint32_t sr = cpu->cop0[12];
switch (func) {
case 1: /* EnterCriticalSection: disable interrupts */
cpu->cop0[12] = sr & ~1u; /* clear IEc (bit 0) */
cpu->gpr[2] = sr & 1u; /* return old IEc */
g_pc0_reason = PSX_PC0_CRIT_SECTION;
cpu->pc = 0;
/* Directly handled "void" syscall. Return 0: under CPS the caller
* (`if (psx_syscall(...)) return;`) falls through to the inline
* post-syscall code (the guest's own jr $ra), which returns to the
* caller via the flat trampoline — matching real-HW resume at
* EPC+4. Legacy callers ignore this and rely on cpu->pc==0. */
return 0;
case 2: /* ExitCriticalSection: enable interrupts */
cpu->cop0[12] = sr | 0x0401u; /* set IEc (bit 0) + IM[2] (bit 10) */
cpu->gpr[2] = 0;
g_pc0_reason = PSX_PC0_CRIT_SECTION;
cpu->pc = 0;
return 0;
case 3: { /* ChangeThread / ReturnFromException */
uint32_t target_tcb = cpu->gpr[5];
/* Tomba2 loader-thread diagnosis (Patch 1, trace-only): record every
* syscall-3 (ChangeThread/RFE) at its decision point. kind 20 = entered
* with in_exception==0 (eligible for psx_change_thread); kind 24 = entered
* with in_exception==1 (will be forced down the manual-RFE path, NOT a
* thread switch). target_tcb is the requested switch target; its state
* word is auto-captured as target_state. This tells us whether the loader
* ChangeThread is ever requested (Case A) and, if so, whether in_exception
* diverts it (Case B). */
debug_server_log_thread_event(psx_get_in_exception() ? 24u : 20u, cpu,
psx_current_tcb_ptr(cpu), target_tcb, cpu->pc);
int switch_result = 0;
if (!psx_get_in_exception())
switch_result = psx_change_thread(cpu, target_tcb);
if (switch_result < 0) {
/* Native shadow validation requested an impossible thread
* switch. Its bail flag unwinds the speculative call; return
* true so CPS syscall wrappers stop this block immediately. */
return 1;
}
if (switch_result > 0) {
/* Thread switched (and this thread has since been resumed): the
* fiber scheduler leaves cpu->pc == 0. Under CPS that must NOT
* be a transfer — return 0 so the syscall wrapper falls through
* to its own jr $ra and this thread resumes at its caller via
* the flat trampoline. Legacy ignores the return value (it uses
* cpu->pc == 0 + the nested-dispatch C-return to resume). */
return 0;
}
uint32_t tcb_ptr_addr = cpu->read_word(0x00000108u);
if (tcb_ptr_addr != 0) {
uint32_t save_area = cpu->read_word(tcb_ptr_addr);
if (save_area != 0) {
save_area += 8; /* handler adds 8 before saving */
uint32_t saved_epc = cpu->read_word(save_area + 128);
uint32_t saved_sr = cpu->read_word(save_area + 140);
/* Restore ALL GPRs from TCB save area.
* Layout: offset 0 = $zero (skip), 4 = $at, ... 124 = $ra,
* 128 = EPC, 132 = HI, 136 = LO, 140 = SR. */
for (int i = 1; i < 32; i++) {
cpu->gpr[i] = cpu->read_word(save_area + i * 4);
}
cpu->hi = cpu->read_word(save_area + 132);
cpu->lo = cpu->read_word(save_area + 136);
/* RFE pop on saved SR (clears bits [5:0], shifts [5:2]→[3:0]). */
cpu->cop0[12] = (saved_sr & 0xFFFFFFC0u) | ((saved_sr >> 2) & 0x0Fu);
cpu->pc = saved_epc;
if (psx_get_in_exception()) {
/* Fix B: saved_epc is the REAL guest EPC restored from the TCB
* (never the sentinel). Mark the escape reason so the landing in
* psx_check_interrupts does NOT clobber the resumed thread's
* TCB-restored GPRs with the entering thread's saved_gpr. */
extern int g_exc_escape_reason; extern int g_rfe_escape_pending;
g_exc_escape_reason = PSX_EXC_ESCAPE_SYSCALL_RETURN;
g_rfe_escape_pending = 0;
psx_exception_longjmp(); /* unwind handler */
}
return 1;
}
}
/* Fallback: simple RFE on current SR. */
cpu->cop0[12] = (sr & ~0x0Fu) | ((sr >> 2) & 0x0Fu);
cpu->pc = cpu->cop0[14];
return 1;
}
default:
break;
}
/* Unknown syscalls: route through the real BIOS exception handler. */
uint32_t handler_word = cpu->read_word(0x80000080u);
if (handler_word != 0) {
/* Route through the real BIOS exception handler. */
cpu->cop0[14] = cpu->pc; /* EPC */
cpu->cop0[13] = (cpu->cop0[13] & ~0x7Cu) | (0x08u << 2); /* Cause: Syscall */
cpu->cop0[12] = (sr & ~0x3Fu) | ((sr & 0x0Fu) << 2); /* SR push */
uint32_t vector = (sr & 0x00400000u) ? 0xBFC00180u : 0x80000080u;
psx_dispatch(cpu, vector);
return 1;
}
/* Early boot fallback for syscall 3. */
if (func == 3) {
cpu->cop0[12] = (sr & ~0x0Fu) | ((sr >> 2) & 0x0Fu);
cpu->pc = cpu->cop0[14];
return 1;
}
/* Unknown syscall number and no handler — fatal. */
{
char buf[128];
snprintf(buf, sizeof(buf), "SYSCALL %u with no handler @ PC=0x%08X",
func, cpu->pc);
trap_crash(buf);
fprintf(stderr, "%s\n", buf); fflush(stderr);
exit(1);
}
return 1;
}
void psx_break(CPUState* cpu, uint32_t code, uint32_t pc) {
char buf[128];
snprintf(buf, sizeof(buf), "BREAK @ PC=0x%08X, code=0x%05X", pc, code);
trap_crash(buf);
fprintf(stderr, "%s\n", buf); fflush(stderr);
exit(1);
}
void psx_arith_overflow(CPUState* cpu) {
char buf[128];
snprintf(buf, sizeof(buf), "OVERFLOW @ PC=0x%08X", cpu->pc);
trap_crash(buf);
fprintf(stderr, "%s\n", buf); fflush(stderr);
exit(1);
}
void psx_unaligned_access(CPUState* cpu, uint32_t addr, uint32_t pc) {
/* Survivable fault: log diagnostics and let the generated code's
* `return;` skip the rest of the current function. */
static int count = 0;
if (count < 3) {
char buf[1024];
int n = snprintf(buf, sizeof(buf),
"ADEL: addr=0x%08X PC=0x%08X ra=0x%08X v0=0x%08X a0=0x%08X t9=0x%08X (hit #%d)\n"
" Callback table at 0x800DFEE0:",
addr, pc, cpu->gpr[31], cpu->gpr[2], cpu->gpr[4], cpu->gpr[25], count + 1);
for (int i = 0; i < 11; i++) {
uint32_t cb = cpu->read_word(0x800DFEE0u + (uint32_t)(i * 4));
n += snprintf(buf + n, sizeof(buf) - (size_t)n, " [%d]=0x%08X", i, cb);
}
n += snprintf(buf + n, sizeof(buf) - (size_t)n,
"\n Mask=0x%08X s0=0x%08X s1=0x%08X s2=0x%08X",
cpu->read_word(0x800DFF0Cu),
cpu->gpr[16], cpu->gpr[17], cpu->gpr[18]);
trap_crash(buf);
count++;
}
}
void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) {
/* NULL dispatch: address 0 is never a valid function target.
* Silently absorb — this can happen when a corrupt function pointer
* or uninitialized callback slot is dispatched. */
if (addr == 0) {
g_pc0_reason = PSX_PC0_DISPATCH_MISS; /* guest jumped/returned to a null (0) target */
cpu->pc = 0;
return;
}
/* Detect ReturnFromException: when the recompiled B0:0x17 function
* (or handler epilogue) has already restored all registers from the
* TCB and done RFE, it sets cpu->pc = saved_epc = our sentinel
* 0x80000048. The dispatch loop tail-calls here. If we're inside
* the exception handler, longjmp back to psx_check_interrupts to
* properly unwind the handler call tree. */
if (addr == 0x80000048u && psx_get_in_exception()) {
cpu->pc = 0;
psx_exception_longjmp(); /* does not return */
}
/* Async ReturnFromException (Tomba 2 frame-1997 fix): a game-installed handler
* called ReturnFromException OUTSIDE our synchronous exception window (in_exception
* already 0). The recompiled BIOS RFE restored the interrupted GPRs from the TCB and
* set pc = saved EPC = sentinel; with in_exception==0 the longjmp above does not fire.
* Previously this fell through to pc=0 -> abnormal "execution completed" exit. The
* interrupted code was dirty-interpreted, so a real guest resume PC was latched at
* exception entry (g_async_rfe_resume_pc) — resume there instead of exiting. */
if (addr == 0x80000048u) {
extern uint32_t g_async_rfe_resume_pc;
extern uint64_t g_async_rfe_fire_count;
extern uint64_t g_sentinel_reach_traps;
extern uint32_t g_sentinel_reach_async;
g_sentinel_reach_traps++;
g_sentinel_reach_async = g_async_rfe_resume_pc;
if (g_async_rfe_resume_pc != 0u) {
g_async_rfe_fire_count++;
cpu->pc = g_async_rfe_resume_pc;
return;
}
}
/* Exception handler chain-walk continuation: 0xBFC10910 (phys 0x00000E10)
* is the return address set by jalr $s1 in the exception handler chain
* walker. In the merged exception handler function, the continuation
* code at BFC10910 follows the psx_dispatch call as a C fall-through.
* When the chain handler returns via jr $ra (compiled as C `return;`),
* the fall-through handles the continuation. But if external code
* dispatches to BFC10910 directly (e.g., the trampoline resolver or
* the psx_dispatch tail-call loop picking up $ra), it's a no-op — the
* continuation was already handled by the merged function. */
if (phys == 0x00000E10u) {
cpu->pc = 0;
return;
}
/* Reject non-word-aligned targets — corrupt function pointer. Hard fail. */
if (addr & 3) {
char buf[16384];
int pos = snprintf(buf, sizeof(buf),
"DISPATCH FATAL: misaligned target 0x%08X\n"
" aligned form: 0x%08X\n"
" physical: 0x%08X\n"
" cpu->pc: 0x%08X\n"
" $ra: 0x%08X\n"
" $t9: 0x%08X\n"
" $v0: 0x%08X\n"
" $a0: 0x%08X\n"
" $a1: 0x%08X\n"
" $a2: 0x%08X\n"
" $a3: 0x%08X\n"
" COP0_EPC: 0x%08X\n"
" COP0_SR: 0x%08X\n"
" COP0_Cause: 0x%08X\n",
addr, addr & ~3u, phys,
cpu->pc, cpu->gpr[31], cpu->gpr[25],
cpu->gpr[2], cpu->gpr[4], cpu->gpr[5],
cpu->gpr[6], cpu->gpr[7],
cpu->cop0[14], cpu->cop0[12], cpu->cop0[13]);
/* Include the dispatch chain that led to the corrupt target. */
{
extern uint32_t crash_trace_dispatch_ring_get(int idx);
extern uint64_t crash_trace_dispatch_seq_get(void);
uint64_t total = crash_trace_dispatch_seq_get();
int count = total < 32 ? (int)total : 32;
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
"\n dispatch_tail (last %d of %llu):\n", count,
(unsigned long long)total);
uint64_t start = total - (uint64_t)count;
for (int i = 0; i < count && pos < (int)sizeof(buf) - 48; i++) {
uint32_t dispatch = crash_trace_dispatch_ring_get(
(int)((start + (uint64_t)i) & 0xFFFFu));
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
" [%3d] 0x%08X\n", i, dispatch);
}
}
/* Locate stored copies of both the bad pointer and its aligned form.
* This is diagnostic-only and runs immediately before the fatal trap. */
{
extern uint8_t *memory_get_ram_ptr(void);
uint8_t *ram = memory_get_ram_ptr();
uint32_t needles[2] = { addr, addr & ~3u };
int needle_count = needles[0] == needles[1] ? 1 : 2;
for (int n = 0; n < needle_count; n++) {
int hits = 0;
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
"\n RAM scan for 0x%08X:\n", needles[n]);
for (uint32_t off = 0; off + 4 <= 2u * 1024u * 1024u; off += 4) {
uint32_t word;
memcpy(&word, ram + off, sizeof(word));
if (word != needles[n]) continue;
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
" RAM[0x%08X] = 0x%08X\n",
0x80000000u + off, word);
if (++hits == 16 || pos >= (int)sizeof(buf) - 64) break;
}
if (!hits)
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
" (not found in 2MB PSX RAM)\n");
}
}
/* stderr is emitted before trap_crash enters halt-and-serve. */
fprintf(stderr, "%s", buf);
fflush(stderr);
trap_crash(buf);
exit(1);
}
/*
* The BIOS writes small jump trampolines into RAM at runtime
* (e.g., at 0xA0, 0xB0, 0xC0 for the A0/B0/C0 vectors).
* Pattern: lui rN, hi / addiu rN, rN, lo / jr rN / nop
* Since we can't execute RAM instructions, resolve the pattern
* and re-dispatch to the computed target.
*/
{
uint32_t w0 = cpu->read_word(addr);
uint32_t w1 = cpu->read_word(addr + 4);
uint32_t w2 = cpu->read_word(addr + 8);
uint32_t op0 = (w0 >> 26) & 0x3F;
uint32_t op1 = (w1 >> 26) & 0x3F;
/* Trampoline resolution: set cpu->pc so the dispatch loop re-dispatches.
* This avoids growing the native stack for RAM trampoline chains. */
/* Pattern 1: J target */
if (op0 == 2) {
uint32_t target = (addr & 0xF0000000u) | ((w0 & 0x03FFFFFFu) << 2);
cpu->pc = target;
return;
}
/* Pattern 2: JR rs (single instruction) */
if (op0 == 0 && (w0 & 0x3F) == 0x08) {
uint32_t rs = (w0 >> 21) & 0x1F;
cpu->pc = cpu->gpr[rs];
return;
}
/* Pattern 3: addiu rN, $zero, imm / jr rN (small address trampoline) */
if (op0 == 0x09) { /* ADDIU */
uint32_t rs0 = (w0 >> 21) & 0x1F;
uint32_t rt0 = (w0 >> 16) & 0x1F;
if (rs0 == 0) { /* addiu rN, $zero, imm = li rN, imm */
int16_t imm = (int16_t)(w0 & 0xFFFF);
uint32_t target = (uint32_t)(int32_t)imm;
/* w1 should be jr rN */
if ((w1 & 0xFC1FFFFF) == 0x00000008) { /* jr rs */
uint32_t jr_rs = (w1 >> 21) & 0x1F;
if (jr_rs == rt0) {
/* w2 is delay slot — execute it as load of $t1 */
uint32_t ds_op = (w2 >> 26) & 0x3F;
if (ds_op == 0x09) { /* ADDIU in delay slot */
uint32_t ds_rs = (w2 >> 21) & 0x1F;
uint32_t ds_rt = (w2 >> 16) & 0x1F;
int16_t ds_imm = (int16_t)(w2 & 0xFFFF);
if (ds_rs == 0) {
cpu->gpr[ds_rt] = (uint32_t)(int32_t)ds_imm;
}
}
cpu->pc = target;
return;
}
}
}
}
/* Pattern 4: lui rN, hi / addiu|ori rN, rN, lo / jr rN / nop
* All three instructions must be present. Without the jr check,
* any function prologue that loads a constant via lui+ori would
* be misidentified as a trampoline (see 0xBFC3DF90 incident). */
if (op0 == 0x0F) { /* LUI */
uint32_t rt0 = (w0 >> 16) & 0x1F;
uint32_t hi_val = (w0 & 0xFFFF) << 16;
uint32_t computed = 0;
int have_target = 0;
if (op1 == 0x09) { /* ADDIU */
uint32_t rs1 = (w1 >> 21) & 0x1F;
uint32_t rt1 = (w1 >> 16) & 0x1F;
if (rs1 == rt0 && rt1 == rt0) {
int16_t lo_val = (int16_t)(w1 & 0xFFFF);
computed = hi_val + (uint32_t)(int32_t)lo_val;
have_target = 1;
}
}
if (!have_target && op1 == 0x0D) { /* ORI */
uint32_t rs1 = (w1 >> 21) & 0x1F;
uint32_t rt1 = (w1 >> 16) & 0x1F;
if (rs1 == rt0 && rt1 == rt0) {
computed = hi_val | (w1 & 0xFFFF);
have_target = 1;
}
}
/* Only resolve if w2 is jr rN targeting the same register. */
if (have_target && (w2 & 0xFC1FFFFF) == 0x00000008) {
uint32_t jr_rs = (w2 >> 21) & 0x1F;
if (jr_rs == rt0) {
cpu->pc = computed;
return;
}
}
/* Pattern 5: BIOS vector dispatch table.
* lui rN, hi w0
* addiu rN, rN, lo w1 (base = hi|lo)
* sll rM, rM, 2 w2 (index <<= 2)
* addu rN, rN, rM w3 (ptr = base + index*4)
* lw rN, 0(rN) w4 (func = *ptr)
* jr rN w5
* This is the A0/B0/C0 dispatch pattern the BIOS writes at
* 0x500+. rM holds the function number (set by the A0/B0/C0
* trampoline delay slot before we get here). */
if (have_target) {
uint32_t w3 = cpu->read_word(addr + 12);
uint32_t w4 = cpu->read_word(addr + 16);
uint32_t w5 = cpu->read_word(addr + 20);
/* w2: sll rM, rM, 2 (opcode 0, func 0, sa 2) */
if ((w2 & 0xFC0007FF) == 0x00000080) { /* SLL with sa=2 */
uint32_t idx_rt = (w2 >> 16) & 0x1F;
uint32_t idx_rd = (w2 >> 11) & 0x1F;
/* w3: add/addu rN, rN, rM */
if ((w3 & 0xFC0007FE) == 0x00000020) { /* ADD or ADDU */
uint32_t a_rs = (w3 >> 21) & 0x1F;
uint32_t a_rt = (w3 >> 16) & 0x1F;
uint32_t a_rd = (w3 >> 11) & 0x1F;
if (a_rs == rt0 && a_rt == idx_rd && a_rd == rt0) {
/* w4: lw rN, 0(rN) */
if ((w4 & 0xFFFF0000) == (0x8C000000u | ((uint32_t)rt0 << 21) | ((uint32_t)rt0 << 16))) {
/* w5: jr rN — or nop then jr rN (load delay slot) */
uint32_t jr_word = w5;
if (w5 == 0x00000000u) {
jr_word = cpu->read_word(addr + 24);
}
if ((jr_word & 0xFC1FFFFF) == 0x00000008 && ((jr_word >> 21) & 0x1F) == rt0) {
uint32_t index_val = cpu->gpr[idx_rt];
uint32_t table_addr = computed + (index_val << 2);
uint32_t func_ptr = cpu->read_word(table_addr);
cpu->pc = func_ptr;
return;
}
}
}
}
}
}
}
}
{
/* Always-on ring buffer of dispatch misses. Queryable via the
* `unknown_dispatch_log` debug command. Replaces the prior
* file-based log per CLAUDE.md §3. */
extern void psx_unknown_dispatch_record(uint32_t addr, uint32_t phys,
uint32_t ra, uint32_t a0,
uint32_t a1);
psx_unknown_dispatch_record(addr, phys, cpu->gpr[31],
cpu->gpr[4], cpu->gpr[5]);
/* LOUD BY DEFAULT. A miss that reaches this point is a real,
* unresolvable dispatch target — a recompiler discovery bug (ROM
* code the static pass never found) or corrupt guest state. The
* old default silently no-op'd it, leaving stale register state
* and a broken call chain; that masked the MMX6 memset miss
* (A0:2B -> ROM 0xBFC02B8C) for weeks while the symptom was
* chased as an event/timing bug (2026-07-02). Per CLAUDE.md §0
* a function is either fully implemented or it aborts fatally —
* so the first miss dumps a full crash report and halts.
*
* PSX_FAIL_FAST_UNKNOWN_DISPATCH=0 opts OUT for diagnostic
* sessions that need to survive misses to enumerate them (the
* miss is still ring-recorded either way; see
* unknown_dispatch_log). No interp fallback here on purpose:
* the dirty-RAM interpreter is reserved for runtime-written
* code (Rule 18); interpreting around a static-discovery hole
* would hide it exactly like the silent no-op did. */
static int s_fail_fast = -1;
if (s_fail_fast < 0) {
const char *e = getenv("PSX_FAIL_FAST_UNKNOWN_DISPATCH");
s_fail_fast = (e && *e == '0') ? 0 : 1;
}
if (s_fail_fast) {
extern void psx_crash_trace_dump(const char *reason, void *seh_info);
psx_crash_trace_dump("fail_fast_unknown_dispatch", NULL);
char msg[256];
snprintf(msg, sizeof(msg),
"FAIL-FAST unknown dispatch: addr=0x%08X phys=0x%08X ra=0x%08X "
"a0=0x%08X a1=0x%08X — see psx_last_run_report.json\n"
"(recompiler discovery gap: if addr is BIOS ROM, seed it — "
"recompiler/seeds/ — and regen; PSX_FAIL_FAST_UNKNOWN_DISPATCH=0 "
"to survive-and-log instead)\n",
addr, phys, cpu->gpr[31], cpu->gpr[4], cpu->gpr[5]);
trap_crash(msg);
exit(1);
}
/* Opt-out path: return without executing (function is a no-op),
* ring-recorded above. Stale registers WILL corrupt the caller —
* diagnostic use only. */
g_pc0_reason = PSX_PC0_DISPATCH_MISS;
cpu->pc = 0;
}
}