fix mmx6 timing divergences with cosim oracle

This commit is contained in:
Matthew Stanley
2026-07-01 09:28:50 -07:00
parent 0f34c29c45
commit 553d9933d1
21 changed files with 2687 additions and 196 deletions
+295
View File
@@ -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 <cycle>, 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 <cycle>` 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 <cyc> --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`.
+188
View File
@@ -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 <cycle>`, `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.
+91 -10
View File
@@ -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<uint32_t, std::vector<uint32_t>> splits_by_func;
std::set<uint32_t> 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<Function> new_funcs;
std::set<uint32_t> 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());
+53 -17
View File
@@ -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<uint8_t>& rom, uint32_t offset) {
return static_cast<uint32_t>(rom[offset + 0])
| (static_cast<uint32_t>(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<int>(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";
+7 -6
View File
@@ -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";
+16 -5
View File
@@ -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<int>(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<int>(rd)));
} else {
r.c_code = gte_read + emit_gpr_write(rt,
fmt::format("cpu->gte_ctrl[{}]", static_cast<int>(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<int>(rd), static_cast<int>(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<int>(rd), static_cast<int>(rt));
} else {
r.c_code = gte_stall + fmt::format(
"cpu->gte_ctrl[{}] = cpu->gpr[{}];",
static_cast<int>(rd), static_cast<int>(rt));
}
r.comment = fmt::format("ctc2 {}, gte_ctrl[{}]", gpr_name(rt), rd);
return r;
}
+67
View File
@@ -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 <stdint.h>
#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
+9 -2
View File
@@ -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
+21
View File
@@ -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
}
+12 -1
View File
@@ -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()
+371
View File
@@ -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 <stdint.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
typedef SOCKET sock_t;
#define COSIM_SLEEP(ms) Sleep(ms)
#else
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <pthread.h>
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 <n> chain <hex> parked <0|1>"
* runto <n> -> set stop=n, resume; blocks until parked at n (or exit)
* chain -> "chain <hex> seq <n>"
* hash -> "hash <hex> pc <hex> istat <hex> imask <hex> cyc <n>"
* sub -> per-subsystem hashes of the current state
* window <n> -> last n ring rows (bounded to RING_N)
* inject ram <phys> <xor> -> gate-4 fault into RAM
* inject reg <idx> <xor> -> 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 <N-checkpoints>; 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 */
+220
View File
@@ -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 <stdlib.h>
#include <string.h>
/* ---- 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 */
+33 -4
View File
@@ -39,6 +39,7 @@
#include "card_data_writes.h"
#include "crash_trace.h"
#include "gpu_gl_renderer.h"
#include "lockstep.h"
#include <stdio.h>
#include <stdlib.h>
@@ -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 },
+523 -34
View File
@@ -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 <stdint.h>
#include <stdlib.h>
@@ -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;
}
+69
View File
@@ -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)
+4
View File
@@ -902,6 +902,10 @@ static void gte_load_data(PSXRecomp::GTE::GTEState& gte, CPUState* cpu) {
gte_mtc2(&gte, i, cpu->gte_data[i]);
}
for (int i = 0; i < 32; i++) gte_ctc2(&gte, 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) {
+303 -75
View File
@@ -34,6 +34,7 @@
#include "cpu_state.h"
#include "debug_server.h"
#include "event_ring.h"
#include "lockstep.h"
#include "psx_cycles.h"
#include <stdio.h>
#include <stdlib.h>
@@ -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);
}
+68 -29
View File
@@ -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 <name> 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
* <positional> 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();
+17 -6
View File
@@ -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;
}
+31 -7
View File
@@ -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();
+289
View File
@@ -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"<could not read {path}: {e}>"
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()