From 553d9933d15a2398b894297409a490e5ef76cf05 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 1 Jul 2026 09:28:50 -0700 Subject: [PATCH] fix mmx6 timing divergences with cosim oracle --- COSIM_HANDOFF.md | 295 ++++++++++++ COSIM_ORACLE.md | 188 ++++++++ recompiler/src/code_generator.cpp | 101 +++- recompiler/src/full_function_emitter.cpp | 70 ++- recompiler/src/main_psx.cpp | 13 +- recompiler/src/strict_translator.cpp | 21 +- runtime/include/cosim_state.h | 67 +++ runtime/include/interrupts.h | 11 +- runtime/include/lockstep.h | 21 + runtime/runtime.cmake | 13 +- runtime/src/cosim.c | 371 +++++++++++++++ runtime/src/cosim_state.c | 220 +++++++++ runtime/src/debug_server.c | 37 +- runtime/src/dirty_ram_interp.c | 557 +++++++++++++++++++++-- runtime/src/gpu.c | 69 +++ runtime/src/gte.cpp | 4 + runtime/src/interrupts.c | 378 ++++++++++++--- runtime/src/main.cpp | 97 ++-- runtime/src/memory.c | 23 +- runtime/src/psx_cycles.c | 38 +- tools/cosim.py | 289 ++++++++++++ 21 files changed, 2687 insertions(+), 196 deletions(-) create mode 100644 COSIM_HANDOFF.md create mode 100644 COSIM_ORACLE.md create mode 100644 runtime/include/cosim_state.h create mode 100644 runtime/src/cosim.c create mode 100644 runtime/src/cosim_state.c create mode 100644 tools/cosim.py diff --git a/COSIM_HANDOFF.md b/COSIM_HANDOFF.md new file mode 100644 index 00000000..748937e6 --- /dev/null +++ b/COSIM_HANDOFF.md @@ -0,0 +1,295 @@ +# Handoff — MMX6 first-divergence co-sim oracle (2026-06-30) + +Continuation of the MMX6 cutscene→gameplay wedge hunt. Read `COSIM_ORACLE.md` first +(the design + the four validation gates). This file = current state + exact next steps. + +## The mandate (do not drift from this) + +After ~2 weeks of confident-but-wrong "this is it" root causes, the user forbade chasing +any more single hypotheses — **including** the currently-leading-but-UNPROVEN +GPU-draw-time-backpressure idea (Beetle-anchored, bisect-confirmed as a *lever*, never +rendered a frame). We are building a **decision procedure**: a full-architectural-state +lockstep co-sim of the two backends that halts at the FIRST divergence. Whatever it halts +on *is* the first divergence — no hypothesis can be "wrong." It will confirm or kill GPU +backpressure (and everything else) on its own. **Do not implement GPU backpressure or any +other fix until the oracle names the first divergence.** + +## Established facts (proven; do NOT re-chase — each cost days) + +- Compiled backend SKIPS the intro FMV (main-state 0→1, no substate 6) → wedges black at + `02 02`. Interp (`PSX_FORCE_INTERP=1`) plays it. SAME binary. +- Given identical inputs, compiled == interp byte-for-byte (block+function lockstep, 12.9M + + 560K units, zero divergence). So it's NOT codegen — it's WHEN an event is taken + relative to the instruction stream, which flips an early control-flow branch. +- Ruled out with data: async-RFE, CD-DMA-0x79D44 corruption (interp does the same write), + IRQ-storm/timeline, block-edge-vs-per-insn IRQ delivery, load-delay-slot exception + corruption (built + refuted, reverted). +- `PSX_LOAD_DELAY=0` makes compiled play the FMV (pixels) — but that's a secondary phase + modulator, not the faithful root, and it does NOT fix the later `02 02` gameplay wedge. +- TWO separate bugs: (1) FMV-skip, (2) `02 02` gameplay wedge (persists even with FMV fixed). + +## What is BUILT (this session, all uncommitted on branch `codex/mmx6-precise-slice`) + +Worktree: `F:\Projects\psxrecomp\_wt-tomba2\psxrecomp`. All cosim code is `#ifdef PSX_COSIM` +(zero effect on normal builds). + +New files: +- `COSIM_ORACLE.md` — design + gates (READ FIRST). +- `runtime/include/cosim_state.h`, `runtime/src/cosim_state.c` — full guest-arch-state + canonical FNV hash. Reuses `boot_state.c` per-device `_snapshot_write` serializers; ADDS + the CPU micro-state boot_state omits (muldiv_ts_done, gte_ts_done, read_absorb[33], + read_absorb_which, read_fudge, ld_which_t, ld_absorb). Incremental RAM page-hashes via + write hooks. VRAM excluded v1 (downstream, not causal). Gate-4 fault injection built in. +- `runtime/src/cosim.c` — engine: CYCLE-KEYED checkpoints (ring + cumulative chain hash + + park/step lockstep) + standalone minimal TCP server. Commands: status, chain, stride N, + runto , hash, sub, window N, inject ram|reg, reset. +- `tools/cosim.py` — coordinator: launches 2 instances, cycle-locksteps via runto/chain, + reports first divergence + sub-hash diff + window. Its --help documents the gate runs. + +Modified (guarded): +- `recompiler/src/code_generator.cpp` + `full_function_emitter.cpp` — emit guarded + `cosim_block(pc)` at each block leader (+ extern decls). REQUIRES REGEN to take effect + (see below); the CORE compare does NOT need it (see "why regen is optional for v1"). +- `runtime/src/psx_cycles.c` — `cosim_tick()` in `psx_advance_cycles` (THE alignment clock: + the only counter both backends drive identically). +- `runtime/src/dirty_ram_interp.c` — `cosim_block(pc)` in the interp loop. +- `runtime/src/memory.c` — `cosim_note_ram_write` in word/half/byte RAM writes (covers + CPU + DMA; DMA verified to route through psx_write_*_raw via the d44_ring evidence). +- `runtime/src/interrupts.c` — `interrupts_cosim_hash()` (folds cycles_since_vblank + + in_exception ONLY; EXCLUDES total_checks/dispatch_count/cooldown = backend-call-freq + artifacts that would false-positive). ALSO: reverted the refuted load-delay save/restore. +- `runtime/src/main.cpp` — `cosim_init()` at startup. +- `runtime/runtime.cmake` — cosim sources added to shared list; `COSIM` target option → + `PSX_COSIM=1 PSX_NO_DEBUG_TOOLS=1`. +- `MegaManX6Recomp/CMakeLists.txt` — `psx-cosim` target gated on `-DPSX_BUILD_COSIM=ON`. + +Also still uncommitted from earlier (Codex): the precise-slice / `--headless` mode / etc. +(both emitters have `psx_precise_interrupt_at`). Leave as-is; irrelevant to the oracle. + +## Build state (IN PROGRESS at handoff) + +- `build-wt` CANNOT be reconfigured (pre-existing worktree cache-path error, NOT our + changes). So the cosim build uses a FRESH dir: + `F:\Projects\psxrecomp\MegaManX6Recomp\build-cosim` (configured OK with: + `-G Ninja -DPSXRECOMP_V4_ROOT=F:/Projects/psxrecomp/_wt-tomba2/psxrecomp + -DPSX_BUILD_COSIM=ON -DPSX_LAUNCHER=OFF` + the mingw64 gcc/g++/ninja from build-wt cache, + RelWithDebInfo). +- BUILD PROGRESS: ALL translation units compiled CLEAN under PSX_NO_DEBUG_TOOLS+PSX_COSIM + (no unguarded-debug-call errors — the guards are correct). The ONLY failure was one link + error: `cosim_init()` called from main.cpp (C++) needed `extern "C"` — FIXED + (main.cpp:2486). Relinking at handoff. Expect the exe to link now; if any NEW link error + appears it'll be another C/C++ linkage or a missing cosim symbol — all cosim funcs are in + cosim.c/cosim_state.c (C files, added to the shared source list) so they should resolve. +- Build/run commands: + Configure (once): + cmake -S F:/Projects/psxrecomp/MegaManX6Recomp -B F:/Projects/psxrecomp/MegaManX6Recomp/build-cosim \ + -G Ninja -DCMAKE_C_COMPILER=C:/msys64/mingw64/bin/gcc.exe \ + -DCMAKE_CXX_COMPILER=C:/msys64/mingw64/bin/g++.exe \ + -DCMAKE_MAKE_PROGRAM=C:/msys64/mingw64/bin/ninja.exe -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DPSXRECOMP_V4_ROOT=F:/Projects/psxrecomp/_wt-tomba2/psxrecomp \ + -DPSX_BUILD_COSIM=ON -DPSX_LAUNCHER=OFF + Build: cmake --build F:/Projects/psxrecomp/MegaManX6Recomp/build-cosim --target psx-cosim -j 4 + (exe at build-cosim/psx-cosim.exe; run coordinator: python tools/cosim.py --a compiled --b compiled) + +## MILESTONE (2026-06-30 late): ORACLE BUILDS + RUNS + EARLY BOOT DETERMINISTIC + +`psx-cosim.exe` links (build-cosim, 89MB). Coordinator smoke test +(`--a compiled --b compiled --stride 262144 --max 8000000`, ~14 frames early boot): +BOTH instances launch, connect (4600/4601), cycle-lockstep via `runto`, and report +**ZERO divergence** — chains match at every checkpoint. So the full mechanism works: +two-process launch, TCP, cycle-stepping, full-state hashing, chain compare. Early boot +is deterministic. Deeper compiled-vs-compiled (to 250M cyc / ~440 frames) running to test +overlay-load-timing determinism (result in build task b5lvwwi0i / next session). + +## HARNESS BUG FOUND + FIXED (the gates working as intended, 2026-06-30) + +Two bugs the validation caught before any real result was trusted: +1. Coordinator `kv()` parser misaligned the `runto` reply (leading bare word `parked`), + so `chain` parsed as None for BOTH sides → every compare was None==None == "equal" → + the tool was SILENTLY BLIND (would report "no divergence" for a real one). Fixed + (parse all adjacent token pairs) + added a FATAL guard if chain is ever unparseable. +2. With the parser fixed, gate-4 immediately exposed a REAL harness bug: the two + processes parked at DIFFERENT cycles for the same `runto` target (A=3.07M, B=4.03M) — + a racy design where the guest free-runs from launch and only notices the async stop + flag at a wall-time-dependent moment. `runto ` did not stop AT that cycle. + FIX (redesign): the guest now parks at EVERY checkpoint boundary (a deterministic + cycle = multiple of stride) and only advances when the coordinator grants budget via + `step N`. Stride is fixed at LAUNCH via env PSX_COSIM_STRIDE (no set-stride race — + the guest hits cp=1 before the coordinator can send a command). Protocol is now + `step N` (not `runto`); coordinator steps both 1 checkpoint at a time and compares + the cumulative chain, with a cycle-skew WARN if the two ever park at different cycles. + REBUILDING psx-cosim with this fix at handoff (task bxuiye5u3). + +GATE 4 PASSES (post-fix, verified 2026-06-30): injected `ram:1000:255` into B before +cp 20 → tool HALTED at cp 21 (exact checkpoint after the fault), BOTH processes at the +IDENTICAL cycle 5242880 (deterministic park — race fixed, no skew warning), and the ONLY +differing subsystem hash was `ram` (cpu/irqctl/timers/clock/all devices identical). So the +tool provably: detects divergence, halts at the right checkpoint, parks deterministically, +localizes the subsystem. cp 1–19 identical before injection = partial Gate 1 passing. +GATE 1 PASSES (compiled-vs-compiled, no inject): 457 checkpoints to ~210 frames, ZERO +divergence, chains matched throughout — INCLUDING past overlay-load territory (frame +118+). So determinism + hashing + host-state exclusion are all correct, and overlay +loading is deterministic in compiled-vs-compiled (the warming concern did not bite). + +==> TOOL VALIDATED (Gate 1 + Gate 4 both pass). THE compiled-vs-interp RUN is now running +(task bhi8hf5p1): `python tools/cosim.py --a compiled --b interp --stride 262144 +--max 1000000000` (~frame 1766, past the FMV-skip ~1650). Interp is ~5 frames/sec so this +takes ~6-10 min. When it halts: the printed cp/cycle = the first divergence; the FIRST +differing subsystem hash (cpu / irqctl / a device / ram) = WHERE it split; the window = +the last 16 checkpoints of both. THAT names the bug — read it, then design the faithful +fix for whatever it actually is (could confirm GPU-backpressure, or something else). +If it shows a cycle-skew WARN, the two backends charge cycles differently somewhere (would +be its own finding). If interp is too slow to reach 1650, use a coarser --stride for a +first bracket then drill finer. + +## OVERLAY CACHE DECISION (user, correct): leave `overlay_cache=true` — the gcc native +overlay shards ARE the compiled path we must measure; forcing them off would make the +"compiled" instance interpret overlays. The gcc compile worker only fires on a cache +MISS, so **do a WARMING RUN first** (free-run one psx-cosim instance through the FMV +region so autocompile populates build-cosim/cache), then every cosim run loads the shards +synchronously + deterministically (worker never fires). NOTE: at 14 frames no cache dir +existed yet (overlays load later in boot). OPEN: verify the autocompile cmd resolves from +build-cosim (game.toml cmd uses `../psxrecomp/...` relative → resolves to the MAIN checkout +F:/Projects/psxrecomp/psxrecomp, NOT the worktree; may need the worktree recompiler path or +a pre-populated cache). If autocompile can't run, overlays fall to dirty-interp in BOTH +compiled instances (still deterministic, but "compiled" won't exercise native overlays). +The Gate-1 deep run will REVEAL whether cold-cache overlay loading is nondeterministic. + +## ★ FIRST RESULT (2026-06-30): oracle HALTED at cp=32, cycle 8,126,465 (frame ~14) + +compiled-vs-interp first divergence is at frame ~14 (EARLY BIOS BOOT, long before the FMV +skip ~frame 1650). Both at the IDENTICAL cycle (no skew). 31 checkpoints matched exactly, +then cp32 splits. The ONLY differing subsystem is `cpu` — ram, scratch, gpu, spu, cdrom, +dma, sio, timers, clock, dirty ALL identical. `istat=00000001` (VBLANK pending) at the +split; interp's last leader = 0x80054240 (BIOS shell). A CPU-only divergence with memory ++ devices identical and an IRQ pending is the SIGNATURE of an interrupt taken at a +different instruction between backends — the exact "when is the event taken" mechanism, +caught at its FIRST occurrence. BUT NOT YET CONFIRMED: need to know WHICH cpu field split. +Added a `cpu` TCP command (full gpr/pc/cop0/micro-state dump) + coordinator +`--cpudiff-at-cp N`. REBUILDING (task b5y3113cg). Next: run +`python tools/cosim.py --a compiled --b interp --stride 262144 --cpudiff-at-cp 32` and read +which field differs: + - pc / cop0[EPC=14] / cop0[SR=12] / cop0[Cause=13] differ => REAL: an IRQ was taken in + one backend and not the other at this instruction. THIS is the root mechanism. Chase + why (the accurate-timing IRQ take-point), fix faithfully. + - ONLY a lone micro-state field (read_fudge/read_absorb_which/ld_which_t/ld_absorb/ + muldiv_ts_done/gte_ts_done) differs, with pc+cop0+gpr identical => the divergence is a + benign load-delay/stall MICRO-STATE difference that hasn't affected architectural state. + Decide: is it a real cycle-model mismatch (compiled vs interp charge differently here) + — investigate psx_cyc_* ordering — or should that field be excluded from the hash as + non-architectural (refine per COSIM_ORACLE.md). Then continue toward frame 1650. + - a gpr differs with pc identical => a real data computation divergence at this PC + (contradicts the lockstep proof — investigate that instruction). +CAVEAT: `pc` in the ring is g_last_leader_pc, only set by cosim_block; WITHOUT REGEN the +compiled side never calls cosim_block for game/BIOS text (only the interp loop does), so +the compiled ring `pc` is STALE/unreliable for labeling. The `cpu` command reads the REAL +debug_cpu_ptr->pc, which IS reliable. (Regen game+BIOS to make ring pc labels meaningful.) + +## ★ cp32 RESOLVED: it was `pc` currency, a FALSE POSITIVE (2026-06-30) + +CPU field-diff at cp32: the ONLY differing field is `pc` (compiled=0x00000000, +interp=0x80054240). EVERY gpr/cop0/hi/lo/micro-state IDENTICAL. Root: the compiled +backend does not keep cpu->pc current mid-execution (writes it only at block transfers; +transiently 0 between dispatches), while interp keeps it exact per-instruction — so at a +mid-instruction cycle checkpoint they hold different pc while in the SAME architectural +state. FIX: excluded cpu->pc from the cross-backend hash (cosim_state.c hash_cpu; still +reported via the `cpu` command). NOT a blind spot — a real control-flow split shows as a +differing gpr/memory value within one checkpoint (stride 262144 = ~50k instructions). +Gate 1 (compiled-vs-compiled) could not catch this because both sides share pc-currency. +REBUILDING (task b2n8aw3v0). This is the tool honing itself exactly as the gates intend: +it surfaced a representational difference at a clean bracketed point with the exact field +named, instead of letting it corrupt a result. + +## ★★ SECOND RESULT (pc excluded): first REAL divergence at cp=42, frame ~18 + +Re-run compiled-vs-interp with pc excluded: CPU now MATCHES (pc fix worked), tool stepped +past cp32 and halted at cp=42, cycle 10,747,916 (frame ~18), same cycle both sides. The +differing subsystems are `irqctl` AND `gpu` — `cpu`, `ram`, `scratch`, `clock`, `timers`, +cdrom, dma, sio, spu ALL identical. This is DEVICE/INTERRUPT-TIMING state, not CPU/data — +cannot be a pc-currency artifact (both drive devices via the same psx_advance_cycles). +`irqctl` = cycles_since_vblank + in_exception + i_mask (+ i_stat, which the window shows =1 +on both). HYPOTHESIS (unconfirmed, must field-check): VBLANK is raised at a different guest +cycle because compiled checks interrupts at BLOCK edges while interp checks per-instruction +→ cycles_since_vblank diverges AND gpu_vblank_tick (GPUSTAT LCF) diverges → both irqctl+gpu +split. That IS the "when is the event taken" mechanism, at its first real occurrence. +CONFIRM: added `dev` TCP cmd (csv=cycles_since_vblank, inexc, imask, istat, gpustat) + +coordinator prints DEV field-diff in --cpudiff-at-cp mode. REBUILDING (task b8nhp6ub1). +Run: `python tools/cosim.py --a compiled --b interp --stride 262144 --cpudiff-at-cp 42`. + - dev.csv differs => CONFIRMED VBLANK-raise-timing divergence (interrupt-check frequency + differs between backends). The faithful fix: raise VBLANK (and all scheduled device + events) at their exact due guest-cycle in BOTH backends, independent of how often + psx_check_interrupts is called — i.e. a due-cycle event scheduler, not a check-at- + block-leader model. (This is the class fix CLAUDE.md/FAITHFUL_TIMING_PLAN point at.) + - dev.gpustat differs but csv same => a GPU-internal divergence (could be the draw-time / + GPUSTAT-ready model = the GPU-backpressure hypothesis, NOW testable at this exact point). + - Add per-field device dumps as needed to pin the exact GPU field. + +## ★★★ CONFIRMED (2026-06-30): first divergence = VBLANK raised at a different guest cycle + +DEV field-diff at cp42 (cycle 10,747,916, frame ~18): + dev.csv (cycles_since_vblank): compiled=0x590c (22796) interp=0x8f60c (587788) + dev.gpustat: compiled=0x94800000 interp=0x14800000 (bit31 LCF differs) +VBLANK_CYCLES=564480. Compiled's last VBLANK was ~22796 cyc ago; interp is at 587788 +(PAST the threshold, next VBLANK not yet fired). Their last-VBLANK cycles differ by +~564992 ≈ ONE VBLANK period → compiled has raised ONE MORE VBLANK than interp at the same +guest cycle; GPUSTAT LCF (bit31, toggled by gpu_vblank_tick) confirms. So the FIRST real +compiled-vs-interp divergence is EVENT-PHASE: VBLANK is raised at a different guest cycle +between backends. This is the measured root of the "when is the event taken" class; the +FMV skip 1600 frames later cascades from it. cpu.pc also shows (A=0 transient, excluded). + +WHY (next investigation — cycles_since_vblank increments identically via psx_advance_cycles +in both; it only RESETS inside psx_check_interrupts when cycles_since_vblank>=VBLANK_CYCLES): +whichever backend calls psx_check_interrupts first after crossing the threshold fires+resets +first. Candidates: (a) psx_check_interrupts call FREQUENCY differs (compiled at block leaders +vs interp per-instruction) so one crosses/fires at a different cycle; (b) the VBLANK DEFER +logic (interrupts.c ~line 388: defer while sio_card_protocol_active() / progress_stale) +evaluates differently between backends. SIO sub-hash matched at cp42, so lean toward (a). +FAITHFUL FIX DIRECTION: raise VBLANK (and all scheduled device events) at their EXACT due +guest-cycle independent of psx_check_interrupts call frequency — a due-cycle event scheduler +(FAITHFUL_TIMING_PLAN.md). CONFER ChatGPT before implementing. Then rebuild psx-cosim, +re-run compiled-vs-interp: the first divergence should move much later; iterate. + +## NEXT (immediate): re-run compiled-vs-interp with pc excluded +`python tools/cosim.py --a compiled --b interp --stride 262144 --max 1000000000` +Expected: proceeds well past cp32 toward the FMV-skip (~frame 1650 / ~933M cyc). Outcomes: + - Halts near frame 1650 with a CPU (cop0/gpr) or device subsystem diff => THE bug's first + divergence. Use `--cpudiff-at-cp N` (or add device field dumps) to name the exact field. + - Halts EARLIER on another currency artifact => field-diff it; if it's another + mid-instruction-currency field (unlikely — only pc had this), exclude/normalize similarly. + - Reaches frame 1650 with NO divergence => the FMV bug is NOT in the guest-architectural + state the hash covers at this stride; drill finer, or the divergence is in a device + internal not yet in the snapshot (audit boot_state device _snapshot completeness). + +## NEXT STEPS (in order) + +1. Finish the `psx-cosim` build; guard any `PSX_NO_DEBUG_TOOLS` link/compile errors. +2. **Why regen is optional for v1:** the compare is the cycle-keyed full-state hash in + `psx_advance_cycles` (works without regen). `cosim_block` only supplies a human-readable + last-leader-PC for the report. So you can run the gates + first compiled-vs-interp pass + on the CURRENT generated C, then regen (game+BIOS) later for nicer PC labels. +3. **RUN THE GATES (do not skip — this is the whole point):** + - Gate 1: `python tools/cosim.py --a compiled --b compiled` → MUST report zero + divergence across boot. If it diverges, the clean build still has NONDETERMINISM + (threads/host-time) OR the hash includes host-only state — fix that FIRST. Likely + suspects: SPU worker thread, overlay compile worker, any wall-clock in the guest path, + autocompile. The clean build should be single-threaded; verify no worker threads run. + - Gate 1b: `--a interp --b interp` → zero divergence. + - Gate 4: `--a compiled --b compiled --inject-at --inject ram:100000:1` → MUST + halt at ~that cycle. If it says "no divergence," the tool is BLIND — fix before trusting. +4. **THE RUN:** `python tools/cosim.py --a compiled --b interp` → first divergence = + the bug. Read the `sub` diff (which subsystem's hash split first) + `window` (last N + checkpoints of both). That names it: cpu / irqctl / a device / RAM address. +5. Only THEN design the faithful fix for whatever it actually is. + +## Traps / notes + +- Determinism is the load-bearing assumption (see COSIM_ORACLE.md "two instances"). Attract + mode needs no input, which helps. Gate 1 PROVES determinism; don't believe any + compiled-vs-interp result until Gate 1 + Gate 4 pass. +- Reading parked state must be side-effect-free (the device _snapshot_write serializers are + reads; verify none mutate). +- Ports: A=4600, B=4601 (env PSX_COSIM_PORT). Coordinator sets BELOW_NORMAL priority. +- `runto` timeout in cosim.c is ~600k ms of 1ms spins; a very large `--stride`×`--max` at + interp speed can be slow — interp is ~10× slower, budget accordingly. +- Pin/reference: `build-master/psx-runtime.PIN.exe` = the working (old-pin) MMX6. `build-wt` + = the regressed accuracy build. Memory: `mmx6_regression_compiled_codegen.md`. diff --git a/COSIM_ORACLE.md b/COSIM_ORACLE.md new file mode 100644 index 00000000..f9c267f1 --- /dev/null +++ b/COSIM_ORACLE.md @@ -0,0 +1,188 @@ +# COSIM_ORACLE.md — the first-divergence decision procedure + +Status: **DESIGN + BUILD IN PROGRESS** (started 2026-06-30). This document is the +durable spec so the plan cannot be lost across handoffs (the way the Beetle-anchored +GPU-draw-time finding was lost — see history at bottom). + +## Why this exists + +For ~2 weeks the MMX6 cutscene→gameplay wedge has produced a string of confident +"this is it" root causes that each turned out wrong: async-RFE, CD-DMA corruption, +IRQ-storm, block-edge-vs-per-instruction IRQ delivery, load-delay-slot exception +corruption, and (most recently, and still unproven) GPU draw-time backpressure. Every +one was a **single-signal probe with a blind spot** — it saw a slice of the machine +and let us pattern-match a story onto it. + +The fix for that failure mode is not another probe. It is a **decision procedure**: +a tool that compares the **complete architectural state** of two backends, stepped +deterministically from boot, and halts at the **first** state that differs. Whatever +it halts on *is* the first divergence — there is no hypothesis that can be "wrong." +It will confirm or kill GPU-backpressure (and everything else) on its own. + +**No hypothesis is assumed by this tool.** GPU draw-time backpressure is currently the +leading *unproven* idea (Beetle-anchored, bisect-confirmed as a lever, but never shown +to render a frame). The oracle neither assumes nor privileges it. + +## The bug, stated precisely (symptom only) + +MMX6, attract boot. The compiled backend's state machine at `0x800CD3F8` advances +main-state `0→1` (skipping FMV substate `6`) around frame ~1638; the FMV never plays +and it wedges black at `02 02`. The dirty-RAM interpreter (`PSX_FORCE_INTERP=1`) plays +the FMV. **Same binary.** Proven: given identical inputs the two backends compute +byte-identically (block + function lockstep, 12.9M + 560K units, zero divergence). So +the divergence is purely **when an interrupt/event is taken relative to the guest +instruction stream** — which then flips an early control-flow branch. Both backends +also over-run vs the Beetle oracle (neither models GPU draw-time), so the *faithful* +defect may be shared and only the phase differs between them. + +## Design (conferred with ChatGPT "PSX Static Recompiler Debug", 2026-06-30) + +### Architecture: two processes + coordinator (v1) + +- Two instances of a **clean, stripped build** (`psx-cosim`), one forced to the + compiled backend, one to `PSX_FORCE_INTERP`. NO other diagnostic tooling compiled + in (the existing debug server is laggy — user constraint). Only: the cosim state + engine + a minimal TCP command server. +- A **coordinator** (Python) drives both over TCP in lockstep and compares. +- Rejected: single machine with full snapshot/restore per block (too slow — 3 MB ×3 + per block; restore-bug-prone). Rejected: full `psx_machine_t` instancing (correct + long-term, too big a refactor for v1). Two processes gives a trustworthy tool + soonest because our runtime is global-state-heavy. + +### Comparison granularity — cycle-keyed, NOT block-keyed (correctness) + +The two backends do NOT share a block structure: compiled hooks per basic block (its +gen-time CFG); the dirty interp hooks per instruction. So a block-leader hash sequence +would not align between them. The ONLY backend-agnostic common clock is +`psx_cycle_count` — both backends call `psx_advance_cycles` with byte-identical +per-instruction charges (proven). So **checkpoint the hash on guest-cycle milestones** +(hook in `psx_advance_cycles`): at cycle stride S both backends are at the same guest +instruction (until the real divergence), so their checkpoints align. The block-leader +hook (`cosim_block(pc)`) is kept ONLY to stash `g_last_leader_pc` for human-readable +reporting (compiled `cpu->pc` is stale mid-block); the compare itself is the +cycle-checkpoint full-state hash. Drill = shrink stride S→1 in the divergent window. + +### Stepping + two-level search + +1. **Fast mode:** advance both by one block (one dispatch); each returns + `(seq, leader_pc, state_hash, sub_hashes)`. Coordinator compares. Batch K blocks + per round-trip and return K hashes to amortize TCP; on a batch mismatch, re-run the + batch one block at a time. +2. **On first mismatch:** the bug class is "event taken relative to instruction N," so + a block-level answer isn't enough. Drill: re-run the divergent block at + **instruction granularity** (per-instruction state hashes) to name the exact + instruction / control event where they split. + +### Determinism (critical — do NOT mask the divergence) + +Each process runs its **own** CD/GPU/DMA/timer scheduler from its **own** guest cycle +counter. Both are fed the **same external bytes** (same disc image, same BIOS, same +config, no pad input in attract). Do **not** share one live event schedule between the +machines — that would hide the exact class we're hunting. Each backend reads **live** +devices from its own state (our old lockstep fed *recorded* values and was thus blind +to input divergence — that blind spot is the whole reason for this rebuild). + +### Bounded reporting (LLM must not be firehosed — user constraint) + +Because the coordinator steps in lockstep and **halts on the first mismatch**, nothing +scrolls away: each process keeps only a **ring of the last N blocks** +`(seq, leader_pc, state_hash, i_stat, i_mask, sr, cycle, irq_taken?)`. On halt, dump +that N-window from both + a full field-level diff of the single divergent step. Query +surface: `divergence` (the one row + diff) and `window?n=…` (the preceding N). Never a +full trace. + +## Full architectural state — the completeness checklist + +The tool is only as truthful as this list. A missed execution-relevant field = a blind +spot (false "no divergence" or false positive). `boot_state.c` already serializes most +of it; the **gaps below must be added** and the whole thing proven by the gates. + +Reuse `boot_state.c` per-module `_snapshot_bytes/write/read` for: RAM(2MB), +scratchpad(1KB), IRQ(i_stat/i_mask), timers, guest clock, GPU regs, VRAM(1MB), +SPU+SPURAM, CDROM, DMA, SIO, dirty-RAM bitmap. **Audit each device snapshot for +timing/scheduling completeness** (scheduled event deadlines, in-flight DMA remaining +words + completion deadline, CDROM command phase + next-event cycle, GPU draw budget, +FIFO contents) — the save-state is taken at a *clean handoff*, so it may omit mid- +transfer transient state that a cycle-exact lockstep needs. + +**Known CPU gaps (NOT in boot_state `CpuRegs`) — MUST add to the hash:** +- `muldiv_ts_done`, `gte_ts_done` (completion-stall deadlines) +- load-delay interlock: `read_absorb[33]`, `read_absorb_which`, `read_fudge`, + `ld_which_t`, `ld_absorb` + +**Known `interrupts.c` static gaps — MUST add:** +- `in_exception`, `post_exception_cooldown`, `dispatch_count`, `total_checks`, + `cycles_since_vblank`, `g_irq_event_due_fast`, VBLANK schedule state, the async-RFE / + precise-slice / dirty-interp resume state if it affects delivery. + +**Exclude from the hash (host-only, would cause false divergences):** CPUState function +pointers + `sljit_helpers`, jmpbufs, fiber handles, malloc addresses, file handles, +struct padding, uninitialized bytes. Serialize little-endian explicitly; serialize +arrays in fixed order; serialize any event/deadline queue **sorted** by a deterministic +key (guest_cycle, priority, type, seq). + +### Cheap per-block hashing + +- RAM/scratchpad/VRAM: **incremental** hashing — 4 KB page hashes updated on write + + a top hash; per-block read is O(pages touched), not O(2MB). +- CPU + devices: serialize the (small) canonical blob per block and hash it. +- `state_hash = H(cpu_hash, ram_hash, scratch_hash, vram_hash, device_hash)` — compare + sub-hashes first on mismatch to localize which subsystem split. + +## Validation gates — trust NOTHING until these pass + +1. **compiled-vs-compiled = zero divergence across boot** (proves coordinator + determinism + hashing + that we excluded all host-only state). Necessary, not + sufficient. +2. **interp-vs-interp = zero divergence** (proves interp determinism + device + instancing). +3. **hash-vs-byte audit:** every N segments, force a full byte compare even when hashes + match (proves incremental-hash maintenance is correct). +4. **injected-divergence test:** a debug knob flips one RAM byte / one CPU reg in one + process after seq K; the tool MUST halt at exactly that field/address. (Proves it + actually catches divergence and names it correctly.) + +Only after 1–4 pass do we run **compiled-vs-interp** and believe its first-divergence +report. + +## Build plan + +- [x] `COSIM_ORACLE.md` (this file). +- [x] State-hash module `runtime/src/cosim_state.c` (+ `include/cosim_state.h`): + canonical FNV serialize → incremental RAM page hashes + CPU micro-state + (muldiv/gte/load-delay) + interrupts statics + device blobs (boot_state accessors) + → `state_hash` + sub-hashes. VRAM excluded in v1 (downstream, not causal). +- [x] Engine `runtime/src/cosim.c`: cycle-keyed checkpoints (ring + cumulative chain + hash + park/step) + minimal standalone TCP server. Commands: `status`, `chain`, + `stride N`, `runto `, `hash`, `sub`, `window N`, `inject ram|reg`, `reset`. +- [x] Hooks (all `#ifdef PSX_COSIM`, zero effect on normal builds): `cosim_tick()` in + `psx_advance_cycles` (the alignment clock); `cosim_block(pc)` emitted by BOTH + emitters + called in the interp loop (pc reporting); `cosim_note_ram_write` in + `memory.c` word/half/byte RAM writes (covers CPU+DMA — verified DMA routes through + psx_write_*_raw); `interrupts_cosim_hash()`; `cosim_init()` in main. +- [ ] Clean build target `psx-cosim` (CMake): `PSX_COSIM` + `PSX_NO_DEBUG_TOOLS`, + single-threaded guest, software renderer, no host throttle, headless. Add + cosim.c + cosim_state.c to the target. (Fix any unguarded debug-only calls the + `PSX_NO_DEBUG_TOOLS` build surfaces.) +- [ ] Regen game + BIOS (emitters now emit the guarded `cosim_block`). +- [ ] Coordinator `tools/cosim.py`: launch 2 instances (compiled + PSX_FORCE_INTERP), + cycle-keyed lockstep via `runto`/`chain`, stride coarse→1 drill, halt at first + divergent checkpoint, dump bounded `window` + `sub` diff. +- [ ] Run gates 1–4 (compiled-vs-compiled=0, interp-vs-interp=0, hash-vs-byte audit, + injected-divergence halts at the right field), THEN compiled-vs-interp. + +## History (do not lose again) + +- The device-event IRQ ring (`device_trace`, commit `18c94a1`) + a prior session's + Beetle diff found: native charges **zero guest cycles for GPU draw time** while + Beetle halts the CPU (DrawTimeAvail/RecalcHalt), so native over-runs the data-paced + asset-load loop (~5.4× GPU DMAs/frame) and completes `func_8001CB3C` ~150M cycles + early → wrong branch. CD ruled out (faithful vs Beetle); MDEC decode cost + implemented, did NOT fix. GPU draw cost bisect-confirmed as a **lever** but never + validated to render the FMV. ChatGPT gave a full DrawTimeAvail implementation plan. + **This is a hypothesis for the oracle to test, not an assumed answer.** +- This session (2026-06-30): localized to the load-delay axis on the current build + (`PSX_LOAD_DELAY=0` → FMV plays, pixels), but that is a **secondary phase modulator**, + not the faithful root; and a load-delay save/restore-across-exception fix was built + and **refuted** (no effect). Two separate bugs confirmed: FMV-skip and the later + `02 02` gameplay wedge. diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index b2722aee..50ede15d 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1121,7 +1121,11 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) code = gte_read + fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd); } } else if (cop_op == 0x02) { // CFC2 - move from COP2 control - code = gte_read + fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd); + if (rd == 26 || rd == 27 || rd == 29 || rd == 30 || rd == 31) { + code = gte_read + fmt::format("{} = gte_read_ctrl(cpu, {}); /* cfc2 */", reg_name(rt), rd); + } else { + code = gte_read + fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd); + } } else if (cop_op == 0x04) { // MTC2 - move to COP2 data if (rd == 7 || (rd >= 8 && rd <= 11) || rd == 14 || rd == 15 || rd == 28 || rd == 30) { code = gte_stall + fmt::format("gte_write_data(cpu, {}, {}); /* mtc2 */", rd, reg_name(rt)); @@ -1129,7 +1133,11 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) code = gte_stall + fmt::format("cpu->gte_data[{}] = {}; /* mtc2 */", rd, reg_name(rt)); } } else if (cop_op == 0x06) { // CTC2 - move to COP2 control - code = gte_stall + fmt::format("cpu->gte_ctrl[{}] = {}; /* ctc2 */", rd, reg_name(rt)); + if (rd == 26 || rd == 27 || rd == 29 || rd == 30 || rd == 31) { + code = gte_stall + fmt::format("gte_write_ctrl(cpu, {}, {}); /* ctc2 */", rd, reg_name(rt)); + } else { + code = gte_stall + fmt::format("cpu->gte_ctrl[{}] = {}; /* ctc2 */", rd, reg_name(rt)); + } } else if ((cop_op & 0x10) != 0) { // GTE command (bit 25 set) uint32_t gte_cmd = instr & 0x1FFFFFF; // Route ALL GTE commands through gte_execute() for correct behavior @@ -1231,6 +1239,12 @@ std::string CodeGenerator::translate_basic_block( ss << config_.indent << fmt::format("debug_server_cyc_observe(0x{:08X}u);\n", block.start_addr); ss << "#endif\n"; + // First-divergence co-sim oracle (COSIM_ORACLE.md): lean per-block-leader hook, + // present only in the clean PSX_COSIM build (independent of the debug tools). + ss << "#ifdef PSX_COSIM\n"; + ss << config_.indent + << fmt::format("cosim_block(0x{:08X}u);\n", block.start_addr); + ss << "#endif\n"; // 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 @@ -1337,6 +1351,11 @@ std::string CodeGenerator::translate_basic_block( ss << indent << fmt::format("psx_icache_fetch(cpu, 0x{:08X}u);\n", insn_addr); ss << "#endif\n"; }; + auto emit_cosim_instr = [&](uint32_t insn_addr, const std::string& indent) { + ss << "#ifdef PSX_COSIM\n"; + ss << indent << fmt::format("cosim_instr(0x{:08X}u);\n", insn_addr); + ss << "#endif\n"; + }; 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", @@ -1398,6 +1417,7 @@ std::string CodeGenerator::translate_basic_block( if (cycle_per_insn) emit_pre_icache(addr, config_.indent); if (cycle_per_insn) emit_pre_timing(instr, config_.indent); ss << translate_instruction(addr, instr) << "\n"; + emit_cosim_instr(addr, config_.indent); } else { // Control flow is handled at block exit if (addr == exit_branch_addr) { @@ -1410,6 +1430,28 @@ std::string CodeGenerator::translate_basic_block( if (cycle_per_insn) emit_pre_timing(block.exit_instr.instruction, config_.indent); + if (block.exit_instr.type == ControlFlowType::JumpLink) { + ss << config_.indent + << fmt::format("cpu->gpr[31] = 0x{:08X}u; /* jal link before delay slot */\n", + addr + 8); + } else if (block.exit_instr.type == ControlFlowType::JumpLinkReg) { + uint32_t rd = get_rd(block.exit_instr.instruction); + if (rd != 0) { + ss << config_.indent + << fmt::format("{} = 0x{:08X}u; /* jalr link before delay slot */\n", + reg_name(rd), addr + 8); + } + } else if (block.exit_instr.type == ControlFlowType::Branch) { + uint32_t branch_instr = block.exit_instr.instruction; + uint32_t b_opcode = (branch_instr >> 26) & 0x3F; + uint32_t regimm_op = (branch_instr >> 16) & 0x1F; + if (b_opcode == 0x01 && (regimm_op == 0x10 || regimm_op == 0x11)) { + ss << config_.indent + << fmt::format("cpu->gpr[31] = 0x{:08X}u; /* branch-and-link before delay slot */\n", + addr + 8); + } + } + // MIPS delay slot handling: emit delay slot instruction BEFORE branch/jump std::string delay_saved_cond; // non-empty if condition was pre-captured if (block.exit_instr.has_delay_slot) { @@ -1424,6 +1466,7 @@ std::string CodeGenerator::translate_basic_block( ss << config_.indent << "/* delay slot (likely) - conditional execution */\n"; ss << config_.indent << "if (" << generate_branch_condition(block.exit_instr.instruction) << ") {\n"; ss << config_.indent << translate_instruction(delay_slot_addr, delay_instr) << "\n"; + emit_cosim_instr(delay_slot_addr, config_.indent + config_.indent); ss << config_.indent << "}\n"; } else { // Normal delay slot - always executes. @@ -1442,6 +1485,7 @@ std::string CodeGenerator::translate_basic_block( if (cycle_per_insn) emit_pre_icache(delay_slot_addr, config_.indent); if (cycle_per_insn) emit_pre_timing(delay_instr, config_.indent); ss << translate_instruction(delay_slot_addr, delay_instr) << "\n"; + emit_cosim_instr(delay_slot_addr, config_.indent); } } } @@ -1861,20 +1905,20 @@ std::string CodeGenerator::translate_basic_block( } // If no explicit control flow, fall through to the next block. When a - // mid-function seed split placed the next instruction in a different C - // function, tail-call that split piece so the physical fall-through is - // preserved. + // CFG split or mid-function seed split placed the next instruction behind + // a C label/function boundary, preserve the physical fall-through without + // consuming an interrupt/cooldown check at the artificial boundary. if (block.exit_instr.type == ControlFlowType::None && !block.successors.empty()) { - ss << emit_interrupt_check(block.successors[0], config_.indent); ss << config_.indent << fmt::format("/* fall through to block_{:08X} */\n", block.successors[0]); } else if (block.exit_instr.type == ControlFlowType::None) { uint32_t next_addr = block.end_addr + 4; - ss << emit_interrupt_check(next_addr, config_.indent); if (known_functions_.count(next_addr) > 0) { ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* fallthrough to split piece */\n", next_addr); + } else { + ss << emit_interrupt_check(next_addr, config_.indent); } } @@ -2446,6 +2490,10 @@ std::string CodeGenerator::generate_file( ss << "#ifndef PSX_NO_DEBUG_TOOLS\n"; ss << "extern void debug_server_cyc_observe(uint32_t block_leader_phys);\n"; ss << "#endif\n"; + ss << "#ifdef PSX_COSIM\n"; + ss << "extern void cosim_block(uint32_t block_leader_phys);\n"; + ss << "extern void cosim_instr(uint32_t pc);\n"; + ss << "#endif\n"; ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n"; ss << "extern int psx_ws_x_margin(void); /* widescreen cull-margin term (gpu.c) */\n"; ss << "extern int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); /* ws auto screen-x cull (gpu.c) */\n"; @@ -2624,19 +2672,52 @@ std::string CodeGenerator::generate_file( if (f.alias_walk_lo == 0) func_starts.push_back(f.start_addr); } - // Group targets by containing function + // Group targets by containing function. Some real compiler layouts + // branch past an early jr-ra return into the bytes before the next + // discovered function. Those targets are not "mid-function" by the + // return-scanner's range, but they are executable and must still be + // dispatchable CPS pieces. std::map> splits_by_func; + std::set gap_targets; for (uint32_t target : mid_targets) { auto it = std::upper_bound(func_starts.begin(), func_starts.end(), target); - if (it == func_starts.begin()) continue; + if (it == func_starts.begin()) { + gap_targets.insert(target); + continue; + } --it; - splits_by_func[*it].push_back(target); + auto fit = std::find_if(functions_mut.begin(), functions_mut.end(), + [&](const Function& f) { return f.start_addr == *it; }); + if (fit != functions_mut.end() && target > fit->start_addr && target < fit->end_addr) { + splits_by_func[*it].push_back(target); + } else { + gap_targets.insert(target); + } } // Split each affected function std::vector new_funcs; std::set affected; + for (uint32_t target : gap_targets) { + auto next_it = std::upper_bound(func_starts.begin(), func_starts.end(), target); + uint32_t gap_end = (next_it != func_starts.end()) ? *next_it : exe_end; + if (target >= gap_end) continue; + + Function nf; + nf.start_addr = target; + nf.end_addr = gap_end; + nf.size = nf.end_addr - nf.start_addr; + nf.name = fmt::format("func_{:08X}", nf.start_addr); + nf.has_prologue = false; + nf.has_epilogue = false; + nf.stack_frame_size = 0; + nf.is_data_section = false; + new_funcs.push_back(nf); + known_addrs.insert(nf.start_addr); + affected.insert(nf.start_addr); + } + for (auto& [func_start, targets] : splits_by_func) { std::sort(targets.begin(), targets.end()); diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 8cd9dc35..effa10e0 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -58,6 +58,21 @@ static uint32_t ram_alias_to_rom(uint32_t addr) { return addr; } +/* Map a BIOS ROM PC to the address the PSX CPU actually executes from after the + * BIOS copies kernel/shell code into RAM. Literal interrupt resume PCs must use + * this runtime address; otherwise psx_check_interrupts rejects ROM-shell EPCs + * and falls back to the legacy sentinel path. */ +static uint32_t bios_runtime_pc(uint32_t rom_pc) { + uint32_t phys = rom_pc & 0x1FFFFFFFu; + if (phys >= 0x1FC18000u && phys <= 0x1FC42FFFu) { + return 0x80030000u + (phys - 0x1FC18000u); + } + if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) { + return phys - 0x1FC10000u + 0x00000500u; + } + return rom_pc; +} + uint32_t FullFunctionEmitter::read_u32_le(const std::vector& rom, uint32_t offset) { return static_cast(rom[offset + 0]) | (static_cast(rom[offset + 1]) << 8) @@ -118,12 +133,18 @@ bool FullFunctionEmitter::emit_function( return e == nullptr || e[0] != '0'; }(); auto emit_irq_check = [](uint32_t resume_pc, const std::string& indent = " ") { - return indent + fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc); + return indent + fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", + bios_runtime_pc(resume_pc)); }; auto emit_irq_check_expr = [](const std::string& resume_pc_expr, const std::string& indent = " ") { return indent + fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr); }; + auto emit_cosim_instr = [](uint32_t pc, const std::string& indent = " ") { + return "#ifdef PSX_COSIM\n" + indent + + fmt::format("cosim_instr(0x{:08X}u);\n", bios_runtime_pc(pc)) + + "#endif\n"; + }; // Build sorted instruction list and a set for O(1) membership test. // sfr.instructions is already sorted by address. @@ -270,19 +291,7 @@ bool FullFunctionEmitter::emit_function( * Without this, handler/callback tables get ROM addresses instead of * RAM addresses, breaking pointer comparisons in the BIOS. */ auto relocate_ra = [](uint32_t rom_ra) -> uint32_t { - uint32_t phys = rom_ra & 0x1FFFFFFFu; - /* Shell relocation upper bound matches the runtime dispatcher's - * normalize() in SCPH1001_dispatch.c: shell covers RAM 0x30000-0x5AFFF - * (= ROM 0x1FC18000-0x1FC42FFF inclusive). Previously this was - * 0x1FC427FF, which left BFC42800-BFC42FFF JAL targets un-relocated - * (target kept ROM PC[31:28]=0xB instead of RAM PC[31:28]=0x8). */ - if (phys >= 0x1FC18000u && phys <= 0x1FC42FFFu) { - return 0x80030000u + (phys - 0x1FC18000u); - } - if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) { - return phys - 0x1FC10000u + 0x00000500u; - } - return rom_ra; + return bios_runtime_pc(rom_ra); }; // First pass: identify terminators and their delay slots. @@ -430,6 +439,10 @@ bool FullFunctionEmitter::emit_function( out += fmt::format(" debug_server_cyc_observe(0x{:08X}u);\n", normalize_address(addr)); out += "#endif\n"; + // First-divergence co-sim oracle (COSIM_ORACLE.md): lean block-leader hook. + out += "#ifdef PSX_COSIM\n"; + out += fmt::format(" cosim_block(0x{:08X}u);\n", normalize_address(addr)); + out += "#endif\n"; // Phase 1.0e-d: advance guest cycles for this block. Macro- // gated; when off, generated code matches pre-1.0e-d output. // In per-instruction mode the charge is emitted per instruction @@ -474,6 +487,16 @@ bool FullFunctionEmitter::emit_function( out += fmt::format(" /* 0x{:08X}: {:08X} {} */\n", addr, raw, tr.comment); const std::string kind = tr.terminator_kind ? tr.terminator_kind : ""; + if (kind == "jal") { + out += fmt::format(" cpu->gpr[31] = 0x{:08X}u; /* jal link before delay slot */\n", + relocate_ra(addr + 8)); + } else if (kind == "jalr") { + uint8_t rd = (raw >> 11) & 0x1F; + if (rd != 0) { + out += fmt::format(" cpu->gpr[{}] = 0x{:08X}u; /* jalr link before delay slot */\n", + static_cast(rd), relocate_ra(addr + 8)); + } + } if (is_branch_kind(tr.terminator_kind)) { // Emit pre-delay snapshot using a unique flag. @@ -799,6 +822,7 @@ bool FullFunctionEmitter::emit_function( // Non-terminator: emit normally. out += fmt::format(" /* 0x{:08X}: {:08X} {} */\n", addr, raw, tr.comment); out += fmt::format(" {}\n", tr.c_code); + out += emit_cosim_instr(addr); // Check if this instruction is a delay slot with pending resolution. if (pending_at.count(addr)) { @@ -1357,6 +1381,14 @@ void FullFunctionEmitter::emit_dispatch( out += "extern uint64_t g_dispatch_static_hits;\n"; out += "\n"; out += "int g_psx_dispatch_depth = 0;\n\n"; + out += "static void psx_dispatch_check_return_boundary(CPUState* cpu, uint32_t stop_addr) {\n"; + out += " if (stop_addr != 0u) {\n"; + out += " psx_check_interrupts_at(cpu, stop_addr);\n"; + out += " if (((cpu->pc ^ stop_addr) & 0x1FFFFFFFu) == 0) cpu->pc = 0;\n"; + out += " } else {\n"; + out += " psx_check_interrupts(cpu);\n"; + out += " }\n"; + out += "}\n\n"; out += "static void psx_dispatch_impl(CPUState* cpu, uint32_t addr, uint32_t stop_addr) {\n"; out += " /* Tail-call trampoline: functions signal tail calls by setting\n"; out += " * cpu->pc to the target and returning. We loop here to re-dispatch\n"; @@ -1447,7 +1479,7 @@ void FullFunctionEmitter::emit_dispatch( out += " cpu->pc = 0;\n"; out += " --g_psx_dispatch_depth;\n"; out += " if (outermost) {\n"; - out += " psx_check_interrupts(cpu);\n"; + out += " psx_dispatch_check_return_boundary(cpu, stop_addr);\n"; out += " }\n"; out += " return;\n"; out += " }\n"; @@ -1484,7 +1516,7 @@ void FullFunctionEmitter::emit_dispatch( out += " }\n"; out += " --g_psx_dispatch_depth;\n"; out += " if (outermost) {\n"; - out += " psx_check_interrupts(cpu);\n"; + out += " psx_dispatch_check_return_boundary(cpu, stop_addr);\n"; out += " }\n"; out += " return;\n"; out += " }\n"; @@ -1499,7 +1531,7 @@ void FullFunctionEmitter::emit_dispatch( out += " cpu->pc = 0;\n"; out += " --g_psx_dispatch_depth;\n"; out += " if (outermost) {\n"; - out += " psx_check_interrupts(cpu);\n"; + out += " psx_dispatch_check_return_boundary(cpu, stop_addr);\n"; out += " }\n"; out += " return;\n"; out += " }\n"; @@ -1589,6 +1621,10 @@ EmitStats FullFunctionEmitter::emit( full_c += "extern uint32_t gte_read_data(CPUState* cpu, uint8_t reg);\n"; full_c += "extern void debug_server_log_call_entry(uint32_t func_addr);\n"; full_c += "extern void debug_server_log_probe(uint32_t pc, CPUState *cpu);\n"; + full_c += "#ifdef PSX_COSIM\n"; + full_c += "extern void cosim_block(uint32_t block_leader_phys);\n"; + full_c += "extern void cosim_instr(uint32_t pc);\n"; + full_c += "#endif\n"; full_c += "#ifndef PSX_NO_DEBUG_TOOLS\n"; full_c += "extern void debug_server_cyc_observe(uint32_t block_leader_phys);\n"; full_c += "#endif\n"; diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index d27b06a1..e3c96704 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -860,6 +860,7 @@ int main(int argc, char** argv) { std::ostringstream ds; ds << "/* Generated by PSXRecomp - dynamic dispatch table */\n"; ds << "#include \"psx_runtime.h\"\n\n"; + ds << "extern void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc);\n\n"; // Forward declarations ds << "/* Forward declarations for all recompiled functions */\n"; @@ -886,20 +887,20 @@ int main(int argc, char** argv) { // entry-switch routes into the owning block. The unified flat // trampoline (psx_dispatch_impl) then drives every tail-transfer. for (uint32_t addr : dispatch_addrs) { - ds << fmt::format(" case 0x{:08X}u: cpu->pc = 0; func_{:08X}(cpu); return 1;\n", - addr, addr); + ds << fmt::format(" case 0x{:08X}u: psx_check_interrupts_dispatch_entry(cpu, 0x{:08X}u); cpu->pc = 0; func_{:08X}(cpu); return 1;\n", + addr, addr, addr); } const auto& conts = codegen.cps_continuations(); ds << fmt::format(" /* {} continuation return-points (CPS) */\n", conts.size()); for (const auto& [cont, owner] : conts) { if (dispatch_addrs.count(cont)) continue; /* already a function entry */ - ds << fmt::format(" case 0x{:08X}u: cpu->pc = 0x{:08X}u; func_{:08X}(cpu); return 1;\n", - cont, cont, owner); + ds << fmt::format(" case 0x{:08X}u: psx_check_interrupts_dispatch_entry(cpu, 0x{:08X}u); cpu->pc = 0x{:08X}u; func_{:08X}(cpu); return 1;\n", + cont, cont, cont, owner); } } else { for (uint32_t addr : dispatch_addrs) { - ds << fmt::format(" case 0x{:08X}u: func_{:08X}(cpu); return 1;\n", - addr, addr); + ds << fmt::format(" case 0x{:08X}u: psx_check_interrupts_dispatch_entry(cpu, 0x{:08X}u); func_{:08X}(cpu); return 1;\n", + addr, addr, addr); } } ds << " default: return 0;\n"; diff --git a/recompiler/src/strict_translator.cpp b/recompiler/src/strict_translator.cpp index e3ae59d9..a82dcb51 100644 --- a/recompiler/src/strict_translator.cpp +++ b/recompiler/src/strict_translator.cpp @@ -1281,8 +1281,13 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& } if (cop_op == 0x02) { // CFC2 — move from COP2 control register r.supported = true; - r.c_code = gte_read + emit_gpr_write(rt, - fmt::format("cpu->gte_ctrl[{}]", static_cast(rd))); + if (rd == 26 || rd == 27 || rd == 29 || rd == 30 || rd == 31) { + r.c_code = gte_read + emit_gpr_write(rt, + fmt::format("gte_read_ctrl(cpu, {})", static_cast(rd))); + } else { + r.c_code = gte_read + emit_gpr_write(rt, + fmt::format("cpu->gte_ctrl[{}]", static_cast(rd))); + } r.comment = fmt::format("cfc2 {}, gte_ctrl[{}]", gpr_name(rt), rd); return r; } @@ -1304,9 +1309,15 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& } if (cop_op == 0x06) { // CTC2 — move to COP2 control register r.supported = true; - r.c_code = gte_stall + fmt::format( - "cpu->gte_ctrl[{}] = cpu->gpr[{}];", - static_cast(rd), static_cast(rt)); + if (rd == 26 || rd == 27 || rd == 29 || rd == 30 || rd == 31) { + r.c_code = gte_stall + fmt::format( + "gte_write_ctrl(cpu, {}, cpu->gpr[{}]);", + static_cast(rd), static_cast(rt)); + } else { + r.c_code = gte_stall + fmt::format( + "cpu->gte_ctrl[{}] = cpu->gpr[{}];", + static_cast(rd), static_cast(rt)); + } r.comment = fmt::format("ctc2 {}, gte_ctrl[{}]", gpr_name(rt), rd); return r; } diff --git a/runtime/include/cosim_state.h b/runtime/include/cosim_state.h new file mode 100644 index 00000000..04ead474 --- /dev/null +++ b/runtime/include/cosim_state.h @@ -0,0 +1,67 @@ +/* cosim_state.h — full-architectural-state canonical hash for the first-divergence + * co-simulation oracle. See COSIM_ORACLE.md. + * + * The single correctness rule: this hash must cover EVERY piece of state that can + * influence future guest execution, and NOTHING that is host-only (pointers, padding, + * jmpbufs, fiber/malloc addresses). A missed execution-relevant field is a blind spot + * (false "no divergence"); an included host-only field is a false positive. The + * validation gates in COSIM_ORACLE.md (compiled-vs-compiled == 0, injected-divergence + * halts at the right field) exist to prove this list is exactly right. + */ +#pragma once +#include +#include "cpu_state.h" +#ifdef __cplusplus +extern "C" { +#endif + +/* Per-subsystem sub-hashes so a mismatch localizes to a subsystem before a full + * field/byte diff. All 64-bit FNV-1a over canonical little-endian serialization. */ +typedef struct { + uint64_t cpu; /* GPRs/HI/LO/PC/COP0/GTE + micro-state (muldiv/gte/load-delay) */ + uint64_t irqctl; /* i_stat/i_mask + interrupts.c timing statics + VBLANK schedule */ + uint64_t ram; /* 2MB main RAM (incremental page-hash) */ + uint64_t scratch; /* 1KB scratchpad */ + uint64_t vram; /* 1MB VRAM (incremental page-hash) */ + uint64_t gpu; /* GPU regs + FIFO + draw budget + deadlines (boot_state blob) */ + uint64_t spu; /* SPU regs + voices + SPU RAM */ + uint64_t cdrom; /* CDROM command/FIFO/phase/next-event */ + uint64_t dma; /* DMA channel regs + in-flight/deadlines */ + uint64_t sio; /* SIO/pad/memcard transaction state */ + uint64_t timers; /* RootCounters counter/mode/target/frac/last-update */ + uint64_t clock; /* psx_cycle_count */ + uint64_t dirty; /* dirty-RAM page bitmap (affects dispatch/interp routing) */ +} CosimSubHashes; + +/* Compute the full canonical state hash of the LIVE machine (debug_cpu_ptr + globals). + * Fills `sub` (may be NULL) and returns the folded top hash. Cheap per call: RAM/VRAM + * use incremental page hashes maintained by cosim_note_ram_write/cosim_note_vram_write; + * everything else is small. */ +uint64_t cosim_state_hash(CosimSubHashes *sub); + +/* Incremental page-hash maintenance. Call from the RAM/VRAM write chokepoints in the + * cosim build: mark the touched page dirty (cheap); the page hash is recomputed lazily + * inside cosim_state_hash(). phys is the byte offset into RAM (0..2MB) / VRAM (0..1MB). */ +void cosim_note_ram_write(uint32_t phys, uint32_t nbytes); +void cosim_note_vram_write(uint32_t byte_off, uint32_t nbytes); + +/* Reset all incremental state (call once at machine init / after a full state load). */ +void cosim_state_reset(void); + +/* ---- engine (cosim.c) ---- */ +void cosim_init(void); /* start the TCP oracle server (call once at startup) */ +void cosim_block(uint32_t pc); /* block-leader pc stash (emitted + interp), reporting */ +uint32_t cosim_last_block(void); /* last block-leader pc, for diagnostic attribution */ +void cosim_tick(void); /* cycle-keyed checkpoint (called from psx_advance_cycles) */ +void cosim_instr(uint32_t pc); /* retire pending cycle checkpoints at an instruction boundary */ +uint32_t cosim_cycles_to_next_checkpoint(void); + +/* Gate-4 fault injection: after this is armed, the next cosim_state_hash perturbs one + * byte of RAM (addr) or one CPU GPR (reg 0..31, hi=32, lo=33) by XOR val, so the + * oracle MUST halt at exactly that field. Returns via the same hash path. */ +void cosim_inject_ram(uint32_t phys, uint8_t xor_val); +void cosim_inject_reg(int reg_index, uint32_t xor_val); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/interrupts.h b/runtime/include/interrupts.h index fcd686f9..462bb1fd 100644 --- a/runtime/include/interrupts.h +++ b/runtime/include/interrupts.h @@ -32,12 +32,17 @@ void interrupts_init(void); void psx_irq_raise(uint32_t bit, uint32_t detail); /* Called from the dispatch loop after each function returns. - * Fires vblank on schedule, checks (i_stat & i_mask), and if - * pending + COP0 allows, dispatches the exception handler. */ + * Services due scheduled edges, checks (i_stat & i_mask), and if pending + COP0 + * allows, dispatches the exception handler. */ void psx_check_interrupts(struct CPUState* cpu); /* Interrupt check with the compiled guest PC to resume if a game-installed * handler later RFEs to the sentinel outside the synchronous host window. */ void psx_check_interrupts_at(struct CPUState* cpu, uint32_t resume_pc); +int psx_interrupts_checked_at_current_cycle(uint32_t resume_pc); +/* Dispatch-entry check for re-enterable compiled PCs. De-dupes an immediately + * preceding check at the same guest PC/cycle so generated transfers can keep + * their explicit checks while dirty/interp-to-static entries get a boundary. */ +void psx_check_interrupts_dispatch_entry(struct CPUState* cpu, uint32_t resume_pc); /* Accumulate emitted PSX cycles toward the next VBlank trigger. * Called from psx_advance_cycles() so the VBlank rate is gated on @@ -45,6 +50,8 @@ void psx_check_interrupts_at(struct CPUState* cpu, uint32_t resume_pc); * count (which was 5-6x too fast and squeezed game-time to ~60% of * real). One real-PSX VBlank = 564480 cycles (33.8688 MHz / 60). */ void interrupts_advance_cycles(uint32_t cycles); +void interrupts_service_scheduled_events(void); +uint32_t interrupts_cycles_to_vblank(void); /* Cycle-budgeted precise event slicing: minimum guest-CPU-cycle distance to the * next DELIVERABLE hardware interrupt (source raises its I_STAT bit AND that bit diff --git a/runtime/include/lockstep.h b/runtime/include/lockstep.h index 917c77ca..1229830f 100644 --- a/runtime/include/lockstep.h +++ b/runtime/include/lockstep.h @@ -32,6 +32,26 @@ void ls_write_hook(uint32_t addr, int size, uint32_t val); /* Per-basic-block-leader entry, called from debug_server_cyc_observe. */ void ls_at_leader(uint32_t leader_phys, CPUState *cpu); +/* Dispatch-segment comparator: wraps one clean psx_dispatch_game_compiled() + * invocation and replays the same callable through exec_one until it reaches + * the same returned cpu->pc. This measures the native CPS unit, not just one + * basic block. */ +void ls_func_enter(uint32_t entry_pc, CPUState *cpu); +void ls_func_exit(uint32_t entry_pc, CPUState *cpu, int handled); +void ls_func_set_window(uint32_t frame_lo, uint32_t frame_hi); +void ls_func_set_record_only(int on); + +/* Called at real IRQ exception entry. Function-scope comparisons skip segments + * that delivered an interrupt, keeping codegen/tooling checks separate from + * event-phase behavior. */ +void ls_note_exception_entry(void); + +/* Suppress lockstep memory recording while runtime/debug observers read guest + * RAM for diagnostics. These reads are not guest instructions and must not + * become part of the compiled-vs-interp trace. */ +void ls_suppress_begin(void); +void ls_suppress_end(void); + /* Arm the comparator over a guest-frame window [lo,hi]. hi==0 => disabled. */ void ls_set_window(uint32_t frame_lo, uint32_t frame_hi); @@ -41,6 +61,7 @@ void ls_set_record_only(int on); /* Drain the first-divergence record as JSON into buf. Returns bytes written. */ int ls_get_diverge_json(char *buf, int buflen); +int ls_get_func_json(char *buf, int buflen); #ifdef __cplusplus } diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5788909f..ef96b6a5 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -135,6 +135,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/parity_trace.c ${PSXRECOMP_ROOT}/runtime/src/device_trace.c ${PSXRECOMP_ROOT}/runtime/src/boot_state.c + ${PSXRECOMP_ROOT}/runtime/src/cosim_state.c + ${PSXRECOMP_ROOT}/runtime/src/cosim.c ${PSXRECOMP_ROOT}/runtime/src/traps.c ${PSXRECOMP_ROOT}/runtime/src/crash_trace.c ${PSXRECOMP_ROOT}/runtime/src/freeze_heartbeat.c @@ -220,7 +222,7 @@ if(NOT PSXRECOMP_SKIP_BIOS_STALE_CHECK) endif() function(psxrecomp_add_runtime_target target) - set(options ORACLE) + set(options ORACLE COSIM) set(oneValueArgs GAME_GENERATED_FULL_C GAME_GENERATED_DISPATCH_C @@ -387,6 +389,15 @@ function(psxrecomp_add_runtime_target target) target_compile_definitions(${target} PRIVATE PSX_NO_DEBUG_TOOLS=1) endif() + # First-divergence co-sim oracle (COSIM_ORACLE.md): the clean, deterministic build. + # PSX_COSIM activates the cosim engine/hooks; PSX_NO_DEBUG_TOOLS strips ALL the laggy + # diagnostic tooling (the debug server thread, per-block recording, rings) so the run + # is single-threaded + fast + deterministic. The two instances (this + a FORCE_INTERP + # run) are driven in cycle-lockstep by tools/cosim.py. + if(PSXRT_COSIM) + target_compile_definitions(${target} PRIVATE PSX_COSIM=1 PSX_NO_DEBUG_TOOLS=1) + endif() + # Integrated RmlUi launcher (not in the oracle build — that's headless). if(PSX_LAUNCHER AND NOT PSXRT_ORACLE) psxrecomp_ensure_launcher_libs() diff --git a/runtime/src/cosim.c b/runtime/src/cosim.c new file mode 100644 index 00000000..39376743 --- /dev/null +++ b/runtime/src/cosim.c @@ -0,0 +1,371 @@ +#ifdef PSX_COSIM +/* cosim.c — first-divergence co-simulation engine + minimal TCP server. + * See COSIM_ORACLE.md. Active ONLY in the clean `psx-cosim` target (PSX_COSIM); the + * whole translation unit is empty otherwise, so it is safe in the shared source list. + * + * Model: the guest runs normally on the main thread. At every basic-block leader the + * emitted/interp hook calls cosim_block(pc): it stamps the block into a ring, folds a + * cumulative chain hash, and — when the coordinator has asked to stop at a target + * sequence — PARKS the guest thread there so the coordinator can read a quiescent + * machine over TCP, compare, and resume. Two processes (compiled + interp) driven in + * lockstep by tools/cosim.py converge on the first divergent block, then drill. + * + * Determinism is a hard requirement (both runs must be bit-identical until the real + * divergence). The clean build runs headless, single-threaded guest, software renderer, + * no host-time throttle. The compiled-vs-compiled gate proves it. + */ +#include "cosim_state.h" +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +typedef SOCKET sock_t; +#define COSIM_SLEEP(ms) Sleep(ms) +#else +#include +#include +#include +#include +typedef int sock_t; +#define COSIM_SLEEP(ms) usleep((ms)*1000) +#endif + +extern uint32_t i_stat, i_mask; +extern uint64_t psx_cycle_count; + +#define RING_N 8192u /* bounded reporting window (power of two) */ +typedef struct { uint64_t cp; uint32_t pc; uint64_t hash; uint32_t istat, imask; uint64_t cycle; } Entry; +static Entry g_ring[RING_N]; +static uint64_t g_cp = 0; /* checkpoints crossed so far */ +static uint64_t g_chain = 1469598103934665603ULL; /* cumulative FNV over checkpoints */ +static uint64_t g_stride = 4096; /* guest cycles per checkpoint (coordinator sets) */ +static uint64_t g_next_cp = 0; /* next cycle at which to checkpoint */ +static uint32_t g_last_leader_pc = 0; /* set by cosim_block, reported at checkpoints */ +static uint64_t g_pending_first_cycle = 0; +static uint64_t g_pending_count = 0; + +/* lockstep control (written by TCP thread, read by guest thread). + * The guest parks at EVERY checkpoint boundary (a deterministic guest cycle = multiple + * of stride) and only advances when the coordinator grants budget via `step N`. It does + * NOT free-run: the earlier "free-run until you notice an async stop cycle" design was + * racy — two processes noticed the flag at different wall-times and parked at different + * cycles (a HARNESS nondeterminism, not a guest one). Parking at fixed checkpoint + * boundaries makes both processes stop at identical cycles by construction. */ +static volatile int64_t g_run_budget = 0; /* checkpoints the guest may cross before parking */ +static volatile int g_parked = 0; +static volatile int g_go_token = 0; /* bump to wake a parked guest to re-check budget */ + +static inline uint64_t fold(uint64_t h, uint64_t v) { + for (int i = 0; i < 8; i++) { h ^= (uint8_t)(v >> (i*8)); h *= 1099511628211ULL; } + return h; +} + +/* Block-leader hook (emitted under PSX_COSIM + called from the interp loop). Cheap: + * just stashes the last leader PC for human-readable reporting — the actual compare is + * the cycle-keyed checkpoint below, which aligns across both backends. */ +void cosim_block(uint32_t pc) { g_last_leader_pc = pc; } +uint32_t cosim_last_block(void) { return g_last_leader_pc; } + +uint32_t cosim_cycles_to_next_checkpoint(void) { + uint64_t now = psx_cycle_count; + if (now >= g_next_cp) return 0; + uint64_t d = g_next_cp - now; + return d > 0xFFFFFFFFULL ? 0xFFFFFFFFu : (uint32_t)d; +} + +/* Cycle-keyed checkpoint — called from psx_advance_cycles (both backends, identical + * per-instruction charges). Folds a full-state hash into the chain at each guest-cycle + * stride and parks at the coordinator's stop cycle. This is the alignment clock. */ +static void cosim_record_checkpoint(uint64_t cycle, uint32_t pc) { + uint64_t h = cosim_state_hash(NULL); + uint64_t cp = ++g_cp; + g_chain = fold(fold(g_chain, cycle), h); + Entry *e = &g_ring[cp & (RING_N - 1u)]; + e->cp = cp; e->pc = pc; e->hash = h; + e->istat = i_stat; e->imask = i_mask; e->cycle = cycle; + + /* deterministic park: consume one checkpoint of budget, else block for `step`. + * The guest ALWAYS stops here (a fixed cycle boundary), never at a wall-time point. */ + if (g_run_budget > 0) { g_run_budget--; return; } + g_parked = 1; + while (g_run_budget <= 0) { + int tok = g_go_token; + while (g_go_token == tok && g_run_budget <= 0) COSIM_SLEEP(1); + } + g_parked = 0; + g_run_budget--; +} + +void cosim_tick(void) { + uint64_t now = psx_cycle_count; + if (now < g_next_cp) return; + + if (g_cp == 0 && now == 0 && g_next_cp == 0) { + cosim_record_checkpoint(0, g_last_leader_pc); + g_next_cp = g_stride ? g_stride : 1; + return; + } + + while (now >= g_next_cp) { + if (g_pending_count == 0) g_pending_first_cycle = g_next_cp; + g_pending_count++; + g_next_cp += g_stride ? g_stride : 1; + } +} + +void cosim_instr(uint32_t pc) { + g_last_leader_pc = pc; + while (g_pending_count > 0) { + uint64_t cycle = g_pending_first_cycle; + g_pending_first_cycle += g_stride ? g_stride : 1; + g_pending_count--; + cosim_record_checkpoint(cycle, pc); + } +} + +/* ---------------- minimal TCP command server (own thread) ---------------- */ +/* Line protocol, whitespace-separated. Replies one line. + * seq -> "seq chain parked <0|1>" + * runto -> set stop=n, resume; blocks until parked at n (or exit) + * chain -> "chain seq " + * hash -> "hash pc istat imask cyc " + * sub -> per-subsystem hashes of the current state + * window -> last n ring rows (bounded to RING_N) + * inject ram -> gate-4 fault into RAM + * inject reg -> gate-4 fault into a CPU reg (idx 0..31, 32=hi,33=lo) + * reset -> reset incremental hash state + */ +static void handle_line(sock_t s, char *line); + +static int send_line(sock_t s, const char *buf) { + size_t n = strlen(buf); + size_t off = 0; + while (off < n) { + int sent = send(s, buf + off, (int)(n - off), 0); + if (sent <= 0) return 0; + off += (size_t)sent; + } + return 1; +} + +static void server_loop(unsigned short port) { + sock_t ls = socket(AF_INET, SOCK_STREAM, 0); + if (ls == (sock_t)-1) return; + int yes = 1; + setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, (const char*)&yes, sizeof yes); + struct sockaddr_in a; memset(&a, 0, sizeof a); + a.sin_family = AF_INET; a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); a.sin_port = htons(port); + if (bind(ls, (struct sockaddr*)&a, sizeof a) != 0) return; + listen(ls, 4); + for (;;) { + sock_t c = accept(ls, NULL, NULL); + if (c == (sock_t)-1) { COSIM_SLEEP(5); continue; } + char buf[512]; int used = 0; + for (;;) { + int r = recv(c, buf + used, (int)sizeof(buf) - 1 - used, 0); + if (r <= 0) break; + used += r; buf[used] = 0; + char *nl; + while ((nl = strchr(buf, '\n')) != NULL) { + *nl = 0; + char *cr = strchr(buf, '\r'); if (cr) *cr = 0; + handle_line(c, buf); + size_t rest = strlen(nl + 1); + memmove(buf, nl + 1, rest + 1); + used = (int)rest; + } + if (used >= (int)sizeof(buf) - 1) used = 0; /* overlong line: drop */ + } +#ifdef _WIN32 + closesocket(c); +#else + close(c); +#endif + } +} + +static void handle_line(sock_t s, char *line) { + char out[1024]; + char cmd[32] = {0}; + if (sscanf(line, "%31s", cmd) != 1) { send_line(s, "err empty\n"); return; } + + if (!strcmp(cmd, "status")) { + snprintf(out, sizeof out, "cp %llu cycle %llu chain %016llx stride %llu parked %d\n", + (unsigned long long)g_cp, (unsigned long long)psx_cycle_count, + (unsigned long long)g_chain, (unsigned long long)g_stride, g_parked); + send_line(s, out); return; + } + if (!strcmp(cmd, "chain")) { + snprintf(out, sizeof out, "chain %016llx cp %llu cycle %llu\n", + (unsigned long long)g_chain, (unsigned long long)g_cp, + (unsigned long long)psx_cycle_count); + send_line(s, out); return; + } + if (!strcmp(cmd, "stride")) { + /* Stride fixes the checkpoint cycle boundaries; set ONCE before stepping so both + * processes checkpoint at identical cycles. Changing it mid-run is refused. */ + unsigned long long n = 0; + if (sscanf(line, "%*s %llu", &n) != 1 || n == 0) { send_line(s, "err stride\n"); return; } + if (g_cp != 0) { send_line(s, "err stride-after-start\n"); return; } + g_stride = (uint64_t)n; g_next_cp = 0; send_line(s, "ok\n"); return; + } + if (!strcmp(cmd, "step")) { /* step ; parks at cp+N */ + unsigned long long n = 1; + sscanf(line, "%*s %llu", &n); + g_run_budget += (int64_t)n; + g_go_token++; /* wake the parked guest */ + /* wait until it parks again (crossed N checkpoints), or the guest exits */ + int spins = 0; + while (g_run_budget > 0 && spins < 1200000) { COSIM_SLEEP(1); spins++; } + /* small settle so g_parked/g_chain reflect the checkpoint just recorded */ + int s2 = 0; while (!g_parked && g_run_budget <= 0 && s2 < 2000) { COSIM_SLEEP(1); s2++; } + snprintf(out, sizeof out, "%s cp %llu cycle %llu chain %016llx\n", + g_parked ? "parked" : "running", + (unsigned long long)g_cp, (unsigned long long)psx_cycle_count, + (unsigned long long)g_chain); + send_line(s, out); return; + } + if (!strcmp(cmd, "hash")) { + Entry *e = &g_ring[g_cp & (RING_N - 1u)]; + snprintf(out, sizeof out, "hash %016llx pc %08x istat %08x imask %08x cyc %llu\n", + (unsigned long long)e->hash, e->pc, e->istat, e->imask, + (unsigned long long)e->cycle); + send_line(s, out); return; + } + if (!strcmp(cmd, "sub")) { + CosimSubHashes h; cosim_state_hash(&h); + snprintf(out, sizeof out, + "cpu %016llx irqctl %016llx ram %016llx scratch %016llx vram %016llx " + "gpu %016llx spu %016llx cdrom %016llx dma %016llx sio %016llx " + "timers %016llx clock %016llx dirty %016llx\n", + (unsigned long long)h.cpu,(unsigned long long)h.irqctl,(unsigned long long)h.ram, + (unsigned long long)h.scratch,(unsigned long long)h.vram,(unsigned long long)h.gpu, + (unsigned long long)h.spu,(unsigned long long)h.cdrom,(unsigned long long)h.dma, + (unsigned long long)h.sio,(unsigned long long)h.timers,(unsigned long long)h.clock, + (unsigned long long)h.dirty); + send_line(s, out); return; + } + if (!strcmp(cmd, "window")) { + unsigned long long n = 32; + sscanf(line, "%*s %llu", &n); + if (n > RING_N) n = RING_N; + if (n > g_cp) n = g_cp; + for (uint64_t i = 0; i < n; i++) { + uint64_t cp = g_cp - (n - 1 - i); + Entry *e = &g_ring[cp & (RING_N - 1u)]; + snprintf(out, sizeof out, "row cp %llu pc %08x hash %016llx istat %08x cyc %llu\n", + (unsigned long long)e->cp, e->pc, (unsigned long long)e->hash, + e->istat, (unsigned long long)e->cycle); + send_line(s, out); + } + send_line(s, "end\n"); return; + } + if (!strcmp(cmd, "inject")) { + char what[8] = {0}; unsigned long a=0, b=0; + if (sscanf(line, "%*s %7s %lu %lu", what, &a, &b) == 3) { + if (!strcmp(what, "ram")) { cosim_inject_ram((uint32_t)a, (uint8_t)b); send_line(s, "ok\n"); return; } + if (!strcmp(what, "reg")) { cosim_inject_reg((int)a, (uint32_t)b); send_line(s, "ok\n"); return; } + } + send_line(s, "err inject\n"); return; + } + if (!strcmp(cmd, "cpu")) { + /* Full CPU field dump so the coordinator can field-diff a cpu-subsystem + * divergence (which register / micro-state field actually split). */ + extern CPUState *debug_cpu_ptr; + CPUState *c = debug_cpu_ptr; + if (!c) { send_line(s, "err no-cpu\n"); return; } + char *p = out; size_t rem = sizeof out; + int w = snprintf(p, rem, "pc %08x hi %08x lo %08x", c->pc, c->hi, c->lo); + p += w; rem -= w; + for (int i = 0; i < 32; i++) { w = snprintf(p, rem, " r%d %08x", i, c->gpr[i]); p+=w; rem-=w; } + for (int i = 0; i < 16; i++) { w = snprintf(p, rem, " c%d %08x", i, c->cop0[i]); p+=w; rem-=w; } + w = snprintf(p, rem, " mdts %llx gtets %llx rfudge %02x rabw %02x ldw %02x ldabs %08x\n", + (unsigned long long)c->muldiv_ts_done, (unsigned long long)c->gte_ts_done, + c->read_fudge, c->read_absorb_which, c->ld_which_t, c->ld_absorb); + send_line(s, out); return; + } + if (!strcmp(cmd, "gte")) { + extern CPUState *debug_cpu_ptr; + CPUState *c = debug_cpu_ptr; + if (!c) { send_line(s, "err no-cpu\n"); return; } + char big[4096]; + char *p = big; size_t rem = sizeof big; + for (int i = 0; i < 32 && rem > 1; i++) { + uint32_t val = c->gte_data[i]; + if ((i >= 8 && i <= 11) || i == 15 || i == 28 || i == 29 || i == 31) { + val = gte_read_data(c, (uint8_t)i); + } + int w = snprintf(p, rem, " gd%d %08x", i, val); + if (w < 0 || (size_t)w >= rem) { p += rem - 1; rem = 1; break; } + p += w; rem -= (size_t)w; + } + for (int i = 0; i < 32 && rem > 1; i++) { + int w = snprintf(p, rem, " gc%d %08x", i, c->gte_ctrl[i]); + if (w < 0 || (size_t)w >= rem) { p += rem - 1; rem = 1; break; } + p += w; rem -= (size_t)w; + } + snprintf(p, rem, "\n"); + send_line(s, big); return; + } + if (!strcmp(cmd, "dev")) { + /* Device-timing field dump: the fields behind the `irqctl` and `gpu` + * sub-hashes, so a device divergence can be field-diffed (esp. + * cycles_since_vblank = VBLANK-raise-timing, and GPUSTAT). */ + extern void interrupts_cosim_dump(uint32_t*, int*); + extern uint32_t gpu_read_gpustat(void); + uint32_t csv = 0; int inexc = 0; interrupts_cosim_dump(&csv, &inexc); + snprintf(out, sizeof out, + "csv %08x inexc %d imask %08x istat %08x gpustat %08x\n", + csv, inexc, i_mask, i_stat, gpu_read_gpustat()); + send_line(s, out); return; + } + if (!strcmp(cmd, "gpu")) { + extern void gpu_cosim_dump(char *out, int cap); + char big[4096]; + gpu_cosim_dump(big, (int)sizeof big); + send_line(s, big); return; + } + if (!strcmp(cmd, "irqtrace")) { + extern void interrupts_cosim_irq_dump(char *out, int cap); + char big[32768]; + interrupts_cosim_irq_dump(big, (int)sizeof big); + send_line(s, big); return; + } + if (!strcmp(cmd, "reset")) { cosim_state_reset(); send_line(s, "ok\n"); return; } + send_line(s, "err unknown\n"); +} + +#ifdef _WIN32 +static DWORD WINAPI server_thread(LPVOID p) { server_loop((unsigned short)(uintptr_t)p); return 0; } +#else +static void *server_thread(void *p) { server_loop((unsigned short)(uintptr_t)p); return NULL; } +#endif + +/* Call once at startup (from the clean-build main, before the guest runs). Port from + * env PSX_COSIM_PORT (default 4600). */ +void cosim_init(void) { + cosim_state_reset(); + unsigned short port = 4600; + const char *e = getenv("PSX_COSIM_PORT"); + if (e && *e) port = (unsigned short)atoi(e); + /* Stride fixed at launch (env) so the checkpoint cycle boundaries are identical in + * both processes before either runs a single instruction — no set-stride race. */ + const char *st = getenv("PSX_COSIM_STRIDE"); + if (st && *st) { unsigned long long v = strtoull(st, 0, 10); if (v) g_stride = v; } + const char *sc = getenv("PSX_COSIM_START_CYCLE"); + if (sc && *sc) { unsigned long long v = strtoull(sc, 0, 10); g_next_cp = (uint64_t)v; } +#ifdef _WIN32 + WSADATA w; WSAStartup(MAKEWORD(2,2), &w); + CreateThread(NULL, 0, server_thread, (LPVOID)(uintptr_t)port, 0, NULL); +#else + pthread_t t; pthread_create(&t, NULL, server_thread, (void*)(uintptr_t)port); +#endif + fprintf(stdout, "cosim: first-divergence oracle listening on 127.0.0.1:%u\n", port); +} + +#endif /* PSX_COSIM */ diff --git a/runtime/src/cosim_state.c b/runtime/src/cosim_state.c new file mode 100644 index 00000000..c62ef8b1 --- /dev/null +++ b/runtime/src/cosim_state.c @@ -0,0 +1,220 @@ +#ifdef PSX_COSIM +/* cosim_state.c — full guest-architectural-state canonical hash. See COSIM_ORACLE.md + * and cosim_state.h. Active ONLY in the PSX_COSIM build; empty TU otherwise (safe in + * the shared source list). + * + * Principle: hash exactly the state a real PSX exposes to the running program plus the + * device-timing quantities that determine WHEN a guest-visible bit flips — never our + * emulator's internal bookkeeping (e.g. total_checks / dispatch_count / cooldown are + * counted in psx_check_interrupts CALLS, which legitimately differ between the compiled + * and interp backends by call frequency; hashing them would report a false first + * divergence). The genuine timing quantity that matters is cycles_since_vblank (guest + * cycles), which is included. This exclusion list is refined by the injected-divergence + * and compiled-vs-compiled gates. + */ +#include "cosim_state.h" +#include +#include + +/* ---- FNV-1a 64 ---- */ +#define FNV_OFF 1469598103934665603ULL +#define FNV_PRM 1099511628211ULL +static inline uint64_t fnv(uint64_t h, const void *p, size_t n) { + const uint8_t *b = (const uint8_t *)p; + for (size_t i = 0; i < n; i++) { h ^= b[i]; h *= FNV_PRM; } + return h; +} +static inline uint64_t fnv_u32(uint64_t h, uint32_t v) { return fnv(h, &v, 4); } +static inline uint64_t fnv_u64(uint64_t h, uint64_t v) { return fnv(h, &v, 8); } + +/* ---- runtime state accessors (existing modules) ---- */ +extern CPUState *debug_cpu_ptr; +extern uint8_t *memory_get_ram_ptr(void); +extern uint8_t *memory_get_scratchpad_ptr(void); +extern uint32_t i_stat; +extern uint32_t i_mask; +extern uint64_t psx_cycle_count; +extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); +extern uint32_t dirty_ram_get_bitmap_word_count(void); +extern void timers_get_snapshot(uint16_t counter[3], uint32_t mode[3], + uint16_t target[3], int32_t irq_line[3], + uint32_t frac[3]); +extern uint32_t gpu_cosim_snapshot_bytes(void); extern void gpu_cosim_snapshot_write(uint8_t*); +extern uint32_t spu_snapshot_bytes(void); extern void spu_snapshot_write(uint8_t*); +extern uint8_t* spu_get_ram_ptr(void); extern uint32_t spu_get_ram_bytes(void); +extern uint32_t cdrom_snapshot_bytes(void); extern void cdrom_snapshot_write(uint8_t*); +extern uint32_t dma_snapshot_bytes(void); extern void dma_snapshot_write(uint8_t*); +extern uint32_t sio_snapshot_bytes(void); extern void sio_snapshot_write(uint8_t*); +/* interrupts.c: fold the genuine guest-timing statics (NOT the call-count artifacts). */ +extern uint64_t interrupts_cosim_hash(uint64_t seed); +/* renderer-agnostic VRAM readback (sw renderer = cheap memcpy). */ +extern void gr_vram_transfer_out(int x, int y, int w, int h, uint16_t *dst); + +#define RAM_SIZE (2u * 1024u * 1024u) +#define SPAD_SIZE (1024u) +#define PAGE 4096u +#define RAM_PAGES (RAM_SIZE / PAGE) /* 512 */ +#define VRAM_W 1024 +#define VRAM_H 512 +#define VRAM_SIZE ((uint32_t)(VRAM_W * VRAM_H * 2)) /* 1 MB */ +#define VRAM_PAGES (VRAM_SIZE / PAGE) /* 256 */ + +/* ---- incremental RAM page hashes ---- */ +static uint64_t s_ram_page_h[RAM_PAGES]; +static uint8_t s_ram_page_dirty[RAM_PAGES]; +static int s_ram_all_dirty = 1; + +/* ---- VRAM: full-recompute when dirty (draws are periodic, not per-block) ---- */ +static uint64_t s_vram_h; +static int s_vram_dirty = 1; +static uint16_t s_vram_buf[VRAM_W * VRAM_H]; + +/* ---- reusable device-blob scratch ---- */ +static uint8_t *s_blob; +static uint32_t s_blob_cap; + +/* ---- gate-4 injection (applied to LIVE state so it flows into the hash naturally) -- */ +static int32_t s_inj_ram_phys = -1; static uint8_t s_inj_ram_xor; +static int32_t s_inj_reg = -1; static uint32_t s_inj_reg_xor; + +void cosim_state_reset(void) { + memset(s_ram_page_h, 0, sizeof s_ram_page_h); + memset(s_ram_page_dirty, 0, sizeof s_ram_page_dirty); + s_ram_all_dirty = 1; + s_vram_dirty = 1; s_vram_h = 0; + s_inj_ram_phys = -1; s_inj_reg = -1; +} + +void cosim_note_ram_write(uint32_t phys, uint32_t nbytes) { + if (phys >= RAM_SIZE) return; + uint32_t last = phys + (nbytes ? nbytes - 1 : 0); + if (last >= RAM_SIZE) last = RAM_SIZE - 1; + for (uint32_t p = phys / PAGE; p <= last / PAGE; p++) s_ram_page_dirty[p] = 1; +} +void cosim_note_vram_write(uint32_t byte_off, uint32_t nbytes) { + (void)byte_off; (void)nbytes; s_vram_dirty = 1; +} +void cosim_inject_ram(uint32_t phys, uint8_t xor_val) { s_inj_ram_phys = (int32_t)phys; s_inj_ram_xor = xor_val; } +void cosim_inject_reg(int reg_index, uint32_t xor_val) { s_inj_reg = reg_index; s_inj_reg_xor = xor_val; } + +static uint64_t blob_hash(uint64_t seed, uint32_t (*bytes)(void), void (*write)(uint8_t*)) { + uint32_t n = bytes(); + if (n > s_blob_cap) { free(s_blob); s_blob = (uint8_t*)malloc(n); s_blob_cap = n; } + if (n && !s_blob) return seed; /* OOM: leave unchanged (will surface in gate) */ + if (n) write(s_blob); + return fnv(fnv_u32(seed, n), s_blob, n); +} + +static uint64_t hash_cpu(const CPUState *c) { + uint64_t h = FNV_OFF; + uint32_t gte_data[32]; + h = fnv(h, c->gpr, sizeof c->gpr); + /* cpu->pc is DELIBERATELY EXCLUDED from the cross-backend hash. The compiled + * backend does not keep cpu->pc current mid-execution — it writes pc only at block + * transfers, and it is transiently 0 between dispatch calls — whereas the interp + * keeps pc exact per-instruction. So at a mid-instruction cycle checkpoint the two + * backends legitimately hold different pc values while being in the SAME + * architectural state (verified: at the first flagged divergence, cp32, ONLY pc + * differed; every gpr/cop0/hi/lo/micro-state matched). Including pc produced a false + * first-divergence. This is NOT a blind spot: a REAL control-flow split shows up as a + * differing gpr/memory value within one checkpoint. pc stays available via the `cpu` + * TCP command for reporting. */ + h = fnv_u32(h, c->hi); h = fnv_u32(h, c->lo); + h = fnv(h, c->cop0, sizeof c->cop0); + memcpy(gte_data, c->gte_data, sizeof gte_data); + /* Some GTE data registers are derived/read-special values. The compiled + * backend reads these through gte_read_data(), while the dirty interpreter's + * all-purpose gte_write_data() refreshes their backing cache as a side + * effect. Hash the same canonical value the guest would read so stale cache + * bits do not become oracle false positives. */ + CPUState *mc = (CPUState *)c; + for (int i = 8; i <= 11; i++) gte_data[i] = gte_read_data(mc, (uint8_t)i); + gte_data[15] = gte_read_data(mc, 15); + gte_data[28] = gte_read_data(mc, 28); + gte_data[29] = gte_read_data(mc, 29); + gte_data[31] = gte_read_data(mc, 31); + h = fnv(h, gte_data, sizeof gte_data); + h = fnv(h, c->gte_ctrl, sizeof c->gte_ctrl); + /* micro-state — the execution-critical fields boot_state omits */ + h = fnv_u64(h, c->muldiv_ts_done); + h = fnv_u64(h, c->gte_ts_done); + h = fnv(h, c->read_absorb, sizeof c->read_absorb); + h = fnv(h, &c->read_absorb_which, 1); + h = fnv(h, &c->read_fudge, 1); + h = fnv(h, &c->ld_which_t, 1); + h = fnv_u32(h, c->ld_absorb); + /* EXCLUDED (host-only): read_word/.../write_byte fn ptrs, sljit_helpers. */ + return h; +} + +uint64_t cosim_state_hash(CosimSubHashes *sub) { + CosimSubHashes s; memset(&s, 0, sizeof s); + CPUState *cpu = debug_cpu_ptr; + uint8_t *ram = memory_get_ram_ptr(); + + /* apply pending gate-4 injection to live state */ + if (s_inj_ram_phys >= 0 && (uint32_t)s_inj_ram_phys < RAM_SIZE) { + ram[s_inj_ram_phys] ^= s_inj_ram_xor; cosim_note_ram_write((uint32_t)s_inj_ram_phys, 1); + s_inj_ram_phys = -1; + } + if (s_inj_reg >= 0 && cpu) { + if (s_inj_reg < 32) cpu->gpr[s_inj_reg] ^= s_inj_reg_xor; + else if (s_inj_reg == 32) cpu->hi ^= s_inj_reg_xor; + else if (s_inj_reg == 33) cpu->lo ^= s_inj_reg_xor; + s_inj_reg = -1; + } + + if (cpu) s.cpu = hash_cpu(cpu); + + /* RAM: recompute dirty pages, fold page hashes */ + for (uint32_t p = 0; p < RAM_PAGES; p++) { + if (s_ram_all_dirty || s_ram_page_dirty[p]) { + s_ram_page_h[p] = fnv(FNV_OFF, ram + (size_t)p * PAGE, PAGE); + s_ram_page_dirty[p] = 0; + } + } + s_ram_all_dirty = 0; + s.ram = fnv(FNV_OFF, s_ram_page_h, sizeof s_ram_page_h); + + s.scratch = fnv(FNV_OFF, memory_get_scratchpad_ptr(), SPAD_SIZE); + + /* VRAM: EXCLUDED in v1. The guest state machine reads RAM + device registers to + * make the FMV/handoff decision, never VRAM, so a VRAM divergence is downstream of + * (not causal for) the first control-flow split — and a 1MB readback per cycle- + * checkpoint would be prohibitively slow. Add incrementally (cosim_note_vram_write + * page hashes) only if a gate shows a divergence that first surfaces in VRAM. */ + s.vram = 0; + + /* interrupt controller: guest-visible i_stat/i_mask + genuine timing statics */ + s.irqctl = interrupts_cosim_hash(fnv_u32(fnv_u32(FNV_OFF, i_stat), i_mask)); + + /* timers */ + { uint16_t cnt[3],tgt[3]; uint32_t mode[3],frac[3]; int32_t irq[3]; + timers_get_snapshot(cnt, mode, tgt, irq, frac); + uint64_t h = FNV_OFF; + h = fnv(h,cnt,sizeof cnt); h = fnv(h,mode,sizeof mode); h = fnv(h,tgt,sizeof tgt); + h = fnv(h,irq,sizeof irq); h = fnv(h,frac,sizeof frac); s.timers = h; } + + s.clock = fnv_u64(FNV_OFF, psx_cycle_count); + + s.gpu = blob_hash(FNV_OFF, gpu_cosim_snapshot_bytes, gpu_cosim_snapshot_write); + s.spu = blob_hash(FNV_OFF, spu_snapshot_bytes, spu_snapshot_write); + s.spu = fnv(s.spu, spu_get_ram_ptr(), spu_get_ram_bytes()); + s.cdrom = blob_hash(FNV_OFF, cdrom_snapshot_bytes, cdrom_snapshot_write); + s.dma = blob_hash(FNV_OFF, dma_snapshot_bytes, dma_snapshot_write); + s.sio = blob_hash(FNV_OFF, sio_snapshot_bytes, sio_snapshot_write); + + /* dirty-RAM page bitmap (affects dispatch/interp routing) */ + { uint64_t h = FNV_OFF; uint32_t wc = dirty_ram_get_bitmap_word_count(); + for (uint32_t i = 0; i < wc; i++) h = fnv_u32(h, dirty_ram_get_bitmap_word(i)); + s.dirty = h; } + + if (sub) *sub = s; + + /* fold everything into the top hash */ + uint64_t top = FNV_OFF; + top = fnv(top, &s, sizeof s); + return top; +} + +#endif /* PSX_COSIM */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index f913de91..9b31991b 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -39,6 +39,7 @@ #include "card_data_writes.h" #include "crash_trace.h" #include "gpu_gl_renderer.h" +#include "lockstep.h" #include #include @@ -696,7 +697,10 @@ static uint32_t trace_read_word(CPUState *cpu, uint32_t addr) /* Use psx_read_word directly (NOT cpu->read_word): debug instrumentation * must not charge guest main-RAM read wait states, and runs on the IO * thread where psx_advance_cycles would race the emulation thread. */ - return psx_read_word(addr); + ls_suppress_begin(); + uint32_t v = psx_read_word(addr); + ls_suppress_end(); + return v; } static uint32_t trace_read_half(CPUState *cpu, uint32_t addr) @@ -707,7 +711,10 @@ static uint32_t trace_read_half(CPUState *cpu, uint32_t addr) (phys < 0x1F800000u || phys > 0x1F8003FEu)) { return 0; } - return psx_read_half(addr); + ls_suppress_begin(); + uint32_t v = psx_read_half(addr); + ls_suppress_end(); + return v; } static int sreg_trace_focus_func(uint32_t func) @@ -1938,6 +1945,7 @@ void debug_server_log_call_entry(uint32_t func_addr) { psx_native_stack_guard(func_addr); /* runs in debug AND release (before the early-return) */ #endif #ifndef PSX_NO_DEBUG_TOOLS + ls_suppress_begin(); if (s_synth_recurse_armed) { s_synth_recurse_armed = 0; psx_synth_recurse(0); } /* cyc_watch: universal compiled-function-entry hook (game AND BIOS, incl. * relocated BIOS-shell funcs which dispatch oddly but still log their entry @@ -1956,13 +1964,14 @@ void debug_server_log_call_entry(uint32_t func_addr) { s_fn_direct_seen++; if (!debug_cpu_ptr) { s_fn_direct_no_cpu++; + ls_suppress_end(); return; } card_mgr_trace_record(func_addr, 0); sreg_trace_record(func_addr); call_focus_record(func_addr); - if (!s_fn_entry) return; - if (!fn_trace_in_filter(func_addr)) return; + if (!s_fn_entry) { ls_suppress_end(); return; } + if (!fn_trace_in_filter(func_addr)) { ls_suppress_end(); return; } s_fn_direct_filtered++; FnEntryEntry *e = &s_fn_entry[s_fn_entry_seq % FN_TRACE_CAP]; e->seq = s_fn_entry_seq; @@ -1981,6 +1990,7 @@ void debug_server_log_call_entry(uint32_t func_addr) { e->depth = (uint32_t)s_fn_stack_top; e->frame = (uint32_t)s_frame_count; s_fn_entry_seq++; + ls_suppress_end(); } /* Always-on A0/B0/C0 BIOS-call ring (ported from ape-fw for good-vs-bad @@ -2161,6 +2171,7 @@ void debug_server_trace_dispatch(uint32_t func_addr) { (void)func_addr; return; #endif + ls_suppress_begin(); /* cyc_watch: compiled-dispatch path. func_addr is already the physical * (normalized) block leader. Sampled before the block runs. */ cyc_watch_observe(func_addr & 0x1FFFFFFFu); @@ -2243,6 +2254,7 @@ void debug_server_trace_dispatch(uint32_t func_addr) { s_dispatch_ring[s_dispatch_seq % DISPATCH_TRACE_CAP] = func_addr; s_dispatch_seq++; dispatch_unique_add(func_addr); + ls_suppress_end(); } static int json_get_int(const char *json, const char *key, int def); @@ -10410,8 +10422,25 @@ static void handle_lockstep(int id, const char *json) { send_fmt("{\"id\":%d,\"ok\":true,\"lockstep\":%s}", id, buf); } +static void handle_lockstep_func(int id, const char *json) { + /* Dispatch-segment lockstep comparator. Same arming shape as lockstep, + * but the measured unit is one clean psx_dispatch_game_compiled() segment. */ + extern void ls_func_set_window(uint32_t, uint32_t); + extern void ls_func_set_record_only(int); + extern int ls_get_func_json(char*, int); + int lo = json_get_int(json, "lo", -1); + int hi = json_get_int(json, "hi", -1); + int ro = json_get_int(json, "record_only", -1); + if (ro >= 0) ls_func_set_record_only(ro); + if (lo >= 0 && hi >= 0) ls_func_set_window((uint32_t)lo, (uint32_t)hi); + char buf[8192]; + ls_get_func_json(buf, (int)sizeof(buf)); + send_fmt("{\"id\":%d,\"ok\":true,\"lockstep_func\":%s}", id, buf); +} + static const CmdEntry s_commands[] = { { "lockstep", handle_lockstep }, + { "lockstep_func", handle_lockstep_func }, { "ping", handle_ping }, { "parity_dump", handle_parity_dump }, { "parity_ctl", handle_parity_ctl }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 18514594..a4310ece 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -31,6 +31,7 @@ #include "psx_instr_cost.h" /* psx_instr_base_cycles — single-source cycle cost */ #include "gpu.h" /* psx_ws_is_backdrop_site / psx_ws_backdrop_x (interp hook) */ #include "ws_backdrop_detect.h" /* shared backdrop-window detector (auto_backdrop) */ +#include "lockstep.h" #include #include @@ -48,6 +49,7 @@ uint64_t g_dirty_ram_guard_yields = 0; * advancing cycles without surfacing to psx_check_interrupts (the softlock). */ uint64_t g_dirty_pump_max_gap_insns = 0; uint64_t g_dirty_pump_count = 0; +static uint64_t s_last_dirty_irq_pump_insns = 0; /* EPC de-overload signal (Tomba 2 frame-1997 fix). Set to the committed guest PC * immediately around a dirty-pump psx_check_interrupts() call, 0 otherwise. When @@ -73,6 +75,21 @@ int g_precise_mode = 0; * (g_ls_dirty_observe==0) from an interpreted per-instruction sample (==1). */ int g_ls_dirty_observe = 0; extern int g_ls_replay_active; /* defined in the lockstep section; used by exec_one's jal/jalr guard */ + +#ifdef PSX_COSIM +static int g_cosim_exec_one_hooked = 0; +static void cosim_exec_one_begin(void) { g_cosim_exec_one_hooked = 0; } +static int cosim_exec_one_did_hook(void) { return g_cosim_exec_one_hooked; } +static void cosim_exec_one_transfer_hook(uint32_t pc) { + extern void cosim_instr(uint32_t); + cosim_instr(pc); + g_cosim_exec_one_hooked = 1; +} +#else +static void cosim_exec_one_begin(void) {} +static int cosim_exec_one_did_hook(void) { return 0; } +static void cosim_exec_one_transfer_hook(uint32_t pc) { (void)pc; } +#endif 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). */ @@ -84,6 +101,13 @@ 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? */ +uint32_t g_slice_exit_pc = 0; /* PC published by the last precise slice */ +uint32_t g_slice_exit_reason = 0; /* 1=safe, 2=unsupported, 3=bail, 4=guard */ +uint32_t g_slice_exit_iter = 0; /* loop iteration at exit */ +uint32_t g_slice_exit_dispatchable = 0; /* precise_pc_dispatchable(exit_pc) */ +uint32_t g_slice_exit_dirty = 0; /* dirty_ram_is_dirty(exit_pc phys) */ +uint32_t g_slice_exit_in_text = 0; /* psx_game_address_in_text(exit_pc) */ +uint32_t g_slice_exit_want = 0; /* want_exit value at exit */ /* 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 @@ -109,6 +133,7 @@ uint32_t g_sentinel_reach_async = 0; * than psx_unknown_dispatch (which has its own fail-fast). Captures the dispatch * addr + interrupt-return context so the exact producing path is identifiable * without printf. Surfaced in freeze_check (pczero_*). */ +int g_cosim_dirty_pump_site = 0; /* PSX_COSIM diagnostic: current dirty IRQ pump site */ int g_pczero_latched = 0; uint32_t g_pczero_addr = 0; uint32_t g_pczero_ra = 0; @@ -540,6 +565,46 @@ static int dirty_ram_word_looks_decodable(uint32_t insn) { } } +static int dirty_ram_same_pc(uint32_t a, uint32_t b) { + return (((a ^ b) & 0x1FFFFFFFu) == 0); +} + +static int dirty_ram_pump_boundary(CPUState *cpu, uint32_t committed_pc, int site) { + uint32_t prev_safe = g_dirty_safe_resume_pc; + int prev_site = g_cosim_dirty_pump_site; + + s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; + if (!psx_interrupts_checked_at_current_cycle(committed_pc)) { + g_dirty_safe_resume_pc = committed_pc; + g_cosim_dirty_pump_site = site; + psx_check_interrupts(cpu); + g_dirty_safe_resume_pc = prev_safe; + g_cosim_dirty_pump_site = prev_site; + } + + if (g_psx_call_bail) return 1; + if (cpu->pc == 0u && committed_pc != 0u) { + cpu->pc = committed_pc; + g_async_rfe_fire_count++; + } + if (cpu->pc != 0u && !dirty_ram_same_pc(cpu->pc, committed_pc)) { + return 1; + } + + cpu->pc = committed_pc; + return 0; +} + +static int dirty_ram_finish_call_return(CPUState *cpu, uint32_t return_pc, + uint32_t *next_pc_out) { + uint32_t prev_pc = cpu->pc; + cpu->pc = return_pc; + if (dirty_ram_pump_boundary(cpu, return_pc, 5)) return 1; + cpu->pc = prev_pc; + *next_pc_out = return_pc; + return 0; +} + static int dispatch_nonlocal_call(CPUState *cpu, uint32_t target, uint32_t return_pc, uint32_t *next_pc_out) { @@ -549,8 +614,7 @@ static int dispatch_nonlocal_call(CPUState *cpu, uint32_t target, * unwind in progress surfaces with cpu->pc = the guest's true target. */ if (g_psx_call_bail) return 1; if (cpu->pc != 0) return 1; - *next_pc_out = return_pc; - return 0; + return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); } /* ── Mixed interp<->compiled dispatch owner (host-stack recursion fix) ────── @@ -882,7 +946,9 @@ static int interp_enter_compiled(CPUState *cpu, uint32_t target) { return 1; } g_mixed_depth++; + ls_func_enter(target, cpu); int r = psx_dispatch_game_compiled(cpu, target); + ls_func_exit(target, cpu, r); g_mixed_depth--; return r; } @@ -1045,6 +1111,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { case 0x08: { /* JR rs */ uint32_t target = cpu->gpr[rs]; exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); /* crossing (if target is compiled) is counted at the block-loop * tail-transfer site (interp_enter_compiled, §18) — not here, to * avoid double-counting J/JR-to-compiled. */ @@ -1057,6 +1124,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { cpu->gpr[rd ? rd : 31] = return_pc; cpu->gpr[0] = 0; exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); 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); @@ -1069,8 +1137,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { if (rd == 0 || rd == 31) { if (psx_call_contract(cpu, return_pc, site_sp)) return 1; } - *next_pc_out = return_pc; - return 0; + return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); } #endif /* Native overlay candidates get the SAME call contract as @@ -1087,8 +1154,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { if (rd == 0 || rd == 31) { if (psx_call_contract(cpu, return_pc, site_sp)) return 1; } - *next_pc_out = return_pc; - return 0; + return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); } } if (!is_local_dirty_target(target)) { @@ -1216,6 +1282,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { case 0x02: { /* J target */ uint32_t target = ((pc + 4) & 0xF0000000u) | (target26(insn) << 2); exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); /* crossing counted at the block-loop tail-transfer site (§18). */ cpu->pc = target; return 1; @@ -1225,6 +1292,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { uint32_t return_pc = pc + 8; cpu->gpr[31] = return_pc; exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); 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); @@ -1235,8 +1303,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { if (g_psx_call_bail) return 1; /* wild unwind: cpu->pc = true target */ if (cpu->pc != 0) return 1; if (psx_call_contract(cpu, return_pc, site_sp)) return 1; - *next_pc_out = return_pc; - return 0; + return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); } #endif /* Native overlay candidates get the SAME call contract as statically- @@ -1252,8 +1319,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { if (g_psx_call_bail) return 1; if (cpu->pc != 0) return 1; if (psx_call_contract(cpu, return_pc, site_sp)) return 1; - *next_pc_out = return_pc; - return 0; + return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); } } if (!is_local_dirty_target(target)) { @@ -1268,24 +1334,28 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { case 0x04: { /* BEQ rs, rt, simm */ int taken = (cpu->gpr[rs] == cpu->gpr[rt]); exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); return 1; } case 0x05: { /* BNE */ int taken = (cpu->gpr[rs] != cpu->gpr[rt]); exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); return 1; } case 0x06: { /* BLEZ */ int taken = ((int32_t)cpu->gpr[rs] <= 0); exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); return 1; } case 0x07: { /* BGTZ */ int taken = ((int32_t)cpu->gpr[rs] > 0); exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); return 1; } @@ -1301,6 +1371,7 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { default: return abort_unsupported(pc, insn, "REGIMM rt"); } exec_delay_slot(cpu, pc + 4); + cosim_exec_one_transfer_hook(pc + 4); cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); return 1; } @@ -1588,14 +1659,15 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { * g_dirty_interp_active at its longjmp landing (same contract as the inner * local-flow pump), so a deliver-via-longjmp here is safe. */ if (r == 1) { - static uint64_t s_last_pump_insns = 0; uint64_t now = g_dirty_ram_insns_run; - uint64_t gap = now - s_last_pump_insns; + uint64_t gap = now - s_last_dirty_irq_pump_insns; if (gap > g_dirty_pump_max_gap_insns) g_dirty_pump_max_gap_insns = gap; if (gap >= 4096u) { - s_last_pump_insns = now; + s_last_dirty_irq_pump_insns = now; g_dirty_pump_count++; uint32_t committed = cpu->pc; /* block already retired; this is next PC */ + int prev_site = g_cosim_dirty_pump_site; + g_cosim_dirty_pump_site = 6; g_dirty_safe_resume_pc = committed; psx_check_interrupts(cpu); /* may exception-enter / longjmp */ /* Frame-1997 fix: a game-driven ReturnFromException longjmp'd through the @@ -1610,6 +1682,7 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { g_async_rfe_fire_count++; } g_dirty_safe_resume_pc = 0; + g_cosim_dirty_pump_site = prev_site; } } @@ -1674,18 +1747,68 @@ static void psx_run_precise(CPUState *cpu, uint32_t bcyc, int deadline_entry) { g_slice_last_block = pc; g_slice_last_first_pc = pc; g_slice_last_first_insn = fetch_word(pc & 0x1FFFFFFFu); + g_slice_exit_pc = pc; + g_slice_exit_reason = 0; + g_slice_exit_iter = 0; + g_slice_exit_dispatchable = 0; + g_slice_exit_dirty = dirty_ram_is_dirty(pc & 0x1FFFFFFFu) ? 1u : 0u; +#ifdef PSX_HAS_GAME_DISPATCH + g_slice_exit_in_text = psx_game_address_in_text(pc) ? 1u : 0u; +#else + g_slice_exit_in_text = 0; +#endif + g_slice_exit_want = 0; 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++) { + if (!irq_taken && precise_irq_deliverable(cpu)) { + uint32_t committed = pc; + 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; + int prev_site = g_cosim_dirty_pump_site; + g_cosim_dirty_pump_site = 7; + g_dirty_safe_resume_pc = committed; + s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; + psx_check_interrupts(cpu); + if (cpu->pc == 0u && committed != 0u) { + cpu->pc = committed; + g_async_rfe_fire_count++; + } + g_dirty_safe_resume_pc = 0; + g_cosim_dirty_pump_site = prev_site; + g_slice_irq_taken++; + irq_taken = 1; + pc = cpu->pc ? cpu->pc : committed; + cpu->pc = pc; + if (precise_pc_dispatchable(cpu->pc)) { + g_slice_exit_reason = 1; + g_slice_exit_iter = (uint32_t)i; + g_slice_exit_want = 1; + break; + } + continue; + } + uint32_t next_pc = 0; g_unsupported_seen = 0; + cosim_exec_one_begin(); int transferred = exec_one(cpu, pc, &next_pc); /* charges its own interlock */ g_dirty_ram_insns_run++; +#ifdef PSX_COSIM + if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } +#endif 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; + g_slice_exit_reason = 2; + g_slice_exit_iter = (uint32_t)i; + g_slice_exit_want = 0; break; } uint32_t committed = transferred ? cpu->pc : next_pc; @@ -1710,34 +1833,59 @@ static void psx_run_precise(CPUState *cpu, uint32_t bcyc, int deadline_entry) { g_slice_last_imask = i_mask; g_slice_last_sr = cpu->cop0[12]; cpu->pc = committed; + int prev_site = g_cosim_dirty_pump_site; + g_cosim_dirty_pump_site = 7; g_dirty_safe_resume_pc = committed; /* real EPC for exception entry */ + s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; psx_check_interrupts(cpu); /* takes it; runs handler; restores GPRs */ g_dirty_safe_resume_pc = 0; + g_cosim_dirty_pump_site = prev_site; g_slice_irq_taken++; irq_taken = 1; - cpu->pc = committed; /* resume the interrupted stream at the exact PC */ + committed = cpu->pc ? cpu->pc : committed; want_exit = 1; } pc = committed; cpu->pc = committed; - if (g_psx_call_bail) break; /* wild unwind: hand cpu->pc to the dispatcher */ + if (g_psx_call_bail) { + g_slice_exit_reason = 3; + g_slice_exit_iter = (uint32_t)i; + g_slice_exit_want = want_exit ? 1u : 0u; + 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 */ + want_exit = 1; /* 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; + if (want_exit && precise_pc_dispatchable(cpu->pc)) { + g_slice_exit_reason = 1; + g_slice_exit_iter = (uint32_t)i; + g_slice_exit_want = 1; + break; + } + } + if (g_slice_exit_reason == 0) { + g_slice_exit_reason = 4; + g_slice_exit_iter = MAX_PRECISE_INSNS; } cpu->pc = pc; + g_slice_exit_pc = cpu->pc; + g_slice_exit_dispatchable = precise_pc_dispatchable(cpu->pc) ? 1u : 0u; + g_slice_exit_dirty = dirty_ram_is_dirty(cpu->pc & 0x1FFFFFFFu) ? 1u : 0u; +#ifdef PSX_HAS_GAME_DISPATCH + g_slice_exit_in_text = psx_game_address_in_text(cpu->pc) ? 1u : 0u; +#else + g_slice_exit_in_text = 0; +#endif g_precise_mode = prev_precise; g_dirty_interp_active = prev_active; } @@ -1770,8 +1918,23 @@ int psx_slice_block(CPUState *cpu, uint32_t block_addr, uint32_t bcyc, int side_ * exception handling anyway). Keeps re-entrancy structurally impossible. */ if (g_precise_mode || psx_get_in_exception()) return 0; + static int s_slice_always = -1; + static int s_slice_margin = -1; + if (s_slice_always < 0) { + const char *e = getenv("PSX_PRECISE_ALWAYS"); + s_slice_always = (e && e[0] == '1') ? 1 : 0; + } + if (s_slice_margin < 0) { + const char *e = getenv("PSX_PRECISE_MARGIN"); + s_slice_margin = (e && *e) ? atoi(e) : 0; + if (s_slice_margin < 0) s_slice_margin = 0; + } + + uint32_t deadline = cycles_to_next_event(); + uint32_t budget = bcyc + (uint32_t)s_slice_margin; + if (budget < bcyc) budget = 0xFFFFFFFFu; int entry_deliverable = precise_irq_deliverable(cpu); - int has_deadline = entry_deliverable || (cycles_to_next_event() <= bcyc); + int has_deadline = s_slice_always || entry_deliverable || (deadline <= budget); if (!has_deadline && !side_effects) return 0; /* fast path: no event in this block */ g_slice_entry_deliverable = (uint32_t)entry_deliverable; @@ -1783,6 +1946,7 @@ int psx_slice_block(CPUState *cpu, uint32_t block_addr, uint32_t bcyc, int side_ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { uint32_t phys = addr & 0x1FFFFFFFu; + int clean_game_text_miss = 0; if (addr == 0x80000048u) { g_sentinel_reach_dirty++; @@ -1815,7 +1979,14 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * unchanged for non-overlaying games once their text is baselined). */ if (!dirty_ram_is_dirty(phys)) { g_mixed_depth++; - { int _gc = psx_dispatch_game_compiled(cpu, addr); g_mixed_depth--; if (_gc) return 1; } + { + ls_func_enter(addr, cpu); + int _gc = psx_dispatch_game_compiled(cpu, addr); + ls_func_exit(addr, cpu, _gc); + g_mixed_depth--; + if (_gc) return 1; + } + clean_game_text_miss = psx_game_address_in_text(addr) ? 1 : 0; } #endif @@ -1859,7 +2030,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ } #define OV_FPLOG_RET1() do { if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) - if (!dirty_ram_is_dirty(phys)) return 0; + if (!dirty_ram_is_dirty(phys) && !clean_game_text_miss) return 0; /* Interp-pressure signal for variant-capture automation (step 2.8): * counts dispatches the interpreter actually handles inside a capture @@ -1930,10 +2101,14 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ g_ls_dirty_observe = 1; debug_server_cyc_observe(pc & 0x1FFFFFFFu); g_ls_dirty_observe = 0; +#endif +#ifdef PSX_COSIM + { extern void cosim_block(uint32_t); cosim_block(pc); } #endif uint32_t insn = fetch_word(pc & 0x1FFFFFFFu); uint32_t before_s0 = cpu->gpr[16]; uint32_t before_ra = cpu->gpr[31]; + cosim_exec_one_begin(); int transferred = exec_one(cpu, pc, &next_pc); /* $ra->1 corruption tripwire (confirm-first probe): did THIS overlay * instruction clobber $ra to 1? Latches once, cheap after. */ @@ -1968,6 +2143,9 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ dirty_ram_log_instruction(cpu, pc, insn, before_s0, next_pc, transferred ? cpu->pc : next_pc, transferred); +#ifdef PSX_COSIM + if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } +#endif if (g_unsupported_seen) { if (insns_executed == 0) { /* Couldn't decode the first instruction. Most likely @@ -2010,6 +2188,13 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ OV_FPLOG_RET1(); } uint32_t target = cpu->pc; + if (target != 0u && dirty_ram_pump_boundary(cpu, target, 1)) { + g_dirty_ram_blocks_run++; + if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; + g_dirty_interp_chain_target = cpu->pc; + OV_FPLOG_RET1(); + } + target = cpu->pc; #ifdef PSX_HAS_GAME_DISPATCH if (target != 0) { /* §20 FIX — the long-run idle freeze. A guest TAIL-transfer (j/jr/ @@ -2062,9 +2247,6 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ if ((insns_executed & 0xFFF) == 0) { debug_server_poll(); debug_server_wait_if_paused(); - g_dirty_safe_resume_pc = cpu->pc; /* committed (==target) -> real-EPC */ - psx_check_interrupts(cpu); - g_dirty_safe_resume_pc = 0; } continue; } @@ -2079,6 +2261,12 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * hazard as a transfer to stop_addr). */ if (stop_addr != 0 && pc == stop_addr) { cpu->pc = pc; + if (dirty_ram_pump_boundary(cpu, pc, 2)) { + g_dirty_ram_blocks_run++; + if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; + g_dirty_interp_chain_target = cpu->pc; + OV_FPLOG_RET1(); + } g_dirty_ram_blocks_run++; if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; g_dirty_interp_chain_target = pc; @@ -2089,6 +2277,12 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ uint32_t next_phys = pc & 0x1FFFFFFFu; if (!dirty_ram_is_dirty(next_phys)) { cpu->pc = pc; + if (dirty_ram_pump_boundary(cpu, pc, 3)) { + g_dirty_ram_blocks_run++; + if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; + g_dirty_interp_chain_target = cpu->pc; + OV_FPLOG_RET1(); + } g_dirty_ram_blocks_run++; if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; g_dirty_interp_chain_target = pc; @@ -2104,16 +2298,23 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; g_dirty_interp_chain_target = pc; { - uint32_t committed = cpu->pc; - g_dirty_safe_resume_pc = committed; - psx_check_interrupts(cpu); - /* Frame-1997 fix (see outer pump): restore the committed PC if a game RFE - * longjmp left cpu->pc=0, so the trampoline re-dispatches instead of exiting. */ - if (cpu->pc == 0u && committed != 0u) { - cpu->pc = committed; - g_async_rfe_fire_count++; + uint64_t gap = g_dirty_ram_insns_run - s_last_dirty_irq_pump_insns; + if (gap >= 4096u) { + uint32_t committed = cpu->pc; + int prev_site = g_cosim_dirty_pump_site; + g_cosim_dirty_pump_site = 4; + g_dirty_safe_resume_pc = committed; + s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; + psx_check_interrupts(cpu); + /* Frame-1997 fix (see outer pump): restore the committed PC if a game RFE + * longjmp left cpu->pc=0, so the trampoline re-dispatches instead of exiting. */ + if (cpu->pc == 0u && committed != 0u) { + cpu->pc = committed; + g_async_rfe_fire_count++; + } + g_dirty_safe_resume_pc = 0; + g_cosim_dirty_pump_site = prev_site; } - g_dirty_safe_resume_pc = 0; } OV_FPLOG_RET1(); } @@ -2127,11 +2328,12 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ int g_ls_mode = 0; int g_ls_replay_active = 0; +int g_ls_suppress_record = 0; static uint32_t s_ls_frame_lo = 0, s_ls_frame_hi = 0; /* hi==0 => disabled */ typedef struct { uint8_t is_write, size; uint32_t addr, val; } ls_op_t; -enum { LS_TRACE_CAP = 8192 }; +enum { LS_TRACE_CAP = 65536 }; static ls_op_t s_ls_trace[LS_TRACE_CAP]; static int s_ls_trace_n = 0, s_ls_trace_idx = 0; static int s_ls_overflow = 0, s_ls_mismatch = 0; @@ -2161,8 +2363,45 @@ static ls_op_t s_ls_div_trace[48]; static int s_ls_div_trace_n = 0, s_ls_div_idx = 0; static int s_ls_record_only = 0; /* diagnostic: record but skip inline replay (perturbation test) */ + +static uint32_t s_lsf_frame_lo = 0, s_lsf_frame_hi = 0; /* hi==0 => disabled */ +static int s_lsf_record_only = 0; +static int s_lsf_active = 0; +static int s_lsf_recording = 0; +static int s_lsf_saw_irq = 0; +static CPUState s_lsf_R0; +static uint32_t s_lsf_entry = 0, s_lsf_dispatch_entry = 0, s_lsf_frame = 0; + +static struct { + int found; + int kind; /* see lsf_kind names in ls_get_func_json */ + uint32_t frame, entry, pc, addr; + uint32_t exp, act; + int detail; + uint64_t segments_checked, skipped_irq, skipped_overflow; + uint64_t skipped_unhandled, skipped_conflict, skipped_disabled; + uint32_t trace_ops, replay_ops, steps; +} s_lsf_div = {0}; + +static ls_op_t s_lsf_div_trace[48]; +static int s_lsf_div_trace_n = 0, s_lsf_div_idx = 0; + void ls_set_window(uint32_t lo, uint32_t hi) { s_ls_frame_lo = lo; s_ls_frame_hi = hi; } void ls_set_record_only(int on) { s_ls_record_only = on; } +void ls_func_set_window(uint32_t lo, uint32_t hi) { s_lsf_frame_lo = lo; s_lsf_frame_hi = hi; } +void ls_func_set_record_only(int on) { s_lsf_record_only = on; } + +void ls_note_exception_entry(void) { + if (s_lsf_active) s_lsf_saw_irq = 1; +} + +void ls_suppress_begin(void) { + g_ls_suppress_record++; +} + +void ls_suppress_end(void) { + if (g_ls_suppress_record > 0) g_ls_suppress_record--; +} uint32_t ls_read_hook(uint32_t addr, int size, uint32_t real_val) { if (g_ls_mode == 2) { /* replay: serve from trace */ @@ -2277,7 +2516,222 @@ static void ls_replay_and_compare(CPUState *cpu, uint32_t frame, uint32_t target } } +static int ls_same_phys(uint32_t a, uint32_t b) { + return (((a ^ b) & 0x1FFFFFFFu) == 0); +} + +static void lsf_copy_div_trace(void) { + int n = s_ls_trace_n; + if (n > 48) n = 48; + for (int i = 0; i < n; i++) s_lsf_div_trace[i] = s_ls_trace[i]; + s_lsf_div_trace_n = n; + s_lsf_div_idx = s_ls_trace_idx; +} + +static void lsf_latch(int kind, uint32_t frame, uint32_t entry, uint32_t pc, + uint32_t addr, uint32_t exp, uint32_t act, int detail, + uint32_t steps) { + if (s_lsf_div.found) return; + s_lsf_div.found = 1; + s_lsf_div.kind = kind; + s_lsf_div.frame = frame; + s_lsf_div.entry = entry; + s_lsf_div.pc = pc; + s_lsf_div.addr = addr; + s_lsf_div.exp = exp; + s_lsf_div.act = act; + s_lsf_div.detail = detail; + s_lsf_div.trace_ops = (uint32_t)s_ls_trace_n; + s_lsf_div.replay_ops = (uint32_t)s_ls_trace_idx; + s_lsf_div.steps = steps; + lsf_copy_div_trace(); +} + +static void lsf_compare_regs(CPUState *rep, CPUState *nat, uint32_t frame, + uint32_t entry, uint32_t steps) { + if (!ls_same_phys(rep->pc, nat->pc)) { + lsf_latch(4, frame, entry, rep->pc, 0, rep->pc, nat->pc, 0, steps); + return; + } + for (int i = 0; i < 32; i++) { + if (rep->gpr[i] != nat->gpr[i]) { + lsf_latch(1, frame, entry, rep->pc, 0, rep->gpr[i], nat->gpr[i], i, steps); + return; + } + } + if (rep->hi != nat->hi) { + lsf_latch(2, frame, entry, rep->pc, 0, rep->hi, nat->hi, 0, steps); + return; + } + if (rep->lo != nat->lo) { + lsf_latch(3, frame, entry, rep->pc, 0, rep->lo, nat->lo, 0, steps); + return; + } +} + +static void lsf_replay_and_compare(CPUState *native_post, uint32_t frame, + uint32_t entry_pc) { + CPUState rep = s_lsf_R0; + uint32_t target = native_post->pc; + rep.pc = entry_pc; + + s_ls_trace_idx = 0; + s_ls_mismatch = 0; + s_ls_m_kind = 0; + s_ls_replay_done = 0; + + int sav_unsup_seen = g_unsupported_seen; + uint32_t sav_unsup_pc = g_unsupported_pc; + uint32_t sav_unsup_insn = g_unsupported_insn; + const char *sav_unsup_reason = g_unsupported_reason; + uint32_t sav_store_pc = g_debug_last_store_pc; + uint32_t sav_func_addr = g_debug_current_func_addr; + + g_ls_replay_active = 1; + g_ls_mode = 2; + + uint32_t pc = entry_pc; + uint32_t steps = 0; + int done = 0; + enum { MAX_FUNC_REPLAY_INSNS = 250000 }; + for (;;) { + if (steps > 0 && target != 0 && ls_same_phys(pc, target) && + s_ls_trace_idx == s_ls_trace_n) { + done = 1; + break; + } + if (steps >= MAX_FUNC_REPLAY_INSNS) { + lsf_latch(10, frame, entry_pc, pc, 0, target, pc, 0, steps); + break; + } + if (s_ls_mismatch || s_ls_replay_done) break; + + uint32_t next_pc = 0; + s_ls_cur_pc = pc; + g_unsupported_seen = 0; + int transferred = exec_one(&rep, pc, &next_pc); + if (g_unsupported_seen) { + lsf_latch(11, frame, entry_pc, pc, 0, 0, g_unsupported_insn, 0, steps); + break; + } + pc = transferred ? rep.pc : next_pc; + rep.pc = pc; + steps++; + } + + g_ls_mode = 0; + g_ls_replay_active = 0; + g_unsupported_seen = sav_unsup_seen; + g_unsupported_pc = sav_unsup_pc; + g_unsupported_insn = sav_unsup_insn; + g_unsupported_reason = sav_unsup_reason; + g_debug_last_store_pc = sav_store_pc; + g_debug_current_func_addr = sav_func_addr; + + s_lsf_div.segments_checked++; + + if (s_ls_mismatch) { + int k = (s_ls_m_kind == 1) ? 6 : (s_ls_m_kind == 2) ? 7 : + (s_ls_m_kind == 3) ? 5 : 8; + lsf_latch(k, frame, entry_pc, s_ls_m_pc, s_ls_m_addr, + s_ls_m_exp, s_ls_m_act, 0, steps); + return; + } + if (s_ls_replay_done) { + lsf_latch(8, frame, entry_pc, s_ls_cur_pc, 0, (uint32_t)s_ls_trace_n, + (uint32_t)s_ls_trace_idx, 0, steps); + return; + } + if (!done) { + lsf_latch(10, frame, entry_pc, pc, 0, target, pc, 0, steps); + return; + } + if (s_ls_trace_idx != s_ls_trace_n) { + lsf_latch(9, frame, entry_pc, pc, 0, (uint32_t)s_ls_trace_n, + (uint32_t)s_ls_trace_idx, 0, steps); + return; + } + + lsf_compare_regs(&rep, native_post, frame, entry_pc, steps); +} + +void ls_func_enter(uint32_t entry_pc, CPUState *cpu) { + if (s_lsf_frame_hi == 0 || s_lsf_div.found || !cpu) return; + if (g_ls_replay_active || g_ls_mode != 0 || s_lsf_active) { + s_lsf_div.skipped_conflict++; + return; + } + if (psx_get_in_exception()) { + s_lsf_div.skipped_irq++; + return; + } + uint32_t frame = (uint32_t)s_frame_count; + if (frame < s_lsf_frame_lo || frame > s_lsf_frame_hi) { + s_lsf_div.skipped_disabled++; + return; + } + uint32_t phys = entry_pc & 0x1FFFFFFFu; + if (phys < 0x00010000u || phys >= 0x00200000u) return; + + s_lsf_dispatch_entry = entry_pc; + s_lsf_entry = entry_pc; + s_lsf_frame = frame; + s_lsf_saw_irq = 0; + s_lsf_active = 1; + s_lsf_recording = 0; +} + +void ls_func_exit(uint32_t entry_pc, CPUState *cpu, int handled) { + if (!s_lsf_active) return; + if (s_lsf_dispatch_entry != entry_pc) { + g_ls_mode = 0; + s_lsf_active = 0; + s_lsf_recording = 0; + s_lsf_div.skipped_conflict++; + return; + } + g_ls_mode = 0; + s_lsf_active = 0; + if (!s_lsf_recording) { + s_lsf_div.skipped_unhandled++; + return; + } + s_lsf_recording = 0; + + if (!handled || !cpu) { + s_lsf_div.skipped_unhandled++; + return; + } + if (s_lsf_saw_irq) { + s_lsf_div.skipped_irq++; + return; + } + if (s_ls_overflow) { + s_lsf_div.skipped_overflow++; + return; + } + if (s_lsf_record_only) { + s_lsf_div.segments_checked++; + return; + } + lsf_replay_and_compare(cpu, s_lsf_frame, s_lsf_entry); +} + void ls_at_leader(uint32_t leader_phys, CPUState *cpu) { + if (s_lsf_active) { + if (!s_lsf_recording && cpu && !s_lsf_div.found && g_ls_mode == 0) { + s_lsf_R0 = *cpu; + s_lsf_entry = leader_phys | 0x80000000u; + s_ls_trace_n = 0; + s_ls_trace_idx = 0; + s_ls_overflow = 0; + s_ls_mismatch = 0; + s_ls_m_kind = 0; + s_lsf_recording = 1; + g_ls_mode = 1; + } + return; /* function-scope recording owns g_ls_mode */ + } if (s_ls_frame_hi == 0 || s_ls_div.found || !cpu) return; if (g_ls_mode == 2) return; /* re-entrancy guard (shouldn't happen) */ uint32_t frame = (uint32_t)s_frame_count; @@ -2330,3 +2784,38 @@ int ls_get_diverge_json(char *buf, int buflen) { p += snprintf(buf + p, buflen - p, "]}"); return p; } + +int ls_get_func_json(char *buf, int buflen) { + static const char *kn[] = { + "none","gpr","hi","lo","pc","write-val","read-addr","write-addr", + "trace-exhausted","trace-leftover","path-cap","unsupported" + }; + int k = (s_lsf_div.kind >= 0 && s_lsf_div.kind <= 11) ? s_lsf_div.kind : 0; + int p = snprintf(buf, buflen, + "{\"found\":%d,\"kind\":\"%s\",\"frame\":%u,\"entry\":\"0x%08X\"," + "\"pc\":\"0x%08X\",\"addr\":\"0x%08X\"," + "\"interp_expected\":\"0x%08X\",\"compiled_actual\":\"0x%08X\"," + "\"reg\":%d,\"segments_checked\":%llu,\"skipped_irq\":%llu," + "\"skipped_overflow\":%llu,\"skipped_unhandled\":%llu," + "\"skipped_conflict\":%llu,\"skipped_disabled\":%llu," + "\"window\":[%u,%u],\"trace_ops\":%u,\"replay_ops\":%u," + "\"steps\":%u,\"div_idx\":%d,\"trace\":[", + s_lsf_div.found, kn[k], s_lsf_div.frame, s_lsf_div.entry, + s_lsf_div.pc, s_lsf_div.addr, s_lsf_div.exp, s_lsf_div.act, + s_lsf_div.detail, (unsigned long long)s_lsf_div.segments_checked, + (unsigned long long)s_lsf_div.skipped_irq, + (unsigned long long)s_lsf_div.skipped_overflow, + (unsigned long long)s_lsf_div.skipped_unhandled, + (unsigned long long)s_lsf_div.skipped_conflict, + (unsigned long long)s_lsf_div.skipped_disabled, + s_lsf_frame_lo, s_lsf_frame_hi, s_lsf_div.trace_ops, + s_lsf_div.replay_ops, s_lsf_div.steps, s_lsf_div_idx); + for (int i = 0; i < s_lsf_div_trace_n && p < buflen - 48; i++) { + p += snprintf(buf + p, buflen - p, "%s\"%c%d:%08X=%08X\"", + i ? "," : "", s_lsf_div_trace[i].is_write ? 'W' : 'R', + s_lsf_div_trace[i].size, s_lsf_div_trace[i].addr, + s_lsf_div_trace[i].val); + } + p += snprintf(buf + p, buflen - p, "]}"); + return p; +} diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 12f59ef8..fff888bf 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -3098,6 +3098,26 @@ void gpu_write_gp1(uint32_t val) { X(vram_write_col) X(vram_write_row) X(vram_write_remaining) \ X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) \ X(vram_read_col) X(vram_read_row) +#define GPU_COSIM_SNAP_FIELDS(X) \ + X(texpage_x) X(texpage_y) X(semi_transparency) X(texpage_colors) \ + X(dither_enabled) X(draw_to_display) X(texture_disable) X(texture_window_value) \ + X(set_mask_bit) X(check_mask_bit) \ + X(interlace_field) X(reverse_flag) \ + X(draw_area_left) X(draw_area_top) X(draw_area_right) X(draw_area_bottom) \ + X(draw_offset_x) X(draw_offset_y) \ + X(hres1) X(hres2) X(vres) X(video_mode) X(display_depth) X(vertical_interlace) \ + X(display_disabled) X(irq1_flag) X(dma_direction) X(lcf) \ + X(display_area_x) X(display_area_y) \ + X(h_display_x1) X(h_display_x2) X(v_display_y1) X(v_display_y2) \ + X(gpuread_latch) \ + X(gp0_state) \ + X(gp0_cmd_buf) X(gp0_words_collected) X(gp0_words_needed) \ + X(polyline_color) X(polyline_prev_x) X(polyline_prev_y) X(polyline_prev_c) \ + X(polyline_semi_trans) X(polyline_has_prev) \ + X(vram_write_x) X(vram_write_y) X(vram_write_w) X(vram_write_h) \ + X(vram_write_col) X(vram_write_row) X(vram_write_remaining) \ + X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) \ + X(vram_read_col) X(vram_read_row) uint32_t gpu_snapshot_bytes(void){ uint32_t n=0; #define X(f) n += (uint32_t)sizeof(f); GPU_SNAP_FIELDS(X) @@ -3108,6 +3128,55 @@ void gpu_snapshot_write(uint8_t *p){ GPU_SNAP_FIELDS(X) #undef X } +uint32_t gpu_cosim_snapshot_bytes(void){ uint32_t n=0; +#define X(f) n += (uint32_t)sizeof(f); + GPU_COSIM_SNAP_FIELDS(X) +#undef X + return n; } +void gpu_cosim_snapshot_write(uint8_t *p){ +#define X(f) memcpy(p,&(f),sizeof(f)); p+=sizeof(f); + GPU_COSIM_SNAP_FIELDS(X) +#undef X +} +void gpu_cosim_dump(char *out, int cap) { + if (!out || cap <= 0) return; + char *p = out; + size_t rem = (size_t)cap; +#define APPEND(fmt, ...) do { \ + if (rem <= 1) break; \ + int w = snprintf(p, rem, fmt, __VA_ARGS__); \ + if (w < 0) { out[0] = 0; return; } \ + if ((size_t)w >= rem) { p += rem - 1; rem = 1; } \ + else { p += (size_t)w; rem -= (size_t)w; } \ + } while (0) +#define X(f) APPEND(" %s %llx", #f, (unsigned long long)(uint32_t)(f)); + X(texpage_x) X(texpage_y) X(semi_transparency) X(texpage_colors) + X(dither_enabled) X(draw_to_display) X(texture_disable) X(texture_window_value) + X(set_mask_bit) X(check_mask_bit) + X(interlace_field) X(reverse_flag) + X(draw_area_left) X(draw_area_top) X(draw_area_right) X(draw_area_bottom) + X(draw_offset_x) X(draw_offset_y) + X(hres1) X(hres2) X(vres) X(video_mode) X(display_depth) X(vertical_interlace) + X(display_disabled) X(irq1_flag) X(dma_direction) X(lcf) + X(display_area_x) X(display_area_y) + X(h_display_x1) X(h_display_x2) X(v_display_y1) X(v_display_y2) + X(gpuread_latch) + X(gp0_state) + for (int i = 0; i < 16; i++) { + APPEND(" gp0_cmd_buf%d %08x", i, gp0_cmd_buf[i]); + } + X(gp0_words_collected) X(gp0_words_needed) + X(gp0_next_source_addr) X(gp0_cmd_source_addr) + X(polyline_color) X(polyline_prev_x) X(polyline_prev_y) X(polyline_prev_c) + X(polyline_semi_trans) X(polyline_has_prev) + X(vram_write_x) X(vram_write_y) X(vram_write_w) X(vram_write_h) + X(vram_write_col) X(vram_write_row) X(vram_write_remaining) + X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) + X(vram_read_col) X(vram_read_row) +#undef X + APPEND("%s", "\n"); +#undef APPEND +} int gpu_snapshot_read(const uint8_t *p, uint32_t len){ if(len!=gpu_snapshot_bytes()) return 0; #define X(f) memcpy(&(f),p,sizeof(f)); p+=sizeof(f); GPU_SNAP_FIELDS(X) diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 3b0b7039..9bc19765 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -902,6 +902,10 @@ static void gte_load_data(PSXRecomp::GTE::GTEState& gte, CPUState* cpu) { gte_mtc2(>e, i, cpu->gte_data[i]); } for (int i = 0; i < 32; i++) gte_ctc2(>e, i, cpu->gte_ctrl[i]); + /* Loading emulator state is not a guest CTC2 write. CTC2 to FLAG masks + * bit 31, but a previously computed FLAG error bit must survive reads and + * unrelated control-register writes. */ + gte.FLAG = cpu->gte_ctrl[31]; } extern "C" uint32_t gte_read_data(CPUState* cpu, uint8_t reg) { diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index 5c14252b..df7b62b4 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -34,6 +34,7 @@ #include "cpu_state.h" #include "debug_server.h" #include "event_ring.h" +#include "lockstep.h" #include "psx_cycles.h" #include #include @@ -43,6 +44,7 @@ * static BIOS exception handler is NOT interp code, so we clear it around the * handler dispatch (see psx_check_interrupts). */ extern int g_dirty_interp_active; +extern uint32_t g_dirty_safe_resume_pc; /* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt; dumped via `irqctx_ring`). */ #define IRQCTX_RING_CAP 64u @@ -102,16 +104,218 @@ void psx_irq_raise(uint32_t bit, uint32_t detail) /* Dispatch counter for vblank scheduling. */ #define VBLANK_INTERVAL 50000 /* legacy: dispatch-count fallback (unused for VBlank gating now) */ #define VBLANK_CYCLES 564480u /* 33.8688 MHz / 60 Hz — real PSX NTSC VBlank period */ +#define VBLANK_DEFER_STALE_CYCLES (VBLANK_CYCLES * 10ull) static uint32_t dispatch_count; static uint64_t total_checks; static uint32_t cycles_since_vblank; /* incremented by interrupts_advance_cycles */ - -void interrupts_advance_cycles(uint32_t cycles) { - cycles_since_vblank += cycles; -} +extern uint64_t g_vblank_raise_count; /* Reentrancy guard: prevent interrupt handler from triggering interrupts. */ static int in_exception; +static int post_exception_cooldown; + +static uint32_t last_sio_seq_seen; +static uint64_t last_sio_progress_cycle; + +#ifdef PSX_COSIM +#define COSIM_IRQ_RING_CAP 4096u +typedef struct { + uint64_t seq; + uint64_t cycle; + uint32_t kind; + uint32_t cpu_pc; + uint32_t take_pc; + uint32_t dirty_resume_pc; + uint32_t compiled_resume_pc; + uint32_t epc; + uint32_t sr_before; + uint32_t sr_after; + uint32_t cause; + uint32_t istat; + uint32_t imask; + uint32_t func; + uint32_t block; + uint32_t native; + int32_t cooldown; + int32_t dirty_site; +} CosimIrqEntry; +static CosimIrqEntry s_cosim_irq_ring[COSIM_IRQ_RING_CAP]; +static uint64_t s_cosim_irq_seq; +extern int g_cosim_dirty_pump_site; +extern uint32_t g_debug_current_func_addr; +extern uint32_t cosim_last_block(void); +extern uint32_t overlay_loader_get_inprogress(void); + +static void cosim_irq_note(CPUState *cpu, + uint32_t kind, + uint32_t take_pc, + uint32_t dirty_resume_pc, + uint32_t compiled_resume_pc, + uint32_t sr_before) +{ + CosimIrqEntry *e = &s_cosim_irq_ring[s_cosim_irq_seq & (COSIM_IRQ_RING_CAP - 1u)]; + e->seq = s_cosim_irq_seq++; + e->cycle = psx_get_cycle_count(); + e->kind = kind; + e->cpu_pc = cpu ? cpu->pc : 0; + e->take_pc = take_pc; + e->dirty_resume_pc = dirty_resume_pc; + e->compiled_resume_pc = compiled_resume_pc; + e->epc = cpu ? cpu->cop0[COP0_EPC] : 0; + e->sr_before = sr_before; + e->sr_after = cpu ? cpu->cop0[COP0_SR] : 0; + e->cause = cpu ? cpu->cop0[COP0_CAUSE] : 0; + e->istat = i_stat; + e->imask = i_mask; + e->func = g_debug_current_func_addr; + e->block = cosim_last_block(); + e->native = overlay_loader_get_inprogress(); + e->cooldown = post_exception_cooldown; + e->dirty_site = g_cosim_dirty_pump_site; +} + +void interrupts_cosim_irq_dump(char *out, int cap) +{ + if (!out || cap <= 0) return; + char *p = out; + size_t rem = (size_t)cap; + uint64_t total = s_cosim_irq_seq < COSIM_IRQ_RING_CAP ? s_cosim_irq_seq : COSIM_IRQ_RING_CAP; + uint64_t count = total; + if (count > 16u) count = 16u; + int w = snprintf(p, rem, "irqtrace count %llu", + (unsigned long long)count); + if (w < 0 || (size_t)w >= rem) { out[cap - 1] = 0; return; } + p += w; rem -= (size_t)w; + for (uint64_t i = 0; i < count && rem > 1; i++) { + uint64_t seq = s_cosim_irq_seq - count + i; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + w = snprintf(p, rem, + " ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + e->kind, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + } + w = snprintf(p, rem, " | deliveries"); + if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; } + uint64_t emitted = 0; + for (uint64_t scanned = 0; scanned < total && emitted < 16u && rem > 1; scanned++) { + uint64_t seq = s_cosim_irq_seq - 1u - scanned; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + if (e->kind != 1u) continue; + w = snprintf(p, rem, + " ; seq %llu cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + emitted++; + } + uint64_t latest_delivery = UINT64_MAX; + for (uint64_t scanned = 0; scanned < total; scanned++) { + uint64_t seq = s_cosim_irq_seq - 1u - scanned; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + if (e->kind == 1u) { latest_delivery = e->seq; break; } + } + if (latest_delivery != UINT64_MAX && rem > 1) { + w = snprintf(p, rem, " | pre_delivery"); + if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; } + uint64_t first = (latest_delivery >= 15u) ? (latest_delivery - 15u) : 0u; + for (uint64_t seq = first; seq <= latest_delivery && rem > 1; seq++) { + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + w = snprintf(p, rem, + " ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + e->kind, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + } + } + if (rem >= 2) { + p[0] = '\n'; + p[1] = 0; + } else if (cap == 1) { + out[0] = 0; + } else { + out[cap - 2] = '\n'; + out[cap - 1] = 0; + } +} +#endif + +static void note_sio_progress_cycle(void) { + uint32_t cur_sio_seq = sio_get_seq(); + if (cur_sio_seq != last_sio_seq_seen) { + last_sio_seq_seen = cur_sio_seq; + last_sio_progress_cycle = psx_get_cycle_count(); + } +} + +static int should_defer_vblank_for_sio(void) { + if (!sio_card_protocol_active()) return 0; + uint64_t now = psx_get_cycle_count(); + uint64_t since_progress = now >= last_sio_progress_cycle + ? now - last_sio_progress_cycle + : 0; + return since_progress < VBLANK_DEFER_STALE_CYCLES; +} + +static void fire_vblank_edge(void) { + /* Subtract one VBlank period rather than reset to 0 so cycle overshoot + * carries forward. Prevents long-running blocks from rounding multiple + * VBlanks together. */ + cycles_since_vblank -= VBLANK_CYCLES; + dispatch_count = 0; + /* DEQUEUE: this VBlank fired. ENQUEUE: next VBlank scheduled one period out. */ + event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, + (uint32_t)psx_get_cycle_count()); + event_ring_record_aux(EV_ENQ, (uint8_t)SRC_VBLANK, + (uint32_t)(psx_get_cycle_count() + VBLANK_CYCLES)); + psx_irq_raise(IRQ_VBLANK, 0); + g_vblank_raise_count++; + event_ring_record(EV_ISTAT_RAISE, IRQ_VBLANK); + gpu_vblank_tick(); /* Toggle LCF (GPUSTAT bit 31) */ +#ifndef PSX_ENABLE_BLOCK_CYCLES + timers_tick(33868); /* ~1 NTSC frame worth of cycles */ + cdrom_tick(); /* Process pending CDROM responses */ +#endif +} + +void interrupts_service_scheduled_events(void) { + note_sio_progress_cycle(); + if (in_exception) return; + while (cycles_since_vblank >= VBLANK_CYCLES) { + if (should_defer_vblank_for_sio()) return; + fire_vblank_edge(); + } +} + +uint32_t interrupts_cycles_to_vblank(void) { + if (cycles_since_vblank >= VBLANK_CYCLES) return 0; + return VBLANK_CYCLES - cycles_since_vblank; +} + +void interrupts_advance_cycles(uint32_t cycles) { + cycles_since_vblank += cycles; + interrupts_service_scheduled_events(); +} /* Diagnostic: total times the exception handler was entered (in_exception * transitioned 0->1). Diff this between two snapshots to measure handler @@ -131,8 +335,6 @@ static uint64_t exception_reentry_blocks; * pending interrupt can re-fire. Without this, unhandled interrupts cause * a livelock: the handler runs, doesn't clear I_STAT, returns, and the * very next psx_check_interrupts re-enters immediately. */ -static int post_exception_cooldown; - /* IRQ raise/deliver/ack telemetry (Tomba 2 exception-reentry-storm diagnosis). * Always-on, surfaced in the freeze heartbeat. Distinguishes: * raise≈deliver≈ack≈1/frame → healthy @@ -181,9 +383,38 @@ extern int g_psx_dispatch_depth; * the synchronous host window, dirty_ram_dispatch can resume at this guest PC * instead of treating the sentinel as pc=0 termination. */ static uint32_t s_compiled_interrupt_resume_pc = 0; +static uint32_t s_last_interrupt_check_pc = 0; +static uint64_t s_last_interrupt_check_cycle = UINT64_MAX; + +static int same_guest_pc(uint32_t a, uint32_t b) { + return (((a ^ b) & 0x1FFFFFFFu) == 0); +} int psx_get_in_exception(void) { return in_exception; } +/* Co-sim (COSIM_ORACLE.md): fold the GENUINE guest-timing interrupt statics into the + * state hash. Deliberately EXCLUDES total_checks / dispatch_count / post_exception_ + * cooldown / s_compiled_interrupt_resume_pc — those are counted in psx_check_interrupts + * CALLS or are backend-internal, so they legitimately differ between the compiled and + * interp backends by call frequency and would be a false first-divergence. The one + * quantity that is a real guest-cycle timing value is cycles_since_vblank (drives the + * VBLANK deadline); in_exception mirrors the architectural handler-active state. */ +uint64_t interrupts_cosim_hash(uint64_t h) { + const uint64_t P = 1099511628211ULL; + uint32_t v = cycles_since_vblank; + for (int i = 0; i < 4; i++) { h ^= (uint8_t)(v >> (i*8)); h *= P; } + uint64_t sp = last_sio_progress_cycle; + for (int i = 0; i < 8; i++) { h ^= (uint8_t)(sp >> (i*8)); h *= P; } + h ^= (uint8_t)in_exception; h *= P; + return h; +} +/* Co-sim field dump: expose the exact irqctl-timing fields so a divergence can be + * field-diffed (is it cycles_since_vblank = VBLANK-raise-timing, or in_exception, ...). */ +void interrupts_cosim_dump(uint32_t *csv, int *inexc) { + if (csv) *csv = cycles_since_vblank; + if (inexc) *inexc = in_exception; +} + /* ===== Fix B: faithful exception-return escape state (see psx_runtime.h) ===== */ int g_rfe_escape_pending = 0; int g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; @@ -246,6 +477,9 @@ void interrupts_init(void) { post_exception_cooldown = 0; exception_entries_total = 0; exception_reentry_blocks = 0; + cycles_since_vblank = 0; + last_sio_seq_seen = sio_get_seq(); + last_sio_progress_cycle = psx_get_cycle_count(); } /* @@ -325,6 +559,20 @@ uint32_t cycles_to_next_event(void) { } void psx_check_interrupts(CPUState* cpu) { + extern int g_ls_suppress_record; +#define PSX_CHECK_INTERRUPTS_RETURN() do { if (g_ls_suppress_record > 0) g_ls_suppress_record--; return; } while (0) +#ifdef PSX_COSIM + extern uint32_t g_dirty_safe_resume_pc; +#define COSIM_IRQ_TAKE_PC() (g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc) +#define COSIM_IRQ_NOTE(kind_) cosim_irq_note(cpu, (kind_), COSIM_IRQ_TAKE_PC(), g_dirty_safe_resume_pc, s_compiled_interrupt_resume_pc, cpu->cop0[COP0_SR]) +#endif + { + uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + s_last_interrupt_check_pc = check_pc; + s_last_interrupt_check_cycle = psx_get_cycle_count(); + } + g_ls_suppress_record++; total_checks++; if ((total_checks & 0x3FFFu) == 0) { debug_server_poll(); @@ -338,25 +586,9 @@ void psx_check_interrupts(CPUState* cpu) { * during the delay loop BEFORE the clear, and the BIOS never * sees it. */ - /* VBlank / timer tick — only when NOT inside the exception handler. - * - * On real hardware, VBlank is a hardware signal tied to the CRT - * scanline counter, not to instruction count. The handler runs - * for a few hundred cycles at most. In our model, the handler - * runs thousands of block leaders (each calling psx_check_interrupts), - * so dispatch_count can exceed VBLANK_INTERVAL during a single - * handler invocation, causing VBlanks to stack up and starve the - * main code. Gating the tick on !in_exception prevents this. */ - /* Track SIO byte progress at every check (not just at VBlank time). */ - static uint32_t last_sio_seq_seen = 0; - static uint64_t total_checks_at_progress = 0; - { - uint32_t cur_sio_seq = sio_get_seq(); - if (cur_sio_seq != last_sio_seq_seen) { - last_sio_seq_seen = cur_sio_seq; - total_checks_at_progress = total_checks; - } - } + interrupts_service_scheduled_events(); + + /* Dispatch-loop maintenance only when NOT inside the exception handler. */ if (!in_exception) { #if SIO_MODEL_CYCLE_PACED /* Builds without block-cycle accounting need a small dispatch-loop @@ -371,50 +603,6 @@ void psx_check_interrupts(CPUState* cpu) { #endif #endif dispatch_count++; - /* VBlank pacing is now cycle-based (real PSX has VBlank at - * VBLANK_CYCLES = 564480 = 33.8688 MHz / 60 Hz). Previously this - * used dispatch_count >= 50000 which fired at ~5-6 cycles per - * dispatch = ~300k cycles per VBlank — half of real PSX, so - * guest time ran at ~60% real rate. That broke FMV pacing - * (TombaRecomp ISSUES.md #4: FMV at 6.7 fps vs 15 fps target). */ - if (cycles_since_vblank >= VBLANK_CYCLES) { - /* Defer VBlank while a card SIO transaction is mid-flight. - * On real hardware a 140-byte sector read finishes (~4.5ms) - * well before the next VBlank (16.67ms). Our dispatch-count - * pacing fires VBlank during the read window, and the BIOS - * VBlank pad poll injects 0x01 onto the SIO bus mid-card - * read, aborting the transaction via CTRL.RESET. - * - * Defer as long as the card protocol is making progress - * (an SIO byte was exchanged in the last VBLANK_DEFER_STALE - * dispatch ticks). If no byte for that long, treat the - * protocol as stuck and force the VBlank to prevent deadlock. */ - #define VBLANK_DEFER_STALE 500000 /* ~10 frames; covers inter-byte gaps in card protocol */ - int card_active = sio_card_protocol_active(); - uint64_t since_progress = total_checks - total_checks_at_progress; - int progress_stale = since_progress >= VBLANK_DEFER_STALE; - if (!card_active || progress_stale) { - /* Subtract one VBlank period rather than reset to 0 so - * cycle overshoot carries forward. Prevents long-running - * blocks from rounding multiple VBlanks together. */ - cycles_since_vblank -= VBLANK_CYCLES; - dispatch_count = 0; - /* DEQUEUE: this VBlank fired. ENQUEUE: next VBlank scheduled - * one period out. */ - event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, - (uint32_t)psx_get_cycle_count()); - event_ring_record_aux(EV_ENQ, (uint8_t)SRC_VBLANK, - (uint32_t)(psx_get_cycle_count() + VBLANK_CYCLES)); - psx_irq_raise(IRQ_VBLANK, 0); - g_vblank_raise_count++; - event_ring_record(EV_ISTAT_RAISE, IRQ_VBLANK); - gpu_vblank_tick(); /* Toggle LCF (GPUSTAT bit 31) */ -#ifndef PSX_ENABLE_BLOCK_CYCLES - timers_tick(33868); /* ~1 NTSC frame worth of cycles */ - cdrom_tick(); /* Process pending CDROM responses */ -#endif - } - } } /* Event ring: generic i_stat-edge backstop. Catches raises from sites we @@ -429,24 +617,42 @@ void psx_check_interrupts(CPUState* cpu) { } /* Check if any interrupts are pending. */ - if ((i_stat & i_mask) == 0) { irq_record_outcome(EV_NONE, 0, 0); return; } + if ((i_stat & i_mask) == 0) { irq_record_outcome(EV_NONE, 0, 0); PSX_CHECK_INTERRUPTS_RETURN(); } if (in_exception) { exception_reentry_blocks++; irq_record_outcome(EV_IRQ_GATE, GATE_IN_EXCEPTION, 0); - return; +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(2u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); } /* Post-exception cooldown: let at least one block execute after RFE. */ if (post_exception_cooldown > 0) { post_exception_cooldown--; irq_record_outcome(EV_IRQ_GATE, GATE_COOLDOWN, 0); - return; +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(3u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); } /* Check COP0 SR: IEc (bit 0) must be set, and IM2 (bit 10) must be set. */ uint32_t sr = cpu->cop0[COP0_SR]; - if (!(sr & 0x01)) { irq_record_outcome(EV_IRQ_GATE, GATE_SR_IE, 0); return; } /* Interrupts globally disabled */ - if (!(sr & (1 << 10))) { irq_record_outcome(EV_IRQ_GATE, GATE_SR_IM2, 0); return; } /* Hardware interrupt bit not enabled */ + if (!(sr & 0x01)) { + irq_record_outcome(EV_IRQ_GATE, GATE_SR_IE, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(4u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } /* Interrupts globally disabled */ + if (!(sr & (1 << 10))) { + irq_record_outcome(EV_IRQ_GATE, GATE_SR_IM2, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(5u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } /* Hardware interrupt bit not enabled */ /* Architectural take-PC = the resume PC (same selection the async-RFE block * below uses): the dirty-interp commits the exact interrupted instruction in @@ -481,6 +687,7 @@ void psx_check_interrupts(CPUState* cpu) { }; g_irqctx_seq++; } + ls_note_exception_entry(); in_exception = 1; exception_entries_total++; uint32_t pre_handler_istat = i_stat; /* snapshot for cooldown decision */ @@ -516,7 +723,6 @@ void psx_check_interrupts(CPUState* cpu) { * is no mid-function resume PC, so fall back to the legacy sentinel + saved_gpr path * for THIS delivery only (reason = LEGACY_SENTINEL gates the saved_gpr restore and * disarms the RFE-flag escape). */ - extern uint32_t g_dirty_safe_resume_pc; g_rfe_escape_pending = 0; { uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc @@ -533,6 +739,10 @@ void psx_check_interrupts(CPUState* cpu) { g_exception_real_epc = sentinel; g_exc_escape_reason = PSX_EXC_ESCAPE_LEGACY_SENTINEL; } +#ifdef PSX_COSIM + cosim_irq_note(cpu, 1u, real_pc, g_dirty_safe_resume_pc, + s_compiled_interrupt_resume_pc, sr); +#endif } /* Save the interrupted code's full register state. @@ -701,6 +911,12 @@ void psx_check_interrupts(CPUState* cpu) { uint32_t sio_active = (claimed | (i_stat & i_mask)) & (1u << IRQ_SIO0); post_exception_cooldown = sio_active ? 0 : CLAIMED_PROGRESS_QUANTUM; } + if (g_ls_suppress_record > 0) g_ls_suppress_record--; +#ifdef PSX_COSIM +#undef COSIM_IRQ_NOTE +#undef COSIM_IRQ_TAKE_PC +#endif +#undef PSX_CHECK_INTERRUPTS_RETURN } /* Compatibility shim: the ape-flavored generated code calls @@ -714,3 +930,15 @@ void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc) { psx_check_interrupts(cpu); s_compiled_interrupt_resume_pc = prev; } + +int psx_interrupts_checked_at_current_cycle(uint32_t resume_pc) { + return s_last_interrupt_check_cycle == psx_get_cycle_count() && + same_guest_pc(s_last_interrupt_check_pc, resume_pc); +} + +void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc) { + if (psx_interrupts_checked_at_current_cycle(resume_pc)) { + return; + } + psx_check_interrupts_at(cpu, resume_pc); +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 7166f866..b0b97fc9 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -94,6 +94,9 @@ extern "C" void timers_init(void); /* interrupts.c */ extern "C" void interrupts_init(void); +#ifdef PSX_COSIM +extern "C" void cosim_init(void); /* first-divergence oracle server (cosim.c) */ +#endif extern "C" uint32_t psx_read_word(uint32_t addr); extern "C" void psx_write_word(uint32_t addr, uint32_t val); extern "C" uint16_t psx_read_half(uint32_t addr); @@ -143,6 +146,7 @@ static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitr static int g_video_win_w = 1280; /* window width (height follows aspect) */ static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */ static int g_auto_skip_fmv = 0; /* skip FMVs the instant they're detected */ +static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ /* FMV instant-skip via the game's OWN end-of-movie path. Tomba's MDEC player * (FUN_8001efe8) tears a movie down when the streamed frame number reaches that * movie's per-movie total minus 3; writing the current movie's total down to @@ -1293,6 +1297,15 @@ static void sample_pad_into_sio(int override) { } } +static void sample_headless_pad_into_sio(int override) { + if (override >= 0) { + sio_set_pad_state_slot(0, (uint16_t)override); + return; + } + sio_set_pad_state_slot(0, 0xFFFFu); + sio_set_pad_state_slot(1, 0xFFFFu); +} + /* PSX native vblank cadence: NTSC ≈ 59.94 Hz. Wall-clock target keeps * audio sample generation (735 samples/vblank * 60 = 44100/sec) matched * to the SDL audio device drain rate, eliminating queue overflow drops @@ -1403,33 +1416,35 @@ static void sdl_vblank_present(void) { if (cp->poll_main) cp->poll_main(); } - /* Pump SDL events to prevent window freeze. */ - SDL_Event ev; - while (SDL_PollEvent(&ev)) { - if (ev.type == SDL_QUIT) { - psx_crash_trace_set_exit_origin("sdl_window_close"); - shutdown_runtime(); - std::exit(0); - } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { - refresh_player_devices(); - } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { - if (ev.cdevice.which == g_players[0].instance || - ev.cdevice.which == g_players[1].instance) { - close_controller(); + if (!g_headless) { + /* Pump SDL events to prevent window freeze. */ + SDL_Event ev; + while (SDL_PollEvent(&ev)) { + if (ev.type == SDL_QUIT) { + psx_crash_trace_set_exit_origin("sdl_window_close"); + shutdown_runtime(); + std::exit(0); + } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { refresh_player_devices(); - } - } else if (ev.type == SDL_KEYDOWN) { - /* Fullscreen toggle: F11, Alt+Enter, or Cmd/Ctrl+F. - * FULLSCREEN_DESKTOP keeps the desktop resolution; the - * renderer's logical size letterboxes the 640x480 image. */ - const Uint16 mod = ev.key.keysym.mod; - if (ev.key.keysym.sym == SDLK_F11 || - (ev.key.keysym.sym == SDLK_RETURN && (mod & KMOD_ALT)) || - (ev.key.keysym.sym == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { - Uint32 is_fs = SDL_GetWindowFlags(sdl_window) & - SDL_WINDOW_FULLSCREEN_DESKTOP; - SDL_SetWindowFullscreen(sdl_window, - is_fs ? 0 : SDL_WINDOW_FULLSCREEN_DESKTOP); + } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { + if (ev.cdevice.which == g_players[0].instance || + ev.cdevice.which == g_players[1].instance) { + close_controller(); + refresh_player_devices(); + } + } else if (ev.type == SDL_KEYDOWN) { + /* Fullscreen toggle: F11, Alt+Enter, or Cmd/Ctrl+F. + * FULLSCREEN_DESKTOP keeps the desktop resolution; the + * renderer's logical size letterboxes the 640x480 image. */ + const Uint16 mod = ev.key.keysym.mod; + if (ev.key.keysym.sym == SDLK_F11 || + (ev.key.keysym.sym == SDLK_RETURN && (mod & KMOD_ALT)) || + (ev.key.keysym.sym == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { + Uint32 is_fs = SDL_GetWindowFlags(sdl_window) & + SDL_WINDOW_FULLSCREEN_DESKTOP; + SDL_SetWindowFullscreen(sdl_window, + is_fs ? 0 : SDL_WINDOW_FULLSCREEN_DESKTOP); + } } } } @@ -1439,7 +1454,8 @@ static void sdl_vblank_present(void) { * g_low_latency_input this early sample is re-done after the pacer wait * (below) for the interactive present path; it still covers the turbo / * FMV-skip paths that early-return before pacing. */ - sample_pad_into_sio(override); + if (g_headless) sample_headless_pad_into_sio(override); + else sample_pad_into_sio(override); /* Latency ring: open this present cycle's slot, stamping when input was * sampled into SIO. Always-on; queried via the debug server "latency". */ @@ -1516,6 +1532,8 @@ static void sdl_vblank_present(void) { sdl_audio_update(fmv_skip_active); #endif + if (g_headless) return; + /* TCP turbo is for automated validation and trace capture. It keeps the * simulation advancing and the debug server polling, but removes frontend * presentation and wall-clock pacing. */ @@ -1837,6 +1855,7 @@ int main(int argc, char** argv) { * --renderer override the renderer: software|opengl|vulkan * --launcher force the GUI launcher (overrides skip_launcher) * --no-launcher skip the GUI launcher (boot straight in) + * --headless skip SDL window/audio; use TCP screenshots/state * deprecated alias for --bios * No --memcard-dir / --game-root flags: those are config-driven. */ for (int i = 1; i < argc; i++) { @@ -1860,11 +1879,20 @@ int main(int argc, char** argv) { force_launcher = true; } else if (std::strcmp(argv[i], "--no-launcher") == 0) { force_no_launcher = true; + } else if (std::strcmp(argv[i], "--headless") == 0) { + g_headless = 1; + force_no_launcher = true; } else if (argv[i][0] != '-') { bios_path = argv[i]; bios_from_cli = true; } } + if (const char *e = std::getenv("PSX_HEADLESS")) { + if (e[0] && e[0] != '0') { + g_headless = 1; + force_no_launcher = true; + } + } std::string default_game_config_storage; if (!game_config_path) { @@ -2456,6 +2484,9 @@ int main(int argc, char** argv) { debug_server_init(debug_port); #else (void)debug_port; +#endif +#ifdef PSX_COSIM + cosim_init(); /* first-divergence oracle server */ #endif /* Heartbeat always on — see freeze_heartbeat.c rationale. */ freeze_heartbeat_start("psx-runtime"); @@ -2464,6 +2495,9 @@ int main(int argc, char** argv) { if (game_entry_pc != 0) fntrace_set_game_range(game_entry_pc, 0); + if (g_headless) { + std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); + } else { /* ---- SDL init ---- */ /* Scale quality governs SDL's logical-size -> window scaling. Linear when * antialiasing is on so the (super)sampled frame stays smooth when the @@ -2637,6 +2671,7 @@ int main(int argc, char** argv) { } SDL_SetTextureScaleMode(sdl_texture, g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); + } } /* Register vblank presentation callback. */ @@ -2897,9 +2932,13 @@ 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", + extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter; + extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want; + std::fprintf(stdout, "psxrecomp runtime: [slice diag] slice_fired=%llu slice_irq_taken=%llu dirty_insns=%llu exit_pc=0x%08X reason=%u iter=%u dispatchable=%u dirty=%u in_text=%u want=%u\n", (unsigned long long)g_slice_fired, (unsigned long long)g_slice_irq_taken, - (unsigned long long)g_dirty_ram_insns_run); } + (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); } shutdown_runtime(); if (g_gl_active) gl_renderer_shutdown(); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 1e85da67..281147cb 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -728,11 +728,13 @@ static void mmio_write8(uint32_t addr, uint8_t val) { * those device-internal accesses. Interrupt-handler ops are excluded too * (psx_get_in_exception): the replay runs only the block, never the handler. */ static int s_ls_op_active = 0; +extern int g_ls_suppress_record; +extern int g_dma_exec_depth; extern int psx_get_in_exception(void); static uint32_t psx_read_word_raw(uint32_t addr); uint32_t psx_read_word(uint32_t addr) { if (g_ls_mode == 2) return ls_read_hook(addr, 4, 0u); - if (g_ls_mode != 1 || s_ls_op_active) return psx_read_word_raw(addr); + if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_word_raw(addr); s_ls_op_active = 1; uint32_t v = psx_read_word_raw(addr); s_ls_op_active = 0; @@ -817,7 +819,7 @@ static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) { static void psx_write_word_raw(uint32_t addr, uint32_t val); void psx_write_word(uint32_t addr, uint32_t val) { if (g_ls_mode == 2) { ls_write_hook(addr, 4, val); return; } - if (g_ls_mode != 1 || s_ls_op_active) { psx_write_word_raw(addr, val); return; } + if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_word_raw(addr, val); return; } if (!psx_get_in_exception()) ls_write_hook(addr, 4, val); s_ls_op_active = 1; psx_write_word_raw(addr, val); @@ -840,6 +842,9 @@ static void psx_write_word_raw(uint32_t addr, uint32_t val) { card_data_writes_check(phys, val, 4); dirty_ram_mark_kernel_write(phys); overlay_watch_note_write(phys, 4); +#ifdef PSX_COSIM + { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 4); } +#endif ram[phys] = (uint8_t)(val); ram[phys + 1] = (uint8_t)(val >> 8); ram[phys + 2] = (uint8_t)(val >> 16); @@ -876,7 +881,7 @@ static void psx_write_word_raw(uint32_t addr, uint32_t val) { static uint16_t psx_read_half_raw(uint32_t addr); uint16_t psx_read_half(uint32_t addr) { if (g_ls_mode == 2) return (uint16_t)ls_read_hook(addr, 2, 0u); - if (g_ls_mode != 1 || s_ls_op_active) return psx_read_half_raw(addr); + if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_half_raw(addr); s_ls_op_active = 1; uint16_t v = psx_read_half_raw(addr); s_ls_op_active = 0; @@ -908,7 +913,7 @@ static uint16_t psx_read_half_raw(uint32_t addr) { static void psx_write_half_raw(uint32_t addr, uint16_t val); void psx_write_half(uint32_t addr, uint16_t val) { if (g_ls_mode == 2) { ls_write_hook(addr, 2, val); return; } - if (g_ls_mode != 1 || s_ls_op_active) { psx_write_half_raw(addr, val); return; } + if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_half_raw(addr, val); return; } if (!psx_get_in_exception()) ls_write_hook(addr, 2, val); s_ls_op_active = 1; psx_write_half_raw(addr, val); @@ -925,6 +930,9 @@ static void psx_write_half_raw(uint32_t addr, uint16_t val) { card_data_writes_check(phys, (uint32_t)val, 2); dirty_ram_mark_kernel_write(phys); overlay_watch_note_write(phys, 2); +#ifdef PSX_COSIM + { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 2); } +#endif ram[phys] = (uint8_t)(val); ram[phys + 1] = (uint8_t)(val >> 8); return; @@ -952,7 +960,7 @@ static void psx_write_half_raw(uint32_t addr, uint16_t val) { static uint8_t psx_read_byte_raw(uint32_t addr); uint8_t psx_read_byte(uint32_t addr) { if (g_ls_mode == 2) return (uint8_t)ls_read_hook(addr, 1, 0u); - if (g_ls_mode != 1 || s_ls_op_active) return psx_read_byte_raw(addr); + if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_byte_raw(addr); s_ls_op_active = 1; uint8_t v = psx_read_byte_raw(addr); s_ls_op_active = 0; @@ -1115,7 +1123,7 @@ uint8_t psx_guest_read_byte(uint32_t addr) { return psx_read_byte(addr); } static void psx_write_byte_raw(uint32_t addr, uint8_t val); void psx_write_byte(uint32_t addr, uint8_t val) { if (g_ls_mode == 2) { ls_write_hook(addr, 1, val); return; } - if (g_ls_mode != 1 || s_ls_op_active) { psx_write_byte_raw(addr, val); return; } + if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_byte_raw(addr, val); return; } if (!psx_get_in_exception()) ls_write_hook(addr, 1, val); s_ls_op_active = 1; psx_write_byte_raw(addr, val); @@ -1132,6 +1140,9 @@ static void psx_write_byte_raw(uint32_t addr, uint8_t val) { card_data_writes_check(phys, (uint32_t)val, 1); dirty_ram_mark_kernel_write(phys); overlay_watch_note_write(phys, 1); +#ifdef PSX_COSIM + { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 1); } +#endif ram[phys] = val; return; } diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 663bcb68..ab0695fe 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -8,6 +8,9 @@ #include "sio.h" #include "starvation_ring.h" #include "timers.h" +#ifdef PSX_COSIM +#include "cosim_state.h" +#endif uint64_t psx_cycle_count = 0; @@ -43,13 +46,34 @@ static void advance_devices(uint32_t c) { void psx_advance_cycles(uint32_t cycles) { { extern int g_ls_replay_active; if (g_ls_replay_active) return; } /* lockstep replay: no global cycle/device mutation */ if (cycles == 0) return; - if (g_event_step_conservative && cycles > 1u) { - /* Fine-step so sub-block events fire in true cycle order. */ - for (uint32_t i = 0; i < cycles; i++) advance_devices(1u); - } else { - advance_devices(cycles); + uint32_t charged_cycles = cycles; +#ifdef PSX_COSIM + /* First-divergence oracle: the guest-cycle counter is the ONLY clock both backends + * share identically, so it is the alignment point for the full-state hash. */ + cosim_tick(); +#endif + interrupts_service_scheduled_events(); + while (cycles > 0) { + uint32_t step = cycles; + if (g_event_step_conservative && step > 1u) { + step = 1u; + } else { + uint32_t to_vblank = interrupts_cycles_to_vblank(); + if (to_vblank > 0 && to_vblank < step) step = to_vblank; +#ifdef PSX_COSIM + uint32_t to_cp = cosim_cycles_to_next_checkpoint(); + if (to_cp > 0 && to_cp < step) step = to_cp; +#endif + } + if (step == 0) step = cycles; + advance_devices(step); + cycles -= step; + interrupts_service_scheduled_events(); +#ifdef PSX_COSIM + cosim_tick(); +#endif } - s_watchdog_throttle += cycles; + s_watchdog_throttle += charged_cycles; if (s_watchdog_throttle >= 65536u) { s_watchdog_throttle = 0; starvation_watchdog_check(); @@ -58,7 +82,7 @@ void psx_advance_cycles(uint32_t cycles) { * localize a busy-wait loop, sparse enough to not flood the 16K ring * during normal SIO traffic (~3000 samples/sec vs >10K SIO events/sec * during card transactions). */ - s_pc_sample_throttle += cycles; + s_pc_sample_throttle += charged_cycles; if (s_pc_sample_throttle >= 1048576u) { s_pc_sample_throttle = 0; starvation_ring_pc_sample(); diff --git a/tools/cosim.py b/tools/cosim.py new file mode 100644 index 00000000..d30e9b8d --- /dev/null +++ b/tools/cosim.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""cosim.py — first-divergence co-simulation coordinator. See COSIM_ORACLE.md. + +Launches two clean psx-cosim instances (each its own complete deterministic PSX), +advances BOTH to the same guest-cycle checkpoints, and compares their full-state chain +hashes. The first checkpoint whose chains differ brackets the first divergence; the +per-subsystem `sub` hashes + the ring `window` say WHAT and WHERE. + +The two instances are NOT interleaved on one timeline — they are two independent +deterministic runs, sampled at matched guest cycles. Validity rests ENTIRELY on +determinism, which is why you MUST pass the gates first: + + # GATE 1 — determinism/hashing: two of the SAME backend must NEVER diverge. + python cosim.py --a compiled --b compiled --stride 65536 --max 1500000000 + python cosim.py --a interp --b interp --stride 65536 --max 1500000000 + # GATE 4 — injected fault must halt at the right field: + python cosim.py --a compiled --b compiled --inject-at 200000000 --inject ram:100000:1 + + # THE RUN (only after gates pass): + python cosim.py --a compiled --b interp --stride 65536 --max 1500000000 +""" +import socket, subprocess, os, sys, time, argparse + +EXE = r"F:\Projects\psxrecomp\MegaManX6Recomp\build-cosim\psx-cosim.exe" +CWD = r"F:\Projects\psxrecomp\MegaManX6Recomp\build-cosim" +GAME = r"F:\Projects\psxrecomp\MegaManX6Recomp\game.toml" +LOGDIR = os.path.join(CWD, "cosim-logs") + +def tail_file(path, max_bytes=8192): + try: + with open(path, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(max(0, size - max_bytes), os.SEEK_SET) + return f.read().decode(errors="replace") + except Exception as e: + return f"" + +def launch(mode, port, stride, start_cycle): + os.makedirs(LOGDIR, exist_ok=True) + env = dict(os.environ) + env["PSX_HEADLESS"] = "1" + env["PSX_COSIM_PORT"] = str(port) + env["PSX_COSIM_STRIDE"] = str(stride) + # The starvation watchdog is driven by debug-server heartbeats, while cosim + # can spend seconds fast-forwarding without polling that server. Treat it as + # a GUI/debugger watchdog, not a cosim oracle signal. + env.setdefault("PSX_STARVATION_TIMEOUT_US", "0") + if start_cycle: + env["PSX_COSIM_START_CYCLE"] = str(start_cycle) + if mode == "interp": + env["PSX_FORCE_INTERP"] = "1" + log_path = os.path.join(LOGDIR, f"cosim_{mode}_{port}_{os.getpid()}.log") + log_file = open(log_path, "wb") + p = subprocess.Popen([EXE, "--headless", "--no-launcher", "--game", GAME], + cwd=CWD, env=env, + stdout=log_file, stderr=subprocess.STDOUT, + creationflags=0x00000200) + p._cosim_log_path = log_path + p._cosim_log_file = log_file + if os.environ.get("PSX_COSIM_BELOW_NORMAL") == "1": + try: + import ctypes + h = ctypes.windll.kernel32.OpenProcess(0x0400, False, p.pid) + ctypes.windll.kernel32.SetPriorityClass(h, 0x00004000) # BELOW_NORMAL + except Exception: + pass + return p + +def connect(port, timeout=40): + t0 = time.time() + while time.time() - t0 < timeout: + try: + s = socket.socket(); s.settimeout(5); s.connect(("127.0.0.1", port)) + return s + except Exception: + time.sleep(0.5) + raise RuntimeError(f"cosim port {port} never came up") + +def cmd(s, line, timeout=600): + s.settimeout(timeout) + s.sendall((line + "\n").encode()) + buf = b"" + while b"\n" not in buf: + d = s.recv(65536) + if not d: break + buf += d + return buf.decode(errors="replace").strip() + +def close_proc_log(p): + try: + p._cosim_log_file.close() + except Exception: + pass + +def report_child_failure(label, proc, cp, exc): + code = proc.poll() + print(f"[{label} reset] at local cp {cp}: poll={code} error={exc}", flush=True) + path = getattr(proc, "_cosim_log_path", "") + if path: + print(f"--- {label} log tail: {path} ---", flush=True) + print(tail_file(path), flush=True) + +def wait_parked(sa, sb, timeout=600): + t0 = time.time() + last = ("", "") + while time.time() - t0 < timeout: + ra = kv(cmd(sa, "status", timeout=120)) + rb = kv(cmd(sb, "status", timeout=120)) + last = (ra, rb) + if ra.get("parked") == "1" and rb.get("parked") == "1": + return ra, rb + time.sleep(0.05) + raise RuntimeError(f"cosim did not park before timeout: A={last[0]} B={last[1]}") + +def parse_cpu(resp): + """'pc XX hi XX ... r0 XX ... c0 XX ... mdts XX gtets XX ...' -> {field: val}""" + t = resp.split() + return {t[i]: t[i+1] for i in range(0, len(t)-1, 2)} + +def kv(resp): + """parse a reply into {key: next_token} for every token that is followed by + another token. Robust to a leading bare status word (e.g. 'parked cp N cycle M + chain HEX'): we scan ALL adjacent pairs, so 'chain' -> HEX is always captured + regardless of the leading word's parity. (The earlier stride-2 parser misaligned + on the leading 'parked'/'timeout' word and returned chain=None for BOTH sides, + making every compare None==None == 'equal' — a silent blind spot. Gate 4 exists + to catch exactly this.)""" + t = resp.split() + d = {} + for i in range(len(t) - 1): + d.setdefault(t[i], t[i+1]) + return d + +def dump_window(s, n=24): + s.settimeout(30); s.sendall(f"window {n}\n".encode()); buf = b"" + while b"\nend\n" not in buf and b"\nend" not in (buf[-6:] if len(buf)>=6 else buf): + d = s.recv(65536) + if not d: break + buf += d + if buf.endswith(b"end\n") or buf.endswith(b"end"): break + return buf.decode(errors="replace") + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--a", default="compiled", choices=["compiled", "interp"]) + ap.add_argument("--b", default="interp", choices=["compiled", "interp"]) + ap.add_argument("--stride", type=int, default=65536) + ap.add_argument("--max", type=int, default=1_500_000_000) # ~2650 frames + ap.add_argument("--porta", type=int, default=4600) + ap.add_argument("--portb", type=int, default=4601) + ap.add_argument("--inject-at", type=int, default=0) + ap.add_argument("--inject", default="") # e.g. ram:100000:1 or reg:2:1 + ap.add_argument("--start-cycle", type=int, default=0, + help="free-run to this absolute guest cycle before checkpointing") + ap.add_argument("--cpudiff-at-cp", type=int, default=0, + help="step both to this checkpoint and field-diff the CPU dump") + args = ap.parse_args() + + if args.cpudiff_at_cp: + print(f"launch A={args.a}:{args.porta} B={args.b}:{args.portb} " + f"stride={args.stride} start={args.start_cycle} cpudiff={args.cpudiff_at_cp}", + flush=True) + pa = launch(args.a, args.porta, args.stride, args.start_cycle); pb = launch(args.b, args.portb, args.stride, args.start_cycle) + try: + sa = connect(args.porta); sb = connect(args.portb) + wait_parked(sa, sb) + n = args.cpudiff_at_cp - 1 # both start parked at cp 1 + for _ in range(max(0, n)): + cmd(sa, "step 1", timeout=1200) + cmd(sb, "step 1", timeout=1200) + da = parse_cpu(cmd(sa, "cpu")) + db = parse_cpu(cmd(sb, "cpu")) + print(f"CPU field-diff at cp {args.cpudiff_at_cp} (A={args.a} B={args.b}):", flush=True) + diffs = [k for k in da if da.get(k) != db.get(k)] + if not diffs: + print(" (no CPU field differs)", flush=True) + for k in diffs: + print(f" cpu.{k}: A={da.get(k)} B={db.get(k)}", flush=True) + ga = parse_cpu(cmd(sa, "gte")) + gb = parse_cpu(cmd(sb, "gte")) + print("GTE field-diff:", flush=True) + gdiffs = [k for k in ga if ga.get(k) != gb.get(k)] + if not gdiffs: + print(" (no GTE field differs)", flush=True) + for k in gdiffs: + print(f" gte.{k}: A={ga.get(k)} B={gb.get(k)}", flush=True) + # device-timing fields (behind irqctl/gpu sub-hashes) + va = parse_cpu(cmd(sa, "dev")); vb = parse_cpu(cmd(sb, "dev")) + print("DEV field-diff (irqctl/gpu timing):", flush=True) + ddiffs = [k for k in va if va.get(k) != vb.get(k)] + if not ddiffs: + print(" (no dev field differs)", flush=True) + for k in ddiffs: + print(f" dev.{k}: A={va.get(k)} B={vb.get(k)}", flush=True) + xa = parse_cpu(cmd(sa, "gpu")); xb = parse_cpu(cmd(sb, "gpu")) + print("GPU field-diff:", flush=True) + xdiffs = [k for k in xa if xa.get(k) != xb.get(k)] + if not xdiffs: + print(" (no GPU field differs)", flush=True) + for k in xdiffs: + print(f" gpu.{k}: A={xa.get(k)} B={xb.get(k)}", flush=True) + print("IRQ trace A:", flush=True) + print(" " + cmd(sa, "irqtrace"), flush=True) + print("IRQ trace B:", flush=True) + print(" " + cmd(sb, "irqtrace"), flush=True) + finally: + for p in (pa, pb): + try: + if p.poll() is None: p.terminate() + except Exception: pass + close_proc_log(p) + return + + print(f"launch A={args.a}:{args.porta} B={args.b}:{args.portb} stride={args.stride} start={args.start_cycle}", flush=True) + pa = launch(args.a, args.porta, args.stride, args.start_cycle); pb = launch(args.b, args.portb, args.stride, args.start_cycle) + try: + sa = connect(args.porta); sb = connect(args.portb) + ia, ib = wait_parked(sa, sb) + if args.max <= args.start_cycle: + print(f"--max must be greater than --start-cycle ({args.start_cycle})", flush=True) + return + max_cp = (args.max - args.start_cycle) // args.stride + print(f"both up; stepping {max_cp} checkpoints (stride {args.stride})", flush=True) + print(f"initial park A cycle {ia.get('cycle')} cp {ia.get('cp')} B cycle {ib.get('cycle')} cp {ib.get('cp')}", flush=True) + + # Both guests are parked at checkpoint 1 immediately after launch (deterministic + # cycle boundary). We advance BOTH one checkpoint per iteration and compare the + # cumulative chain hash. First mismatch = first divergence. + injected = not args.inject + cp = 0 + while cp < max_cp: + # Gate-4 injection into B just before the checkpoint that crosses inject_at. + if not injected and args.start_cycle + (cp + 1) * args.stride >= args.inject_at: + kind, a, b = args.inject.split(":") + print(cmd(sb, f"inject {kind} {int(a)} {int(b)}"), flush=True) + print(f"[inject] {args.inject} into B before cp {cp+1}", flush=True) + injected = True + + try: + ra = kv(cmd(sa, "step 1")) + except Exception as e: + report_child_failure("A", pa, cp, e) + return + try: + rb = kv(cmd(sb, "step 1")) + except Exception as e: + report_child_failure("B", pb, cp, e) + return + if pa.poll() is not None or pb.poll() is not None: + print(f"[exit] a={pa.poll()} b={pb.poll()} at cp {cp}", flush=True); break + ca, cb = ra.get("chain"), rb.get("chain") + if ca is None or cb is None: + print(f"[FATAL] could not parse chain — tool is BLIND, aborting.\n" + f" A: {ra}\n B: {rb}", flush=True); return + cyc_a, cyc_b = ra.get("cycle"), rb.get("cycle") + if cyc_a != cyc_b: + print(f"[WARN] cycle skew A={cyc_a} B={cyc_b} at cp {ra.get('cp')} — " + f"the two runs are NOT parking at the same cycle (harness " + f"nondeterminism, not a guest divergence). Investigate before trusting.", + flush=True) + if ca != cb: + print(f"\n*** FIRST DIVERGENCE at checkpoint cp={ra.get('cp')} " + f"cycle~{cyc_a} (frame~{int(cyc_a)//566000}) ***", flush=True) + print(f" A chain={ca} B chain={cb}", flush=True) + print(" --- A subhash ---\n " + cmd(sa, "sub"), flush=True) + print(" --- B subhash ---\n " + cmd(sb, "sub"), flush=True) + print(" (the FIRST subsystem hash that differs is where it split)", flush=True) + print(" --- A window ---\n" + dump_window(sa, 16), flush=True) + print(" --- B window ---\n" + dump_window(sb, 16), flush=True) + return + cp += 1 + if cp % 256 == 0: + print(f" ok cp {cp} cycle {cyc_a} (frame~{int(cyc_a)//566000}) chain {ca}", flush=True) + + print("no divergence within --max (or a process exited).", flush=True) + if args.inject: + print("NOTE: injection run — a clean 'no divergence' means the tool MISSED the " + "fault (gate-4 FAIL); it should have stopped at the injected checkpoint.", flush=True) + finally: + for p in (pa, pb): + try: + if p.poll() is None: p.terminate() + except Exception: + pass + close_proc_log(p) + +if __name__ == "__main__": + main()