From 3b6986827eb8db24197f4a2d2cf4a1fb7f717fc4 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:55:50 -0700 Subject: [PATCH] =?UTF-8?q?recompiler:=20image-edge=20fallthrough=20must?= =?UTF-8?q?=20tail-transfer=20=E2=80=94=20the=20WipEout=203=20static-bake?= =?UTF-8?q?=20boot=20exit=20root=20cause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A region-truncated final block that runs off the end of its function fell off the C function body when no in-image fallthrough function existed: the entry-switch prologue had consumed cpu->pc to 0, so the dispatcher returned "handled" with pc==0 and the top-level trampoline read it as "program ended". Witnessed live in the WipEout 3 ntsc120full8 static bake: the 0xD5000 region variant's block_800D7FC0 ends at the region edge (continuation 0x800D8004 lies outside the image); the moment CD streaming loaded content matching that variant's identity ranges the dispatcher routed 0x800D7FC0 into the truncated block and the run died — the deterministic "execution completed, PC=0" at ~10 s into boot, ~frame 1221, byte-for-byte reproducible. Both code_generator tail sites (plain functions and alias hosts) now emit `cpu->pc = ; return;` when the last live block runs off the image edge — the CPS tail-transfer the full_function_emitter already emits for in-image fallthroughs. The dispatcher routes the published continuation to whoever owns it (a neighboring region variant, AOT text, or the interpreter). Faithful and class-level: no per-game anything. Diagnosis chain that cornered it (all landed as permanent tooling): - pc0 escape journal site 14 (trampoline top-level exit) named the last dispatched target: 0x800D7FC0. - site 15 (dirty-dispatch handled-with-pc0 tripwire, now journaled with a route tag) proved the producer was route 3 = the static overlay dispatcher, first occurrence, not in exception context. - Return-shape census of every candidate variant body showed no return-with-pc==0 path — forcing the discovery that the dispatch case for 0x800D7FC0 routes FIRST to the 0xD5000 variant, whose truncated tail block falls off the end. Co-Authored-By: Claude Fable 5 --- recompiler/src/code_generator.cpp | 43 ++++++++++++++++++++++++ recompiler/src/full_function_emitter.cpp | 6 ++++ runtime/include/cpu_state.h | 2 ++ runtime/src/dirty_ram_interp.c | 15 ++++++++- runtime/src/main.cpp | 5 +++ runtime/src/traps.c | 10 ++++++ 6 files changed, 80 insertions(+), 1 deletion(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 7a1b173b..6abd90e7 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -2808,6 +2808,30 @@ GeneratedFunction CodeGenerator::generate_function( fallthrough_name); } } + } else if (!cfg.block_order.empty()) { + // No in-image fallthrough function exists (region/image boundary): + // a reachable last block that simply runs off the end must TAIL- + // TRANSFER to its continuation PC, never fall off the C function — + // the entry-switch prologue consumed cpu->pc to 0, so falling off + // returns pc==0 to the dispatcher and the top-level trampoline reads + // it as "program ended" (WipEout 3 static bake: region-truncated + // block_800D7FC0 in the 0xD5000 variant fell off at 0x800D8004 → + // deterministic "execution completed, PC=0" ~10 s into boot). The + // published continuation is routed by the dispatcher to whoever owns + // it (a neighboring region variant, AOT text, or the interpreter). + const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); + bool runs_off_end = + last_block.exit_instr.type == ControlFlowType::None || + ((last_block.exit_instr.type == ControlFlowType::Branch || + last_block.exit_instr.type == ControlFlowType::Jump) && + last_block.successors.empty()); + bool is_reachable = last_block.is_entry || !last_block.predecessors.empty(); + if (runs_off_end && is_reachable) { + uint32_t cont = last_block.end_addr + 4u; + body_ss << fmt::format( + " cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", + cont); + } } body_ss << " ; /* label compatibility: C requires a statement after the last label */\n"; body_ss << "}\n"; @@ -3037,6 +3061,25 @@ std::vector CodeGenerator::generate_alias_group( body << fmt::format(" {}(cpu); /* fallthrough to next function */\n", fallthrough_name); } + } else if (!cfg.block_order.empty() && + live_blocks.count(cfg.block_order.back())) { + // Image-edge fallthrough (mirrors generate_function): no in-image next + // function exists, so a live final block that runs off the end must + // tail-transfer to its continuation PC — falling off the C function + // returns the entry-switch's consumed pc==0 and the top-level + // trampoline reads "program ended" (the WipEout 3 static-bake boot + // exit: truncated block_800D7FC0 in the 0xD5000 region variant). + const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); + bool runs_off_end = + (last_block.exit_instr.type == ControlFlowType::None) || + ((last_block.exit_instr.type == ControlFlowType::Branch || + last_block.exit_instr.type == ControlFlowType::Jump) && + last_block.successors.empty()); + if (runs_off_end) { + body << fmt::format( + " cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", + last_block.end_addr + 4u); + } } body << " ; /* label compatibility */\n"; body << "}\n"; diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 7bcad55f..4e9d00d1 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -2162,6 +2162,12 @@ void FullFunctionEmitter::emit_dispatch( out += " if (outermost) {\n"; out += " psx_dispatch_check_return_boundary(cpu, stop_addr);\n"; out += " }\n"; + out += " /* Top-level pc==0 exit is the abnormal-run terminator: name the\n"; + out += " * last dispatched target in the pc0 journal before surfacing. */\n"; + out += " if (stop_addr == 0u) {\n"; + out += " extern void psx_pc0_trampoline_exit(CPUState* cpu, uint32_t last_target);\n"; + out += " psx_pc0_trampoline_exit(cpu, addr);\n"; + out += " }\n"; out += " return;\n"; out += " }\n"; out += " if (stop_addr != 0 && cpu->pc == stop_addr) {\n"; diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 6595d2d0..beb55c57 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -247,6 +247,8 @@ enum { PSX_PC0J_IRQ_RESCUE = 11, /* a: published resume; d: reason */ PSX_PC0J_DIRTY_SENTINEL = 12, /* d: bit0 in_exception, bit1 async-armed */ PSX_PC0J_DIRTY_UNSUPPORTED = 13, /* a: unsupported pc; d: reason */ + PSX_PC0J_TOP_EXIT = 14, /* trampoline top-level pc==0 exit; + * a: last dispatched target; d: $sp */ }; typedef struct Pc0JournalEntry { uint64_t seq; diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 208fbbb1..de934483 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -200,6 +200,10 @@ uint32_t g_pczero_in_exc = 0; uint32_t g_pczero_async_rfe = 0; uint32_t g_pczero_dirty_safe = 0; uint64_t g_pczero_count = 0; +/* Which dirty_ram_dispatch_inner sub-path produced the last handled dispatch: + * 1=sentinel 2=AOT-compiled 3=static-overlay 4=DLL-shard 5=interp. Read by the + * pc0 tripwire so a null-PC return names its producer. */ +uint32_t g_dd_last_route = 0; /* Mid-block unsupported-opcode counters. Bumped instead of fprintf-spamming * stderr (CLAUDE.md §3). Read via dirty_ram_get_unsupported(). The "last_*" @@ -2355,6 +2359,11 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { if (r == 1 && cpu->pc == 0u) { extern uint32_t g_async_rfe_resume_pc, g_dirty_safe_resume_pc; g_pczero_count++; + /* Journal every occurrence (site 15): the dirty/overlay dispatch chain + * reported handled yet published a null PC — at top level this IS the + * abnormal exit. a = dispatched addr; d = occurrence count. */ + psx_pc0_journal_note(15u, cpu, addr, + (uint32_t)g_pczero_count | (g_dd_last_route << 28)); if (!g_pczero_latched) { g_pczero_latched = 1; g_pczero_addr = addr; @@ -2679,6 +2688,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ int clean_game_text_miss = 0; if (addr == 0x80000048u) { + g_dd_last_route = 1; g_sentinel_reach_dirty++; if (!psx_get_in_exception()) g_sentinel_reach_traps++; /* reuse: in_exc==0 dirty reaches */ g_sentinel_reach_async = g_async_rfe_resume_pc; @@ -2713,6 +2723,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ ls_func_enter(addr, cpu); int prev_phase = g_exec_phase; g_exec_phase = 3; + g_dd_last_route = 2; int _gc = psx_dispatch_game_compiled(cpu, addr); g_exec_phase = prev_phase; ls_func_exit(addr, cpu, _gc); @@ -2740,6 +2751,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ #ifdef PSX_HAS_OVERLAY_DISPATCH { extern int psx_overlay_dispatch(CPUState *cpu, uint32_t addr); + g_dd_last_route = 3; if (psx_overlay_dispatch(cpu, addr)) return 1; } #endif @@ -2770,6 +2782,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ { extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); + g_dd_last_route = 4; if (overlay_loader_dispatch(cpu, addr)) { if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 1); return 1; @@ -2778,7 +2791,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ /* Every exit retires any deferred load writeback: once we hand control back to * compiled code (or the dispatch loop) nothing downstream knows a register * write is still owed, and the pipeline would have drained by then anyway. */ -#define OV_FPLOG_RET1() do { dirty_ram_ld_delay_flush(cpu); if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) +#define OV_FPLOG_RET1() do { g_dd_last_route = 5; dirty_ram_ld_delay_flush(cpu); if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) if (!dirty_ram_is_dirty(phys) && !clean_game_text_miss) { /* Bulk host transfers can populate post-EXE executable RAM without diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 66b14adf..d1849886 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -14412,6 +14412,11 @@ session_reboot: (unsigned long long)g_dirty_ram_insns_run, g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter, g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want); } + { extern int g_pczero_latched; extern uint32_t g_pczero_addr, g_pczero_ra, g_pczero_in_exc; + extern uint64_t g_pczero_count; + std::fprintf(stdout, "psxrecomp runtime: [pczero tripwire] latched=%u count=%llu addr=0x%08X ra=0x%08X in_exc=%u\n", + g_pczero_latched, (unsigned long long)g_pczero_count, + g_pczero_addr, g_pczero_ra, g_pczero_in_exc); } /* pc=0 escape journal tail: names the exact runtime site that published * (or rescued) each recent null PC, with frame/depth/ra/epc context — * the play-build replacement for the debug-only fntrace ring, which is diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 9a13c6d9..8a8a8b71 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -258,6 +258,16 @@ void psx_pc0_journal_note(uint32_t site, CPUState *cpu, uint32_t a, uint32_t d) e->d = d; } +/* Called by the emitted trampoline when a top-level dispatch loop exits on + * pc==0 (the abnormal-run terminator): journals the LAST dispatched target, + * which no runtime site can otherwise see (the fatal 0 is published inside + * generated code — a null jr or a plain C-return). site 14; a=last target, + * d=$sp. */ +void psx_pc0_trampoline_exit(CPUState *cpu, uint32_t last_target) +{ + psx_pc0_journal_note(14u, cpu, last_target, cpu->gpr[29]); +} + static uint32_t psx_current_tcb_ptr(CPUState* cpu) { uint32_t tcbh = cpu->read_word(0x00000108u);