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 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-07-19 19:00:29 -07:00
parent e02b7b3455
commit b3bfdd9330
4 changed files with 88 additions and 7 deletions
+21
View File
@@ -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++;
+45 -5
View File
@@ -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
@@ -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)")
+8 -2
View File
@@ -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'