mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
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:
@@ -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++;
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user