fix(tomba2): delay-slot cycle accounting -> Tomba 2 reaches the intro FMV

The multi-week Whoopee-Camp logo->FMV stall was a TIMING-FAITHFULNESS bug, not
game logic: the static recompiler charged cycles block-granular and a branch
whose delay slot is a SEPARATE block leader (branch at end_addr, delay slot
outside the block) undercharged by one cycle on every execution -- the
always-executed delay-slot clone was emitted but counted by neither the branch
block nor the (unentered) delay-slot block. ~8 such sites on the boot init path
= the measured -8 cycle drift that made native run behind the interpreter, so
the frame-1824 elapsed-Timer1 logo-delay loop exited after ~42 iterations
(native) instead of ~1557 (interp/HW) and never reached the FMV.

Fix (code_generator.cpp translate_basic_block, the faithful class fix -- every
dynamically executed instruction charged exactly once):
  block_exec_cycles = instruction_count
                    + (exit branch at end_addr with delay slot outside ? 1 : 0)
applied to both the block cycle charge and the slice budget. R3000 is MIPS-I so
the delay slot is unconditional; the not-taken path already falls through to
branch_pc+8 (no double-count). Result, screenshot-verified: native passes the
logo-delay loop, screen animates, CDROM+DMA stream active, the jungle intro FMV
plays.

Also in this checkpoint (all on wt/tomba2, not merged/pinned):
- PARKED precise interrupt-slicing (PRECISE_IRQ_SLICE.md): root-caused its pc=0
  boot exit to a mid-function clean-text resume PC the dispatcher can't re-enter;
  ChatGPT-validated fix is "every block leader = re-enterable CPS continuation"
  (psx_game_is_function_entry predicate added; precise_pc_dispatchable +
  irq_taken-once restructure in psx_run_precise). Default OFF (PSX_PRECISE_SLICE=1
  to resume); inert on the baseline. Slice-trace diagnostics added to freeze_check.
- FAITHFUL_TIMING_PLAN.md: authoritative game plan (P1-P6) + standing directive
  (build the faithful core, never hacks; ecosystem is being built, not preserved);
  referenced from CLAUDE.md Rule -1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-26 18:24:16 -07:00
parent d7f84cb98b
commit f9d50d7503
9 changed files with 538 additions and 2 deletions
+31
View File
@@ -5,6 +5,37 @@ session before doing any work.
---
## ⛔ RULE -1: BUILD THE FAITHFUL CORE — NO HACKS, NO PRESERVATION
This ecosystem is being BUILT, not maintained. There is nothing to preserve.
- The correct fix is ALWAYS the faithful, hardware-accurate, class-level core.
NEVER a surgical per-game patch, a symptom workaround, a `game.toml` hack
(e.g. `overlay_native_block`), or "make native agree with interp even if both
are fake."
- When a narrow patch and a general faithfulness fix both exist, the
faithfulness fix WINS — even if larger, even if it breaks other titles.
Other games were built on a faulty ecosystem and will be **regenerated**.
Backward-compatibility is NOT a constraint.
- The recurring failure mode (weeks lost) is veering into a quick hack that
doesn't even work and burns the session. Do not do this. If you catch
yourself proposing a "surgical"/"temporary"/"just for this title" fix — STOP
and build the real thing.
- PSX timing specifically: the faithful core = ONE shared per-instruction
cycle-cost function for BOTH backends (compiled + dirty-RAM interp), exact
block cycle totals (delay-slot ownership), timers derived on-demand from a
global guest-cycle counter, devices on scheduled event deadlines, every
basic-block leader re-enterable. Confer with ChatGPT via the Chrome MCP
browser (chatgpt.com, "PSX Static Recompiler Debug" chat), not the codex CLI.
This supersedes any pressure to ship fast. Completeness is absolute here.
**The authoritative game plan is `FAITHFUL_TIMING_PLAN.md` — READ IT EACH
SESSION** (north star, phased plan P1–P6, current status/log). Update its
Status/Log section every session.
---
## 0. The architecture is locked
v4 implements **Architecture A**: static MIPS-to-C recompilation of
+138
View File
@@ -0,0 +1,138 @@
# Faithful Timing Core — Game Plan (psxrecomp)
**READ THIS EACH SESSION.** Referenced from CLAUDE.md Rule -1 and from the
auto-memory ([[psxrecomp-build-faithful-core-not-hacks]],
[[precise_irq_slice_state]]). This is the authoritative plan; update the
"Status / Log" section every session.
---
## 0. North star + guardrails (non-negotiable)
Build the **faithful hardware-timing core** of the static recompiler. The PSX
recompiler is being BUILT, not preserved:
- The correct fix is ALWAYS the faithful, class-level core — NEVER a surgical
per-game patch, symptom workaround, `game.toml` hack, or "make native agree
with interp even if both are fake."
- Breaking other titles is acceptable; they were built on a faulty ecosystem and
will be **regenerated**. Backward-compat is NOT a constraint.
- No stubs, no HLE, no interpreter-as-fallback (Architecture A locked). Fix the
recompiler/runtime and regenerate; never edit `generated/*.c`.
- Don't guess (PRINCIPLES). Confirm every mechanism with the oracle + rings
BEFORE changing code. Build observability first.
- Confer with ChatGPT via the **Chrome MCP browser at chatgpt.com** — the
existing "PSX Static Recompiler Debug" chat (the user has Plus logged in).
NOT the `codex` CLI (usage-limited).
## 1. The problem (diagnosis, confirmed)
A static recompiler charges cycles **block-granular** (instruction count up front
at each block leader) and checks IRQs at **block edges**. Real HW and the
sanctioned dirty-RAM interpreter have a **per-instruction** cycle timeline and
take IRQs at the exact instruction. Games that read timers / poll IRQ-driven
flags in tight loops fork between backends.
Tomba 2 (SCUS-94454) logo→FMV stall is the canonical case. The cascade:
1. Timer1 debounce value-fork @ pc 0x8008592C (frame 1823) — fixed by exact block
cycle costs ("Fix A", already in tree).
2. **CURRENT BLOCKER:** measured **−8 cycle drift** (native BEHIND interp),
entering in the BIOS→overlay init transition (func 0x80050B0C subtree). The
frame-1824 logo-delay wait loop (caller 0x8008AE48 → RCnt reader 0x80085900;
exits when *0x80102748[=960] < elapsed Timer1) loops ~1557× in interp (reaches
FMV) vs ~42× native (stuck on logo).
**Mechanism (located in code_generator.cpp):** when a branch's delay slot is
ALSO a block leader (`exit_has_delay && !delay_slot_in_block`, ~line 1243), the
branch block's `instruction_count` excludes the delay slot; on the TAKEN path
the delay-slot clone runs but its cycle is charged by neither the branch block
nor the (unentered) delay-slot block → undercount 1/site. ~8 sites = −8.
3. Interrupt take-point granularity (block-edge vs exact instruction) — the
"precise IRQ slicing" track. PARKED (default off); it is a later correctness
upgrade, NOT the FMV blocker. Validated design exists (block-leader
continuations); see §5.
## 2. The target architecture (what "faithful core" means)
Per ChatGPT (validated) + standard practice:
- ONE shared **per-instruction cycle-cost function** `psx_instr_base_cycles(pc,
insn)` used by BOTH the dirty-interp and the recompiler. No two approximate
models.
- Recompiler emits **exact** accumulated cycle charges (collapses to a constant
per pure-compute block); every dynamically executed instruction charged exactly
once; delay slots owned by the branch bundle.
- **Segmented charge** before any guest-visible time observation (MMIO read/write
to timers/GPUSTAT/SPUSTAT/DMA/CD/I_STAT/I_MASK, BIOS/device calls, backedges,
calls/returns) so native and interp observe devices at the same architectural
boundary.
- **Timers derived on-demand** from a global guest-cycle counter at read time;
DMA/CD/GPU/IRQ on **scheduled event deadlines** (not per-cycle ticking) so
compiled stays fast.
- Invariant: *every execution backend may differ in host implementation, but not
in guest-visible time.* At same-PC convergence points, native cycle total ==
interp cycle total.
- pc=0 means ONLY a real guest pc=0 / explicit termination — NEVER "dispatcher
couldn't re-enter." Fail closed + log on undispatchable PCs.
## 3. Phased plan (each phase: confirm → build → regen → run → measure → screenshot)
- **P1 — Cycle-audit observability.** Add a per-function/at-convergence cycle
audit: record native vs interp cumulative guest cycles at same-PC points; expose
via TCP/ring. SUCCESS: reproduces the flat −8 and pinpoints the entering site(s).
- **P2 — Delay-slot cycle ownership (the −8).** Fix in code_generator.cpp: branch
bundle charges its delay slot; not-taken fallthrough → branch_pc+8 (not the
delay-slot leader); delay-slot-as-standalone-leader charges itself; no
double-count on the not-taken path. SUCCESS: audit shows −8 → 0; Tomba 2 reaches
the intro FMV (screenshot). Likely the FMV unblock.
- **P3 — Shared per-instruction cost function.** Single `psx_instr_base_cycles`
consumed by both backends; recompiler emits exact accumulated charges with
MMIO/boundary segmentation. SUCCESS: first-divergence hashes identical past frame
1824 across a longer run; audit stays 0.
- **P4 — On-demand timers + event deadlines.** Timer1/2/0 computed from global
cycle counter at read; devices on scheduled deadlines. SUCCESS: no perf
regression; timing-sensitive paths stable.
- **P5 — Precise take-points (fold in parked work).** Re-enable slicing; emit
EVERY block leader as a CPS continuation (global dispatch → owning func w/
cpu->pc; never a new entry; fail-closed on undispatchable). SUCCESS: exact-
instruction IRQ delivery with bounded (one-block) hand-back.
- **P6 — Regression + faithfulness.** Regen + screenshot-smoke ALL titles (BIOS,
Tomba 1, MMX6, Ape, Tomba 2); delete Tomba2 `overlay_native_block` (must still
reach FMV/title); calibrate the shared model against Beetle/psx-spx. Pin bump is
user-gated.
## 4. Tooling / oracle
- Runtime TCP port 4500; Beetle oracle 4382. Always-on rings: `event_ring`,
`wtrace_all` (write trace; `newest=1`). `freeze_check` has slice-trace + cycle
fields. `PSX_EXIT_HALT=1` halts-and-serves at the pc=0 exit for post-mortem.
- Build runtime: `cmake --build Tomba2Recomp/build-t2 --target psx-runtime`
(PATH=/c/msys64/mingw64/bin). Recompiler: `cmake --build
_wt-tomba2/psxrecomp/recompiler/build-t2 --target psxrecomp-game`. Regen:
`recompiler/build-t2/psxrecomp-game.exe --config game.toml` (rebuild tool first).
- Reference: nocash psx-spx; the dirty-RAM interp is the in-process oracle for
compiled code; Beetle is the HW oracle.
## 5. Status / Log (update every session)
- **2026-06-26 (P2 DONE — Tomba 2 reaches the intro FMV):** Implemented the
delay-slot cycle-ownership fix in code_generator.cpp (translate_basic_block):
`block_exec_cycles = instruction_count + (exit branch sits AT end_addr with a
delay slot outside the block ? 1 : 0)` — charges the always-executed delay-slot
clone that was previously uncounted (the -8 undercount). Applied to BOTH the
slice budget and the block cycle charge. Regen + build clean. RESULT: native
progresses past the frame-1824 logo-delay loop; screen animates; i_stat shows
CDROM+DMA+SIO active; screenshot = the lush jungle intro FMV. The multi-week
logo stall is GONE via the faithful fix (no hack, overlay_native_block untouched
for now). Mechanism was structurally confirmed (instruction_count excludes the
outside delay-slot clone) before the change; end-to-end confirmed by FMV
screenshot. NEXT: P3 (shared per-instruction cost fn + MMIO segmentation), P6
validation (delete overlay_native_block; regress other titles), and Stage-2 HW
cycle calibration vs Beetle/psx-spx (current model is 1 cycle/insn = backend-
equivalent, NOT yet hardware-accurate). Apply the same delay-slot fix to the
BIOS emitter (full_function_emitter.cpp) and overlay/alias paths.
- **2026-06-26 (earlier):** Diagnosis corrected (timing-faithfulness, not take-point).
Directive persisted (CLAUDE.md Rule -1, memory, MEMORY.md banner). Precise
slicing root-caused (mid-function clean-text resume not dispatchable) + ChatGPT-
validated fix (all block leaders = CPS continuations) — PARKED default-off;
`psx_game_is_function_entry` predicate + slice-trace diagnostics + env toggle
`PSX_PRECISE_SLICE` left in tree (inert). −8 mechanism located in
code_generator.cpp (delay-slot-is-leader undercount). Tree builds + boots clean.
NEXT: P1 (cycle-audit) → P2 (delay-slot ownership fix).
+57
View File
@@ -134,6 +134,63 @@ to the safe resume PC, then the block `return`s and the trampoline tail-dispatch
the safe PC compiled. (Decide seam during impl; keep generated-code change to the
single emitted guard line.)
## Implementation status (2026-06-26 evening) — Task #4 ROOT-CAUSED (handoff hypothesis CORRECTED)
The prior handoff's Task #4 hypothesis ("nested psx_check_interrupts leaves pc=0
because of setjmp/dispatch-depth/cooldown/VBlank-pacing") was REFUTED by state
evidence. The real root cause is the RESUME PC, not the take machinery.
### Evidence (slice-trace + always-on rings, port 4500)
- Reproduced the deterministic pc=0 with the current build. Added slice-trace
globals (g_slice_last_block/_first_pc/_first_insn/_committed/_istat/_imask/_sr/
_entry_deliverable) surfaced in freeze_check.
- The slice fires at game block 0x80017FEC (NOT 0x8001A2F4 — the earlier
event-ring read of "resume=0xB0" was a DIFFERENT, earlier delivery; red herring).
first_insn = 0x8FA20010 (`lw v0,0x10(sp)`), committed = 0x80017FF0.
- precise_irq_deliverable was TRUE at slice entry; the loop ran ONE instruction then
took the IRQ with resume = 0x80017FF0 — a MID-FUNCTION clean-game-text PC.
- ROOT CAUSE: psx_dispatch_game_compiled is a switch over function ENTRIES (+ CPS
call-return continuations). A mid-function clean-text PC is NOT in it -> returns 0;
the page is not dirty -> dirty_ram_dispatch_inner returns 0 (line 1685-1731) ->
top-level reads pc as 0 ("execution completed"). NOT a take-machinery bug.
Tested+REFUTED: gating cooldown + VBlank-pacing on g_precise_mode did NOT fix it.
- A/B isolation (PSX_PRECISE_SLICE env toggle, now permanent in psx_slice_block):
slice-OFF boots clean past frame 798; slice-ON dies. The slice is the sole cause.
### The fix (ChatGPT-validated, "PSX Static Recompiler Debug" thread 2026-06-26)
DECISIVE RECOMMENDATION: make EVERY basic-block leader a re-enterable CPS
continuation. That is the correct class fix for exact-instruction IRQs in a static
recompiler. Do NOT route clean compiled text through the dirty-RAM interpreter
(reintroduces the 4096-pump/cap/deadlock class). Invariant:
function entry != only valid native entry;
basic-block leader = valid intra-function continuation entry.
- Global dispatch maps each block leader -> its OWNING function with cpu->pc set
(`case L: cpu->pc=L; func_OWNER(cpu); return 1;`), NOT a new function entry —
preserves "never seed an interior address as a function entry" (truncation rule).
- Each function's existing `if(cpu->pc!=0) switch(_cont){case X: goto block_X;}`
is extended to ALL block leaders (today only CPS call-returns).
- precise-mode interprets ONLY to the next post-transfer boundary (a block leader),
then hands back — bounded to "remaining insns in current block + delay/transfer",
NOT "until some future call-return". Fixes the delay-loop deadlock
(do{[sp+16]--;}while(!=-1) in func_80017FC4, leaders 0x80017FEC/..34 not entries).
- Block-leader set must include: branch/jump targets, post-delay fallthrough
(branch_pc+8), call-return PCs, statically-known computed-jump targets.
- Pitfall (live-in safety): satisfied — guest regs are canonical in cpu->gpr[]
(generated C uses cpu->gpr[] directly; only block-local _bc_* temps are C locals).
- FAIL CLOSED, never silent pc=0: if the slice cap trips before reaching a
dispatchable PC -> fatal diagnostic precise_slice_failed_to_reach_block_boundary
{pc,start,last,count,owner}. If a clean-text transfer target is not a known leader
(indirect/jump-table discovery gap) -> log clean_text_undispatchable_pc + fail
closed. pc=0 must mean only a real guest pc=0 / explicit termination.
PROGRESS so far: runtime psx_run_precise restructured (irq_taken-once + every exit
gated on precise_pc_dispatchable, which calls the new generator predicate
psx_game_is_function_entry). With ONLY function-entries+CPS-returns dispatchable,
boot advanced frame 798->800 and 1 slice -> 432 slices, then deadlocks on the
func_80017FC4 delay loop (its leaders aren't entries). NEXT: emit ALL block leaders
as continuations in the generator (full_function_emitter.cpp + main_psx.cpp), then
the delay-loop leaders become dispatchable and the slice hands back within one block.
## Validation [Task #5]
- Beetle exc_ring oracle: native exception-entry record (cycle, last/next PC,
EPC, BD, Status/Cause, I_STAT/I_MASK, pending-load) must match interp + Beetle
+59 -2
View File
@@ -1177,16 +1177,73 @@ std::string CodeGenerator::translate_basic_block(
// Block label
ss << fmt::format("block_{:08X}:\n", block.start_addr);
// Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md). At the block
// leader — before any cycle is charged or the body runs — divert to the
// per-instruction interpreter when an interrupt could be taken inside this
// block, so the IRQ lands at its exact architectural instruction instead of
// the coarse block edge. psx_slice_block returns nonzero iff it sliced (it
// interpreted the block — and possibly more — and left cpu->pc at a
// dispatchable resume point), in which case this function returns so its
// compiled body does not re-execute the same instructions.
// side_effects=1 marks blocks that change interrupt visibility on the CPU
// (mtc0 Status/Cause, rfe), which can make a pending+masked IRQ deliverable
// mid-block with no external event deadline.
// Faithful cycle accounting (FAITHFUL_TIMING_PLAN.md P2): the exit branch/jump
// ALWAYS executes its delay-slot instruction (emitted as a clone before the
// transfer, below). When the delay slot lives INSIDE this block
// (block.exit_instr.address < end_addr) it is already part of
// instruction_count. But when the delay slot is a SEPARATE block leader (a
// branch target, or a split-function edge) the branch sits AT end_addr and the
// delay slot at end_addr+4 is outside [start,end]; instruction_count then
// EXCLUDES it, yet the clone still executes — so the block undercharges by one
// cycle vs the per-instruction interpreter on BOTH paths. (This is the measured
// -8 drift in the Tomba 2 logo init subtree: ~8 such sites on the boot path,
// native runs behind interp, the elapsed-Timer1 logo-delay loop exits early.)
// Charge the always-executed clone here so every dynamically executed
// instruction is charged exactly once. (R3000 is MIPS-I: no branch-likely, so
// the delay slot is unconditional.) cycle_per_insn mode already charges the
// clone per-instruction at its emit site, so this adjustment is block-mode only.
const bool exit_delay_slot_outside =
block.exit_instr.has_delay_slot &&
block.exit_instr.type != ControlFlowType::None &&
block.exit_instr.address == block.end_addr;
const uint32_t block_exec_cycles =
static_cast<uint32_t>(block.instruction_count) + (exit_delay_slot_outside ? 1u : 0u);
{
uint32_t slice_bcyc = block_exec_cycles;
if (slice_bcyc == 0) slice_bcyc = 1;
int slice_side_effects = 0;
for (uint32_t a = block.start_addr; a <= block.end_addr; a += 4) {
auto w = exe_.read_word(a);
if (!w.has_value()) break;
uint32_t insn = *w;
if ((insn >> 26) == 0x10u) { // COP0
uint32_t rs = (insn >> 21) & 0x1Fu;
uint32_t co = (insn >> 25) & 0x1u;
uint32_t funct = insn & 0x3Fu;
if (rs == 0x04u) slice_side_effects = 1; // MTC0 (writes Status/Cause)
if (co && funct == 0x10u) slice_side_effects = 1; // RFE
}
}
ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n";
ss << config_.indent
<< fmt::format("if (psx_slice_block(cpu, 0x{:08X}u, {}u, {})) return;\n",
block.start_addr, slice_bcyc, slice_side_effects);
ss << "#endif\n";
}
const bool cycle_per_insn = codegen_cycle_per_insn();
auto emit_one_cycle = [&](const std::string& indent) {
ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n";
ss << indent << "psx_advance_cycles(1u);\n";
ss << "#endif\n";
};
if (!cycle_per_insn && block.instruction_count > 0) {
if (!cycle_per_insn && block_exec_cycles > 0) {
ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n";
ss << config_.indent << fmt::format("psx_advance_cycles({}u);\n",
static_cast<uint32_t>(block.instruction_count));
block_exec_cycles);
ss << "#endif\n";
}
+25
View File
@@ -906,6 +906,31 @@ int main(int argc, char** argv) {
ds << " }\n";
ds << "}\n";
// Non-destructive companion to psx_dispatch_game_compiled: is `addr` a PC
// the top-level dispatch can re-enter (a compiled function entry, or a CPS
// continuation resume-point)? The precise event slicer (dirty_ram_interp.c)
// needs this to know when it may hand a resume PC back to the dispatcher:
// a MID-function clean-text PC is NOT re-enterable (the switch falls to
// `default: return 0` and the dirty path returns 0 -> top-level PC=0 exit),
// so the slicer must keep interpreting until cpu->pc lands on a true entry.
// Same case set as the dispatch switch above, minus the function calls.
ds << "\n/* 1 iff addr is a re-enterable compiled entry/continuation (no exec). */\n";
ds << "int psx_game_is_function_entry(uint32_t addr) {\n";
ds << " switch (addr) {\n";
for (uint32_t addr : dispatch_addrs) {
ds << fmt::format(" case 0x{:08X}u: return 1;\n", addr);
}
if (codegen.cps_enabled()) {
const auto& conts = codegen.cps_continuations();
for (const auto& [cont, owner] : conts) {
if (dispatch_addrs.count(cont)) continue;
ds << fmt::format(" case 0x{:08X}u: return 1;\n", cont);
}
}
ds << " default: return 0;\n";
ds << " }\n";
ds << "}\n";
if (codegen.cps_enabled()) {
// RECURSION_BUG.md §25 — mark CPS mode at startup so the overlay sljit
// JIT (overlay_sljit.c) emits the CPS contract. Static ctor: no clash
+8
View File
@@ -78,6 +78,14 @@ extern void psx_native_bad_entry(CPUState* cpu, uint32_t owner, uint32_t pc);
extern void psx_dispatch(CPUState* cpu, uint32_t target_addr);
extern void psx_dispatch_call(CPUState* cpu, uint32_t target_addr, uint32_t return_addr);
/* Cycle-budgeted precise event slicing — defined in runtime/src/dirty_ram_interp.c.
* Emitted at each compiled block leader: if it returns nonzero the block ran
* through the per-instruction interpreter (interrupt taken at the exact
* architectural instruction) and cpu->pc holds a dispatchable resume point, so
* the caller must `return` without executing its compiled body. See
* PRECISE_IRQ_SLICE.md. */
extern int psx_slice_block(CPUState* cpu, uint32_t block_addr, uint32_t bcyc, int side_effects);
/* Unknown dispatch — defined in runtime/src/traps.c */
extern void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys);
+24
View File
@@ -7174,6 +7174,10 @@ static void handle_freeze_check(int id, const char *json)
extern uint32_t g_dirty_safe_resume_pc;
extern uint64_t g_async_rfe_set_count;
extern uint64_t g_async_rfe_fire_count;
extern uint64_t g_slice_fired, g_slice_irq_taken;
extern uint32_t g_slice_last_block, g_slice_last_first_pc, g_slice_last_first_insn;
extern uint32_t g_slice_last_committed, g_slice_last_istat, g_slice_last_imask, g_slice_last_sr;
extern uint32_t g_slice_entry_deliverable;
extern uint64_t g_sentinel_reach_dirty;
extern uint64_t g_sentinel_reach_traps;
extern uint32_t g_sentinel_reach_async;
@@ -7264,6 +7268,16 @@ static void handle_freeze_check(int id, const char *json)
"\"dirty_ram_insns\":%llu,"
"\"dirty_ram_aborts\":%llu,"
"\"dirty_ram_guard_yields\":%llu,"
"\"slice_fired\":%llu,"
"\"slice_irq_taken\":%llu,"
"\"slice_block\":\"0x%08X\","
"\"slice_first_pc\":\"0x%08X\","
"\"slice_first_insn\":\"0x%08X\","
"\"slice_committed\":\"0x%08X\","
"\"slice_istat\":\"0x%08X\","
"\"slice_imask\":\"0x%08X\","
"\"slice_sr\":\"0x%08X\","
"\"slice_entry_deliverable\":%u,"
"\"fn_entry_total\":%llu,"
"\"sio_irq_total\":%u,"
"\"sio_byte_seq\":%u,"
@@ -7307,6 +7321,16 @@ static void handle_freeze_check(int id, const char *json)
(unsigned long long)g_dirty_ram_insns_run,
(unsigned long long)g_dirty_ram_aborts,
(unsigned long long)g_dirty_ram_guard_yields,
(unsigned long long)g_slice_fired,
(unsigned long long)g_slice_irq_taken,
g_slice_last_block,
g_slice_last_first_pc,
g_slice_last_first_insn,
g_slice_last_committed,
g_slice_last_istat,
g_slice_last_imask,
g_slice_last_sr,
g_slice_entry_deliverable,
(unsigned long long)s_fn_entry_seq,
irq_total,
sio_get_seq(),
+192
View File
@@ -58,6 +58,27 @@ uint64_t g_dirty_pump_count = 0;
* this 0 -> the sentinel + host-GPR-restore + longjmp path (unchanged: T1/MMX6/Ape). */
uint32_t g_dirty_safe_resume_pc = 0;
/* Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md). Set while the
* block-leader slice guard is running a block through the per-instruction
* interpreter so the interrupt is taken at the EXACT architectural instruction
* (not the coarse block edge). While set, exec_one's jal/jalr short-circuit to a
* plain transfer (cpu->pc = target; return 1) so precise-mode steps INTO callees
* per-instruction instead of running them compiled — the "ignore native
* availability" invariant (ChatGPT-validated option b). Default 0 => every other
* path is byte-for-byte unchanged. */
int g_precise_mode = 0;
uint64_t g_slice_fired = 0; /* diagnostic: slices actually run */
uint64_t g_slice_irq_taken = 0; /* diagnostic: IRQs taken inside precise-mode */
/* First-divergence trace for the precise slice (PRECISE_IRQ_SLICE.md Task #4). */
uint32_t g_slice_last_block = 0; /* block_addr the guard fired on */
uint32_t g_slice_last_first_pc = 0; /* pc of the first interp instruction */
uint32_t g_slice_last_first_insn= 0; /* raw word of that first instruction */
uint32_t g_slice_last_committed = 0; /* committed PC handed back as resume/EPC */
uint32_t g_slice_last_istat = 0; /* i_stat at the take */
uint32_t g_slice_last_imask = 0; /* i_mask at the take */
uint32_t g_slice_last_sr = 0; /* COP0 SR at the take */
uint32_t g_slice_entry_deliverable = 0; /* was IRQ deliverable at slice entry? */
/* Persistent async-RFE resume PC (Tomba 2 frame-1997 fix). psx_check_interrupts latches
* this from g_dirty_safe_resume_pc at each dirty-safe-point exception entry, so it holds
* the real guest PC of the most recent dirty-interp interruption. When a game-installed
@@ -185,6 +206,8 @@ uint32_t g_overlay_region_floor = OVERLAY_REGION_FLOOR_DEFAULT;
#ifdef PSX_HAS_GAME_DISPATCH
extern int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr);
extern int psx_game_address_in_text(uint32_t addr);
extern int psx_game_is_function_entry(uint32_t addr); /* non-destructive entry test */
#endif
extern void psx_dispatch_call(CPUState* cpu, uint32_t addr, uint32_t return_addr);
@@ -1007,6 +1030,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
uint32_t site_sp = cpu->gpr[29]; /* call contract: sp at the call */
xprobe_event(pc, XOP_JALR, XSITE_INTERP, target,
fetch_word((pc + 4) & 0x1FFFFFFFu), site_sp, cpu->gpr[31], 1);
if (g_precise_mode) { cpu->pc = target; return 1; } /* slice: step INTO callee per-insn */
#ifdef PSX_HAS_GAME_DISPATCH
cpu->pc = 0;
if (interp_enter_compiled(cpu, target)) {
@@ -1156,6 +1180,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
uint32_t site_sp = cpu->gpr[29]; /* call contract: sp at the call */
xprobe_event(pc, XOP_JAL, XSITE_INTERP, target,
fetch_word((pc + 4) & 0x1FFFFFFFu), site_sp, cpu->gpr[31], 1);
if (g_precise_mode) { cpu->pc = target; return 1; } /* slice: step INTO callee per-insn */
#ifdef PSX_HAS_GAME_DISPATCH
cpu->pc = 0;
if (interp_enter_compiled(cpu, target)) {
@@ -1497,6 +1522,173 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) {
return r;
}
/* ===== Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md) ===== */
/* Is a hardware interrupt deliverable to the guest at this exact instruction
* boundary? Mirrors the gate in psx_check_interrupts: a pending+unmasked I_STAT
* bit, COP0 IEc + IM2 set, and not already inside the exception handler. */
static int precise_irq_deliverable(CPUState *cpu) {
extern uint32_t i_stat;
if ((i_stat & i_mask) == 0) return 0;
if (psx_get_in_exception()) return 0;
uint32_t sr = cpu->cop0[12];
if (!(sr & 0x1u)) return 0; /* IEc: interrupts globally enabled */
if (!(sr & (1u << 10))) return 0; /* IM2: hardware interrupt mask bit */
return 1;
}
/* Is `pc` a point the TOP-LEVEL dispatcher can re-enter? Clean (compiled) game
* text is only re-enterable at a FUNCTION ENTRY: psx_dispatch_game_compiled is a
* switch over entries and returns 0 for any mid-function PC; the dirty path then
* sees a non-dirty page and also returns 0, which the top dispatch reads as PC=0
* ("execution completed" — the deterministic precise-slice boot exit, root-caused
* to a mid-function clean-text resume PC, PRECISE_IRQ_SLICE.md Task #4). Every
* other PC class — BIOS ROM, dirty RAM (overlays / install-at-runtime stubs),
* overlay candidates, the A0/B0/C0 kernel call vectors, scratchpad — is handled by
* psx_dispatch / the dirty interp / the overlay loader, so it is dispatchable.
* Therefore the precise slicer must never hand control back at a mid-function
* clean-text PC: it keeps interpreting (exec_one handles arbitrary mid-function
* flow) until cpu->pc satisfies this predicate. */
static int precise_pc_dispatchable(uint32_t pc) {
#ifdef PSX_HAS_GAME_DISPATCH
uint32_t phys = pc & 0x1FFFFFFFu;
if (psx_game_address_in_text(pc) && !dirty_ram_is_dirty(phys))
return psx_game_is_function_entry(pc);
#endif
(void)pc;
return 1;
}
/* Run the guest per-instruction from cpu->pc through the interpreter, taking any
* deliverable interrupt at the EXACT instruction boundary, until a safe resume
* point. g_precise_mode makes exec_one step INTO jal/jalr callees per-instruction
* (ignore native availability), so the slice is owned by the event deadline, not
* the function boundary. ALL exits land on a control-transfer target — every
* branch/jump target is dispatchable (compiled blocks end with cpu->pc=target;
* return), whereas an arbitrary mid-block PC is not — so after taking a mid-block
* IRQ we keep interpreting to the next transfer before handing back to compiled.
* `bcyc` = originating block's cycle budget; `deadline_entry` = entered because an
* event is due within bcyc (vs a side-effect-only block, which runs one block). */
static void psx_run_precise(CPUState *cpu, uint32_t bcyc, int deadline_entry) {
int prev_precise = g_precise_mode;
int prev_active = g_dirty_interp_active;
g_precise_mode = 1;
g_dirty_interp_active = 1;
uint32_t pc = cpu->pc;
g_slice_last_block = pc;
g_slice_last_first_pc = pc;
g_slice_last_first_insn = fetch_word(pc & 0x1FFFFFFFu);
int irq_taken = 0; /* one take per slice (avoid re-taking an unacked IRQ) */
enum { MAX_PRECISE_INSNS = 200000 };
for (int i = 0; i < MAX_PRECISE_INSNS; i++) {
uint32_t next_pc = 0;
g_unsupported_seen = 0;
int transferred = exec_one(cpu, pc, &next_pc);
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_advance_cycles(1u);
#endif
g_dirty_ram_insns_run++;
if (g_unsupported_seen) {
/* Valid compiled code always decodes; if not, hand the committed PC to
* the dispatcher rather than abort the slice. */
g_unsupported_seen = 0;
pc = transferred ? cpu->pc : next_pc;
break;
}
uint32_t committed = transferred ? cpu->pc : next_pc;
/* `want_exit`: the slice has done its job and would like to hand back. But
* it may ONLY return control at a dispatchable PC (precise_pc_dispatchable)
* — a mid-function clean-text resume PC is unhandleable by the top dispatch
* (root cause of the deterministic boot PC=0). When we want to exit but the
* committed PC is mid-function clean text, keep interpreting (exec_one runs
* arbitrary mid-function flow) until control lands on a real entry / leaves
* clean game text. */
int want_exit = 0;
/* Exact take-point: an interrupt deliverable on THIS instruction's cycle is
* taken before the next instruction retires — once per slice. Re-checking
* after a take re-fires an IRQ the handler has not acked yet (an
* 8-takes-in-11-insns storm), so gate on !irq_taken. */
if (!irq_taken && precise_irq_deliverable(cpu)) {
extern uint32_t i_stat;
g_slice_last_committed = committed;
g_slice_last_istat = i_stat;
g_slice_last_imask = i_mask;
g_slice_last_sr = cpu->cop0[12];
cpu->pc = committed;
g_dirty_safe_resume_pc = committed; /* real EPC for exception entry */
psx_check_interrupts(cpu); /* takes it; runs handler; restores GPRs */
g_dirty_safe_resume_pc = 0;
g_slice_irq_taken++;
irq_taken = 1;
cpu->pc = committed; /* resume the interrupted stream at the exact PC */
want_exit = 1;
}
pc = committed;
cpu->pc = committed;
if (g_psx_call_bail) break; /* wild unwind: hand cpu->pc to the dispatcher */
if (irq_taken) {
want_exit = 1; /* after the take, leave as soon as it is safe */
} else if (transferred) {
if (!deadline_entry) want_exit = 1; /* side-effect block: one block */
else if (cycles_to_next_event() > bcyc) want_exit = 1; /* imminent window passed */
/* else still imminent — keep slicing across this boundary */
}
/* Hand back ONLY at a dispatchable PC. Otherwise (mid-function clean text)
* keep interpreting until one is reached. */
if (want_exit && precise_pc_dispatchable(cpu->pc)) break;
}
cpu->pc = pc;
g_precise_mode = prev_precise;
g_dirty_interp_active = prev_active;
}
/* Block-leader slice guard, called by the emitted prologue of every compiled
* block BEFORE it charges cycles / runs its body:
* if (psx_slice_block(cpu, <block_addr>, <bcyc>, <side_effects>)) return;
* Returns 1 if it sliced (ran the block — and possibly more — through the precise
* interpreter and left cpu->pc at a dispatchable resume point; the caller MUST
* `return` so its compiled body does not re-execute the same instructions).
* Returns 0 if the whole block is provably safe to run as fast compiled C. */
int psx_slice_block(CPUState *cpu, uint32_t block_addr, uint32_t bcyc, int side_effects) {
/* Runtime A/B toggle (diagnostic, stays in the build): PSX_PRECISE_SLICE=0
* disables precise slicing entirely so the SAME binary can be run slice-on vs
* slice-off to isolate the first divergence the slice introduces. Read once. */
/* PARKED (PRECISE_IRQ_SLICE.md): precise take-point slicing is a later
* correctness upgrade, NOT the current FMV blocker (that is the -8 cycle
* drift / faithful per-instruction cycle model — see CLAUDE.md Rule -1).
* Default OFF so the runtime boots on the baseline; opt in with
* PSX_PRECISE_SLICE=1 to continue the block-leader-continuation work. */
static int s_slice_enabled = -1;
if (s_slice_enabled < 0) {
const char *e = getenv("PSX_PRECISE_SLICE");
s_slice_enabled = (e && e[0] == '1') ? 1 : 0;
}
if (!s_slice_enabled) return 0;
/* No nested slicing: a handler dispatched from inside precise-mode, and any
* block executed while in_exception, run compiled (interrupts are gated during
* exception handling anyway). Keeps re-entrancy structurally impossible. */
if (g_precise_mode || psx_get_in_exception()) return 0;
int entry_deliverable = precise_irq_deliverable(cpu);
int has_deadline = entry_deliverable || (cycles_to_next_event() <= bcyc);
if (!has_deadline && !side_effects) return 0; /* fast path: no event in this block */
g_slice_entry_deliverable = (uint32_t)entry_deliverable;
g_slice_fired++;
cpu->pc = block_addr;
psx_run_precise(cpu, bcyc, has_deadline);
return 1;
}
static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr) {
uint32_t phys = addr & 0x1FFFFFFFu;
+4
View File
@@ -2821,6 +2821,10 @@ int main(int argc, char** argv) {
}
std::fprintf(stdout, "psxrecomp runtime: execution completed, PC=0x%08X\n", cpu.pc);
{ extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run;
std::fprintf(stdout, "psxrecomp runtime: [slice diag] slice_fired=%llu slice_irq_taken=%llu dirty_insns=%llu\n",
(unsigned long long)g_slice_fired, (unsigned long long)g_slice_irq_taken,
(unsigned long long)g_dirty_ram_insns_run); }
shutdown_runtime();
if (g_gl_active) gl_renderer_shutdown();