Files
psxrecomp/docs/internal/COSIM_ORACLE.md
Matthew Stanley 55cec09acd docs: retire stale sljit references, attribute TinyCC
The sljit Tier-2 in-process JIT was replaced by the tcc tier in c2a3f6ad
(2026-06-25) and removed outright in 5b7e69b4 (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>
2026-08-16 18:16:34 -07:00

11 KiB
Raw Permalink Blame History

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.

  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

  • COSIM_ORACLE.md (this file).
  • 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).
  • 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.
  • 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.