mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 10:19:47 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user