From b3bfdd9330da06928aef06d4d6dea3bb595ec709 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 19 Jul 2026 19:00:29 -0700 Subject: [PATCH] Fix in-handler RFE contract gaps that livelock the boot splash (Tomba 2 frame 1385) Three-layer faithful-core fix for the exception-return contract, root-caused live over TCP on a deterministic dbg-build repro (vblank authority vetoed forever while the guest spun in the kernel idle loop, in_exception stuck 1): 1. Interpreter RFE backend parity: dirty-RAM RFE now calls psx_rfe_mark_escape() exactly like the recompiled opcode and every overlay shard (ABI v12), and dirty_ram_dispatch_inner takes a pending escape at the committed transfer before local dirty flow can keep interpreting the resumed code inside psx_check_interrupts' window. 2. Legacy-sentinel deliveries arm the escape too: the old blanket decline assumed a sentinel delivery always resumes AT the sentinel, but guest TCBs never hold the sentinel (Fix B anti-leak), so a kernel ReturnFromException restore resumes at an approximate real PC with no exit path armed. The take site preserves the legacy contract: a resume landing ON the sentinel still exits through the sentinel detector. 3. Same-thread completion of a sentinel delivery via explicit RFE / ReturnFromException converts to the legacy exit (restore interrupted GPRs, pc=0, continue the live host chain) keyed on the structural entry_tcb == exit_tcb test - otherwise the approximate TCB PC surfaces up the trampoline, severs the live chain, and the run dies one hop later on an un-re-enterable mid-function PC ("execution completed"). Validated: production build passes the previously-fatal splash transition (frames 4717+ at 60fps, was deterministic death at 1385 x3 runs); dbg build advances past the wedge (was frozen at exactly 1385); all runtime tests + structural guards pass (guards extended to pin the interp RFE contract). Known follow-up: the PSX_DEBUG_TOOLS build still loses one kernel CD event through a third timing path at the same site - tracked as an open foundation item; production config unaffected. Co-Authored-By: Claude Fable 5 --- runtime/src/dirty_ram_interp.c | 21 ++++++++ runtime/src/interrupts.c | 50 +++++++++++++++++-- runtime/tests/test_interpreter_perf_guards.py | 14 ++++++ tools/launch_tomba2_interp_perf.ps1 | 10 +++- 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index c886d440..b30eca3f 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1650,6 +1650,15 @@ static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, if (cop_op == 0x10 && fnt == 0x10) { /* RFE */ uint32_t sr = cpu->cop0[12]; cpu->cop0[12] = (sr & 0xFFFFFFF0u) | ((sr >> 2) & 0x0Fu); + /* Backend contract parity (overlay_api.h v12; the emitter calls + * this after every recompiled rfe): inside the synchronous + * handler window this arms the host escape taken at the next + * committed transfer. Without it an interpreted handler whose + * post-RFE EPC lands in dirty RAM keeps interpreting flat inside + * psx_check_interrupts' window — in_exception never clears, the + * cycle-paced VBlank authority is vetoed forever, and the guest + * livelocks in the kernel idle loop (dbg-build boot wedge). */ + psx_rfe_mark_escape(); return 0; } return abort_unsupported(pc, insn, "COP0 op"); @@ -2483,6 +2492,18 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; OV_FPLOG_RET1(); } + /* Trampoline contract parity (full_function_emitter emits this + * check after every compiled function return): the transfer that + * just committed may be the jr whose delay-slot RFE armed the + * host escape — cpu->pc now holds the real EPC, so take the + * escape BEFORE local dirty flow can continue interpreting the + * resumed code inside the handler's host window. Same-fiber: this + * longjmps to the psx_check_interrupts frame that dispatched the + * handler (its epilogue restores in_exception and it returns with + * cpu->pc = EPC through the existing pump-boundary redirect). + * Foreign fiber: declines, and the normal surfacing below applies. */ + if (g_rfe_escape_pending) + psx_rfe_escape_check(cpu); uint32_t target = cpu->pc; if (target != 0u && dirty_ram_pump_boundary(cpu, target, 1)) { g_dirty_ram_blocks_run++; diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index a6e842ac..838a1928 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -520,17 +520,25 @@ extern void psx_exception_longjmp(void); * no-op and the real EPC is dispatched directly — that is how a suspended thread * resumes at its own PC. */ void psx_rfe_mark_escape(void) { - if (in_exception && g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) { - g_rfe_escape_pending = 1; - g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN; - } + 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) { - (void)cpu; /* Escape ONLY when this RFE is the synchronous handler completing on the SAME * fiber that set up the exception (the owner of exception_jmpbuf). in_exception * is a single global, so a thread RESUMED on a different fiber (its own real EPC @@ -540,8 +548,20 @@ void psx_rfe_escape_check(CPUState* cpu) { * 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); @@ -1302,6 +1322,26 @@ void psx_check_interrupts(CPUState* cpu) { same_guest_pc(cpu->pc, g_exception_real_epc) && 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. */ + 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; + 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 diff --git a/runtime/tests/test_interpreter_perf_guards.py b/runtime/tests/test_interpreter_perf_guards.py index 2682d17b..8db20975 100644 --- a/runtime/tests/test_interpreter_perf_guards.py +++ b/runtime/tests/test_interpreter_perf_guards.py @@ -97,6 +97,20 @@ def main(): dispatch_inner = body(interp, "dirty_ram_dispatch_inner") if "(!current_page_dirty || next_page != current_page)" not in dispatch_inner: raise AssertionError("page fast path does not preserve clean-miss behavior") + # RFE backend-contract parity: the recompiled rfe and every overlay shard + # (ABI v12) call psx_rfe_mark_escape after popping SR, and the generated + # trampoline runs psx_rfe_escape_check after each return. The interpreter + # must do BOTH — mark at its RFE case and take the escape at the committed + # transfer — or an interpreted handler whose EPC lands in dirty RAM keeps + # interpreting inside psx_check_interrupts' window and in_exception never + # clears (vblank vetoed forever: the dbg-build boot livelock). + if "psx_rfe_mark_escape();" not in interp: + raise AssertionError("interpreter RFE does not honor the rfe escape contract") + rfe_take = dispatch_inner.find("psx_rfe_escape_check(cpu);") + local_flow = dispatch_inner.find("allow_local_dirty_flow && target != 0") + if rfe_take < 0 or local_flow < 0 or rfe_take > local_flow: + raise AssertionError( + "interpreter does not take a pending rfe escape before local dirty flow") dispatch = body(loader, "overlay_loader_dispatch") cached = dispatch.find("lazy_miss_cached(phys)") diff --git a/tools/launch_tomba2_interp_perf.ps1 b/tools/launch_tomba2_interp_perf.ps1 index 8924f098..e7567113 100644 --- a/tools/launch_tomba2_interp_perf.ps1 +++ b/tools/launch_tomba2_interp_perf.ps1 @@ -3,7 +3,13 @@ param( [string]$Mode = 'hybrid', [switch]$Launch, [int]$DebugPort = 4615, - [string]$GameRoot = '' + [string]$GameRoot = '', + # Which isolated build variant to run. 'build-tomba2-interp-iso-rel' is the + # production-config measurement (PSX_DEBUG_TOOLS=OFF: no TCP server, FPS + # only via the stderr log); 'build-tomba2-interp-iso-dbg' adds the TCP + # debug surface (screenshots/telemetry/input) at some hot-path overhead. + # Each variant keeps its OWN cache dir, so cold-cache runs stay repeatable. + [string]$BuildDir = 'build-tomba2-interp-iso-rel' ) $ErrorActionPreference = 'Stop' @@ -42,7 +48,7 @@ $GameRoot = (Resolve-Path $GameRoot).Path # step is gone because the isolated build dir + isolated exe name make the # alias unnecessary; build-tomba2-perf-rel itself is left untouched as # evidence of the original shared-checkout measurement. -$buildDir = Join-Path $repoRoot 'runtime\build-tomba2-interp-iso-rel' +$buildDir = Join-Path $repoRoot (Join-Path 'runtime' $BuildDir) $isolatedExe = Join-Path $buildDir 'Tomba2InterpPerf.exe' $gameConfig = Join-Path $GameRoot 'game.toml' $bios = Join-Path $workspaceRoot 'psxrecomp\bios\SCPH1001.BIN'