mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
55cec09acd
The sljit Tier-2 in-process JIT was replaced by the tcc tier inc2a3f6ad(2026-06-25) and removed outright in5b7e69b4(2026-07-15). That removal reworded the C-level comments but touched no markdown, so the prose still described sljit as a live, default-on backend. Backends today are static -> gcc -> tcc: gcc is the development default when it is on PATH, tcc is the toolchain-free path for players. Both are external compilers invoked as a subprocess that produce a DLL; neither is an in-process JIT. docs/ARCHITECTURE.md, docs/BUILDING.md, docs/EXECUTION_MODEL.md, docs/COMPILING_OVERLAYS.md and README.md already said this correctly and are unchanged here. - THIRD_PARTY_ATTRIBUTION.md: the sljit entry claimed code vendored at lib/sljit/ and compiled into the runtime. That directory does not exist. Replaced with a TinyCC entry, which was missing entirely despite tcc being the compiler intended to reach end users. - docs/internal/SLJIT_PERSIST_CACHE.md: deleted. It documented the persisted shard cache of a subsystem that no longer exists, as "implemented" and "default-on". docs/internal/README.md updated to point at the current model instead of a dead link. - docs/ASYNC_OVERLAY_COMPILE.md: superseded-in-part header. Deliberately NOT rewritten sljit->tcc: the passages about serializing .sljit blobs, overlay_sljit_deserialize and an in-memory JIT registry describe a mechanism tcc never used, so renaming it would trade one false claim for another. The async worker/file-handoff design and the CPS work remain accurate. - docs/OVERLAY_CACHE_V2.md: sljit dropped from the dispatch priority chain. - ISSUES.md: Issue #7 closed as superseded — the backend it reported against was deleted rather than fixed. - docs/internal/COSIM_ORACLE.md: sljit_helpers left CPUState with the tier. - Dated session logs (RECURSION_BUG.md 25.5, BACKDROP_PRELOAD.md, ecosystem-watch.md) keep their narrative and get a pointer to the removal. One thing this does not fix: docs assert the tcc toolchain is "downloaded at release-packaging time and bundled beside the game exe". runtime/src/main.cpp does look for <exe_dir>/overlay_toolchain/, but no script in this repository creates it. The attribution entry now states only what the code does and notes that if packaging supplies the bundle, it happens outside this repo and must carry the TinyCC notice. Flagged rather than papered over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
11 KiB
Markdown
189 lines
11 KiB
Markdown
# 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, 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.
|