Commit Graph

149 Commits

Author SHA1 Message Date
Matthew Stanley d8c4a8ec9f timing: faithful R3000A load-delay interlock (ReadFudge/LDAbsorb)
Implements the last unmodeled load-path cycle component — the R3000A
load-delay pipeline interlock (Beetle ReadAbsorb/ReadFudge/LDAbsorb/
LDWhich) — as ONE shared per-instruction model across the dirty interp
and BOTH static emitters, replacing the flat per-instruction +1 base and
the flat +4 main-RAM read wait-state.

Model (transcribed from beetle-psx cpu.cpp + libretro.cpp, verified Δ):
- §1 base: a pending load's per-register give-back (read_absorb) is
  consumed INSTEAD of charging +1 (pipeline write-back overlap).
- GPR_DEPRES: each opcode's src/dst regs end the give-back on use.
- DO_LDS: commits the prior load's give-back + ReadFudge.
- load ReadMemory: +2 fudge iff the predecessor committed no load;
  main-RAM region +3 + completion +2 (LWC2 +1) become the LDAbsorb
  give-back; scratchpad +0.

New runtime/include/psx_cyc.h (inline §1/deps/DO_LDS + step) over new
CPUState timing fields; psx_cyc_load_*/psx_cyc_lwc2_read in memory.c
(region-aware, own the whole load interlock, value via uncharged
psx_read_*); psx_cyc_dep_res_mask in psx_instr_cost.h (per-opcode
GPR_DEP/RES, gen-time literal). cpu->read_* rewired to uncharged in
main.cpp; charge_main_ram_read removed. Wired before each instruction
body in code_generator.cpp (game) and full_function_emitter.cpp +
strict_translator.cpp (BIOS); loads route through psx_cyc_load_*.

Validated against the Beetle oracle:
- ruler #2 load2: native +10 -> +11 == Beetle (all other components
  still exact: alu+1/load+5/div+38/mult+15/gte_rtps+15/gte_nclip+8).
- ruler #1 [0x80001C5C->0x80001CA4]: 54 -> 56 == Beetle steady-state
  (residual 84/77 spikes = I-cache cold refill, the separate P2 axis).
- Tomba 2 boots to the intro FMV (pixels on screen, no regression).

Follow-up (separate commit): GTE-read (MFC2/CFC2) + MFC0 LDAbsorb
give-back, and muldiv-stall give-back consumption — needed for mixed
game/FMV faithfulness but do not affect the rulers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:14:47 -07:00
Matthew Stanley ec1fd76b56 timing: faithful GTE (COP2) per-command completion-stall
Models the GTE command latency + stall-on-COP2-access, the next Stage-2 cycle
component. Mirrors the mult/div completion-stall mechanism:

- cpu->gte_ts_done (new CPUState field, appended): a GTE command sets it to
  psx_cycle_count + (cost-1); back-to-back commands serialize (psx_gte_set
  stalls to the prior deadline first). Any COP2 register access
  (MFC2/CFC2/MTC2/CTC2/LWC2/SWC2) calls psx_gte_stall, advancing guest cycles
  to the deadline if the op is still in flight.
- Cost table (psx_cycles.c, psx_gte_cmd_latency): per-op cost-1, transcribed +
  verified from beetle-psx gte.cpp op returns (RTPS15 NCLIP8 MVMVA8 NCDS19
  NCDT44 RTPT23 NCCT39 ... AVSZ4=5 not 6). cost-1 because the COP2 instruction's
  own +1 base is charged separately (Beetle GTE_Instruction returns ret-1).
- Set armed in the shared gte_execute() (one site for both backends); stall
  emitted before every COP2 reg access in both emitters (code_generator,
  strict_translator) and the dirty interp. The compiled "+1 at top" vs interp
  "base after exec_one" offset cancels (same as muldiv).

Validated EXACT on ruler #2 (new gte_rtps + gte_nclip loops): native +15/+8 ==
Beetle +15/+8. All other components unchanged (no regression); Tomba2 boots to
FMV. Requires regen (emitters changed); runtime+BIOS+game regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:41:22 -07:00
Matthew Stanley a3e8f2818b feat(timing): faithful mult/div completion-stall + ruler #2 native half (validated EXACT)
Stage-2's biggest cost gap closed. MULT/MULTU/DIV/DIVU now set a completion
deadline; MFLO/MFHI stall guest cycles until it — exactly like Beetle's
muldiv_ts_done. Latencies transcribed from cpu.cpp: DIV/DIVU=37; MULT via
MULT_Tab24 (14/10/7) on the first-operand magnitude. Native previously charged
ZERO for this.

- Per-instruction cycle charging is now the DEFAULT on this audit branch
  (PSX_CODEGEN_CYCLE_PER_INSN), required so the stall absorbs correctly (the
  running cycle count must be accurate mid-block). Validated cycle-neutral for the
  identity base model; Tomba 2 still reaches the FMV.
- runtime: CPUState.muldiv_ts_done + psx_muldiv_set/stall + psx_mult_latency_s/u
  in psx_cycles.c. Emitted at the mult/div/mflo/mfhi sites in the GAME emitter
  (gated PSX_ENABLE_BLOCK_CYCLES). Stall fires even for mflo/mfhi to $zero.
- ruler #2 native half: tools/cycle_testrom/CMakeLists.txt builds psx-cyctest
  (links the recompiled test EXE), boots disc/cyctest.cue (serial CYCT-00101 so
  disc-identity matches, no warning dialog). measure.py --port 4600.

VALIDATION (native vs Beetle oracle, per-iteration component cost):
  div +38 vs +38 EXACT | div_spaced +38 vs +38 EXACT (absorb works) |
  mult +15 vs +15 EXACT | baseline/alu match.
Remaining: load native +7 vs Beetle +5 (memory.c flat +6 wait-state needs
calibration to ~4) — a smaller refinement. BIOS emitter still block-mode (ruler
#1 unchanged at 30) — applying per-insn + muldiv there is the next step to close
ruler #1's div-stall gap.

No FMV regression (i_stat 0x8D, screenshot verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 00:00:22 -07:00
Matthew Stanley 9cec60a30d feat(timing): BIOS-kernel cycle ruler + fix load double-count
Build the game-independent cycle-attribution ruler the plan called for, and
fix two real bugs it exposed.

Tooling (debug_server.c, full_function_emitter.cpp):
- cyc_watch double-fire dedupe: observe fired from BOTH the dispatcher and the
  function prologue at the same cycle, double-recording every dispatched entry.
  Key on (phys,cycle); a genuine re-entry is always >=1 cycle later.
- Per-block-leader cycle observe (debug_server_cyc_observe), emitted by the BIOS
  emitter under #ifndef PSX_NO_DEBUG_TOOLS (prod = zero overhead). Native now
  samples at EVERY compiled block leader (matching Beetle's before-instruction
  sample), so cyc_watch can anchor any block-leader PC, not just fn entries.
  Emitted at normalize_address() so relocated-kernel anchors match the RAM space.

Bug fix (psx_instr_cost.h):
- Loads were double-counted. Stage-2 #1a (2ef47bd) returned 3 for loads (+2
  data-access) in psx_instr_base_cycles WHILE memory.c charge_main_ram_read
  already charged +6 per main-RAM read. The ruler region [0x80001C5C->0x80001CA4]
  decomposed native=34 as 16(base) + 2 + 12(mem) with loads counted twice. The
  opaque 0x80017FC4 window hid it because the load over-charge masked the entirely
  unmodeled divu->mflo stall. Reverted the opcode fn to pure execute base (loads=1)
  per its own stated contract; memory.c is the single address-keyed data-access
  owner (like Beetle ReadMemory). Ruler: native 34->30 (exact), FMV unaffected.

Docs: CYCLE_MODEL_BEETLE.md (full R3000A model transcribed from in-tree Beetle
cpu.cpp, cited), plan + burndown updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:14:35 -07:00
Matthew Stanley 2ef47bd388 feat(timing): Stage-2 #1a — data-load cost in psx_instr_base_cycles (Δ-validated)
Transcribed from Beetle PS_CPU::ReadMemory: a CPU load adds +2 cycles (LWC2 +1);
stores posted (~0). psx_instr_base_cycles now returns 3 for LB/LH/LWL/LW/LBU/LHU/LWR,
2 for LWC2, 1 else. Flows to BOTH backends via the single-source seam.
Δ GATE (cyc_watch @0x80017FC4, native vs Beetle per-iteration): 46 -> 56 (toward
Beetle 91) — load cost closed ~10 cyc; remaining ~35 = later components (I-cache
fetch, mult/div, GTE, region/absorb refinements). FMV still streams (no regression).
Approximation noted in the header: no scratchpad-free / load-delay-absorb / region
yet (Δ-gated next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:59:19 -07:00
Matthew Stanley b00b81f5e9 feat(timing): single-source cycle-cost seam (psx_instr_base_cycles), identity
P3 step 1 (FAITHFUL_TIMING_PLAN.md): both backends now account guest CPU cycles
through ONE function, runtime/include/psx_instr_cost.h::psx_instr_base_cycles(insn),
so they can never disagree (the -8 drift class). Interp routes its 3 per-insn
charges (exec_delay_slot, dirty dispatch loop, precise slice) through it; the
recompiler sums it over each block (+ the outside delay-slot clone) to fold into
the compile-time block charge. Stage 1 = identity (1 cycle/insn) = the proven
baseline, so the seam is BEHAVIOR-PRESERVING: regen is byte-identical (verified,
full.c + dispatch.c) and Tomba 2 still reaches the intro FMV (i_stat 0x8D
streaming). Stage 2 edits ONLY psx_instr_base_cycles (transcribe real R3000A
costs from in-tree Beetle cpu.cpp/gte.cpp + psx-spx, verify each vs Beetle) and
both backends update together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:20:20 -07:00
Matthew Stanley f9d50d7503 fix(tomba2): delay-slot cycle accounting -> Tomba 2 reaches the intro FMV
The multi-week Whoopee-Camp logo->FMV stall was a TIMING-FAITHFULNESS bug, not
game logic: the static recompiler charged cycles block-granular and a branch
whose delay slot is a SEPARATE block leader (branch at end_addr, delay slot
outside the block) undercharged by one cycle on every execution -- the
always-executed delay-slot clone was emitted but counted by neither the branch
block nor the (unentered) delay-slot block. ~8 such sites on the boot init path
= the measured -8 cycle drift that made native run behind the interpreter, so
the frame-1824 elapsed-Timer1 logo-delay loop exited after ~42 iterations
(native) instead of ~1557 (interp/HW) and never reached the FMV.

Fix (code_generator.cpp translate_basic_block, the faithful class fix -- every
dynamically executed instruction charged exactly once):
  block_exec_cycles = instruction_count
                    + (exit branch at end_addr with delay slot outside ? 1 : 0)
applied to both the block cycle charge and the slice budget. R3000 is MIPS-I so
the delay slot is unconditional; the not-taken path already falls through to
branch_pc+8 (no double-count). Result, screenshot-verified: native passes the
logo-delay loop, screen animates, CDROM+DMA stream active, the jungle intro FMV
plays.

Also in this checkpoint (all on wt/tomba2, not merged/pinned):
- PARKED precise interrupt-slicing (PRECISE_IRQ_SLICE.md): root-caused its pc=0
  boot exit to a mid-function clean-text resume PC the dispatcher can't re-enter;
  ChatGPT-validated fix is "every block leader = re-enterable CPS continuation"
  (psx_game_is_function_entry predicate added; precise_pc_dispatchable +
  irq_taken-once restructure in psx_run_precise). Default OFF (PSX_PRECISE_SLICE=1
  to resume); inert on the baseline. Slice-trace diagnostics added to freeze_check.
- FAITHFUL_TIMING_PLAN.md: authoritative game plan (P1-P6) + standing directive
  (build the faithful core, never hacks; ecosystem is being built, not preserved);
  referenced from CLAUDE.md Rule -1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:24:16 -07:00
Matthew Stanley 2d915c3fa9 wip(tomba2): precise-IRQ-slicing groundwork — cycles_to_next_event + design spec
Phase 1 of the cycle-budgeted precise interrupt-take-point fix (root cause:
compiled blocks take IRQs only at block edges; HW + the dirty-RAM interpreter
take them at the exact instruction — oracle-confirmed at the Tomba 2 logo
Timer1 debounce). Design validated with ChatGPT; full spec in PRECISE_IRQ_SLICE.md.

This commit lands the additive, no-behavior-change foundation (Task #2) plus the
prior session's diagnostic take-PC capture, and a build-hygiene dedup:

- cycles_to_next_event() (interrupts.c): single source of truth — min guest-cycle
  distance to the next DELIVERABLE interrupt (source raises its I_STAT bit AND
  that bit is unmasked in i_mask). Aggregates VBLANK + per-peripheral queries.
- timers/cdrom/dma/sio _cycles_to_irq(i_mask): each peripheral owns its own
  next-IRQ-distance query; all conservative under-estimates (smaller => slice
  more => never run past an event).
- debug_server: cycles_to_next_event command for live validation.
- interrupts.c: take-PC capture into the IRQ_DELIVER event-ring aux (prior
  session's diagnostic; records the architectural take-PC for cross-backend diff).
- overlay_loader.c: remove duplicate psx_native_bad_entry definition (HEAD had it
  twice; would not link cleanly).

Live-validated at the logo: returns the VBLANK distance, correctly excludes the
three free-running (unarmed/masked) timers and idle CD/DMA/SIO. Runtime-only,
no regen. The two-tier executor (Task #3) that consumes this is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:04:25 -07:00
Matthew Stanley 8ab24c5e62 wip(tomba2): ABI v8 check_interrupts_at + emitter charge-cycle work (pre-rebase snapshot)
In-progress investigation work snapshotted before rebasing onto the
tcc-refactored psxrecomp master. Includes overlay ABI v8
(check_interrupts_at resume-PC callback for async RFE recovery) and
code_generator/full_function_emitter delay-slot cycle accounting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:26:04 -07:00
Matthew Stanley c6f5193ac4 tomba2: first-divergence tooling + Fix A pt1 (overlay cycle accounting)
Root-caused the Tomba2 native logo-stuck bug: it is a guest-cycle-timeline
divergence between the compiled-overlay backend and the dirty-RAM interpreter,
surfacing through a Timer1 (0x1F801110) debounce loop at game pc 0x8008592C that
reads 1 (native) vs 2 (interp), forking logo->FMV. The handoff's "jalr target
divergence" was a benign store-PC artifact (read-watch proves a0=0xB870 and
t3=[a0]=0x800896E0 are identical in both backends); the scratchpad blind spot
was ruled out (separate sp fingerprint forks at the same frame).

First-divergence observability (debug_server.c, memory.c):
- frame_fingerprint: added separate sp (scratchpad) cumulative hash column +
  per-entry cyc, so a scratchpad or cycle fork is classified, not hidden.
- Unified ordered access recorder: one buffer, kind = ramw/spw/mmiow/mmior/ramr,
  array index = true execution order, per-entry cyc (psx_get_cycle_count).
  record_frame_dump emits the unified stream; record_reads_dump filters reads.
- Targeted main-RAM read-watch (PSX_READ_WATCH="lo,hi" + g_ram_read_watch_active
  guard in psx_read_word) — proves a load's value/address per backend.
- PSX_RECORD_FRAME env seed: arm the recorder from boot (race-free), never
  arm-then-time.

Fix A (cycle-model unification, part 1): compiled-overlay code was built WITHOUT
PSX_ENABLE_BLOCK_CYCLES, so every psx_advance_cycles() in overlay code was
compiled out -> a function run as a native overlay charged ~0 cycles while the
same function via the interp charged per-instruction (~32x), the entire drift.
  - overlay_api.h: OverlayCallbacks gains advance_cycles; ABI v6->v7 (rejects
    stale zero-cycle DLLs).
  - compile_overlays.py: build overlays with -DPSX_ENABLE_BLOCK_CYCLES=1 and
    emit a psx_advance_cycles shim forwarding to the shared host timeline.
  - overlay_loader.c: wire s_callbacks.advance_cycles = psx_advance_cycles.

Result: the first debounce now reads Timer1=2 in native (was 1); overlay cycle
charge went 9.5k -> 380k (interp 306k). Native still logo-stuck: a residual,
perfectly linear ~1 cycle/write over-charge remains (block.instruction_count
counts branch + delay-slot as 2 while the interp charges the pair as 1 step),
which flips a later debounce 3 vs 2. Fix A pt2 (cycle-exact block totals) is the
next step. wt/tomba2 only; no pin bump, no master merge, no other-game regen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:23:15 -07:00
Matthew Stanley 7793bf5ac8 overlay-cache v2 P2: generation-gated dispatch validation
The dispatch path re-CRC32'd a native overlay function's ENTIRE code body on
every single call (cand_crc, unconditional) before running it -- a real tax on
hot per-frame code that blunted the native win. The page-generation counter that
could make this a cheap skip existed but was demoted to a diagnostic.

Promote it: a watched-page write is the ONLY way an entry's code can change, and
every CPU/DMA store funnels through the store chokepoint which bumps
overlay_page_gen for watched pages. So if the page-generation sum is unchanged
since this entry was last confirmed VALID, the bytes are provably unchanged ->
run native WITHOUT re-hashing. Fall back to the full crc32 only when the gen
moved or the entry isn't known-valid (this also re-confirms reload-on-return,
since write-away-then-back bumps the gen). Applied to both the main candidate
chain and the CPS continuation re-entry path. Correctness identical to the old
unconditional hash; only redundant hashing is cut. New gen_fastpath counter
exposed in the overlay status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:17 -07:00
Matthew Stanley aeef4b554e fix(sio): plain digital pad ignores DualShock config commands (Tomba 2 menu input)
Tomba 2's title menu was unresponsive and the idle attract demo never
started. Root cause (proven against the Beetle oracle): the SIO pad
state machine answered the DualShock config-mode commands (0x43 enter-
config, 0x4D rumble-map, etc.) for EVERY pad, including a plain digital
controller. Tomba 2's pad driver probes the controller with 0x43 every
frame to detect a DualShock; on real hardware a digital pad (SCPH-1080,
id 0x41) ignores 0x43 (hi-z, no ACK), so the driver classifies it as
digital-only and just polls with 0x42. Our SM answered 0x43, so the
driver went down the DualShock config path and read the 0x00 config-
response data bytes as button data -> phantom "all buttons pressed"
(pad word 0x0000). That made the menu's edge-detected navigation see
nothing newly-pressed (unresponsive) AND reset the idle/attract timer
every frame (no attract).

Fix: model a plain digital controller. New per-slot pad_supports_config
flag (sio_set_pad_config_capable), set from the player pad mode
(PAD_MODE_DIGITAL => 0, ANALOG/HYBRID => 1, default 1). When 0, all
config-command branches (0x43 / legacy / modern 0x44-0x4F) fall through
to the hi-z "no response" path, so a digital pad answers ONLY the 0x42
poll -- byte-for-byte matching the oracle.

Validated live: SIO now shows only 0x42 polls (id 0x41, buttons 0xFFFF),
idle pad word is clean (no 0x0000 phantom), every injected button reaches
the menu var, and START navigates the title menu and starts the game.

Gated so only DIGITAL-mode pads change behaviour; HYBRID/ANALOG pads
(MMX6, Tomba 1, Ape) are unaffected (config-capable as before) -- but a
T1/MMX6/Ape regression pass is still required before any master merge.

NEW failure point (next bug in the chain, now reachable): with input
fixed, the idle attract demo triggers and loads a gameplay level, which
FREEZES -- guest spins in compiled kernel/dispatch code, dirty-interp
idle (insns_run flat), vblank stalled, level textures corrupted. This is
the untested gameplay-overlay stage, a separate issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:16 -07:00
Matthew Stanley 2acd2d79ef dirty-interp: overlay-region floor = THIS game's text end (Tomba 2 splash fix)
OVERLAY_REGION_FLOOR was hardcoded to 0x98000 = Tomba 1's main-EXE text end
(0x10000 + 0x88000). Tomba 2's boot EXE is only 0x28800 (text ends 0x38800),
so its disc overlays load at 0x80085000+ — inside [0x38800, 0x98000), which the
hardcoded floor wrongly classified as statically-recompiled main-EXE text. That
forced every Tomba 2 overlay onto (a) slow block-by-block dispatch and (b) the
bail-prone dispatch_nonlocal_call contract path; when the per-frame interrupt
pump fired mid-contract it cascaded into the $ra=$sp=1 bail storm = the
Whoopee-Camp splash freeze.

Make the floor a runtime value (g_overlay_region_floor), pinned at game load to
(load_address + text_size) & 0x1FFFFFFF. Defaults to 0x98000 for BIOS-only;
evaluates to the same 0x98000 for Tomba 1 (no behavior change), and is
correct-by-construction for every game.

Resolves: bail storm gone (bail_first=0); splash advances at 60 fps past the
old frame-1823 wedge. NEW failure point exposed next: dispatch_depth desync on
the now-local-flow overlay path (fixed in the following commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:10 -07:00
Matthew Stanley 732e093ab2 heartbeat: IRQ raise/deliver/ack + bail-source telemetry (Tomba 2 spin diag)
Always-on, heartbeat-resident diagnostics for the Whoopee-Camp splash spin:
- VBLANK raise/deliver/ack counters (interrupts.c + memory.c) + irq_deliver.
- Deduped bail-source ledger keyed by (call-site $ra, wild target) (traps.c,
  hooked at psx_call_contract in cpu_state.h) + heartbeat top-key/first-bail.

Result: REFUTES the exception-reentry-storm hypothesis. VBLANK is raised,
delivered AND acked exactly 1/frame (textbook-healthy); irq_deliver is 1/frame,
not a storm. So the freeze is NOT an interrupt/timer/CD problem -- the hardware
sim is healthy. The ~68k bails/frame are the GAME splash loop busy-polling a
kernel event (TestEvent/WaitEvent) that never fires -- the DeliverEvent/WaitEvent
discriminator is the next probe.

(The dominant dispatch-trampoline bails are emitted in generated BIOS code, so
the precise bail call-site needs a recompiler-emit change + BIOS regen; the
direct-call ledger here is partial. Deferred -- not needed to localize to the
event wait.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:08 -07:00
Matthew Stanley 5c64071155 cdrom: multi-track .cue TOC support (GetTN/GetTD report real tracks)
The .cue parser only extracted the FILE line and the CD model hardcoded a
single data track: GetTN always returned 01..01 and GetTD returned LBA 0 for
any track. Multi-track discs with a CD-DA audio track (Tomba 2: TRACK 02 AUDIO
@ 38:48:74) therefore reported a bogus TOC.

- iso_reader: parse TRACK/INDEX lines into a CDTrack table; synthesize a single
  data track for bare .bin/.iso. New TrackCount/TrackStartLBA/TrackIsAudio +
  iso_track_* C wrappers.
- cdrom.c GetTN returns first=1/last=track_count (BCD); GetTD returns each
  track's real start MSF (lead-out via 0xAA/track 0 unchanged).

Framework fix for every CD-DA title. NOTE: does not by itself unblock Tomba 2's
boot — that still stalls in a GetStat/GetID/Init disc-init loop (display toggles,
reading=0) which does NOT call GetTN/GetTD; next is an oracle CD command/response
diff at that loop (and the hardcoded GetID region "SCEI" vs USA "SCEA" is a
candidate). cdrom_trace_dump floods with DMA 'D' events — fix that (Rule 15)
before the diff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:06 -07:00
Matthew Stanley 4d50c2214a overlay: fail-closed native entry guard (port to master) — fixes the native-overlay blue-screen wedge
Brings the fail-closed native entry guard from wt/tomba2 (b5a6940 / bd60381) to
master, where it had never been merged — which is why Tomba (and any master-built
title) still wedged. This is the long-standing "blue screen" / shared-RAM-
divergence-between-interp-and-native class.

Root cause: an overlay function dispatched at a FOREIGN INTERIOR PC (a range-
ownership mismatch routed a PC the function does not own into it) hit the CPS
entry-switch `default: break`, which fell through and ran the function FROM ITS
TOP — re-running its prologue and corrupting shared CPU/RAM state, so the guest's
main loop then waits forever on a condition that never gets set (the 0xCF0 SIO
spin / blue screen). Reproduced on Tomba's Village of All Beginnings: a native
0x000E7000-region shard function entered at a foreign PC wedged; the interpreter
ran the same bytes correctly.

Fix: the generated CPS entry-switch for OVERLAY functions (config_.overlay_mode)
now FAILS CLOSED on an unknown non-zero continuation PC — it restores the PC and
calls psx_native_bad_entry instead of running from the top. overlay_loader_dispatch
consumes g_native_bad_entry and routes the PC to the sanctioned dirty-RAM
interpreter (the live bytes run correctly — NOT a stub/HLE). True-prologue entry
still runs from the top. Static (BIOS/boot-EXE) functions keep the legacy fall-
through. ABI bumped 5->6 so stale ABI-5 caches are rejected and rebuilt with the
guard.

Minimal port: kept only the fail-closed mechanism + its ABI plumbing. The Tomba2
host_aliases interior-routing and the native-block / CPS-probe diagnostics (which
depend on the overlay-cache-v2 multi-range Candidate fields not on master) were
intentionally omitted — the fail-closed default alone is the safety net.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:07:04 -07:00
Matthew Stanley e7f7fb6cf0 overlay: unify cache read/write location + fix missing ws_cull symbol in DLLs
Two framework bugs that made overlay variants run interpreted forever (the
"agonizing lag" in overlay-heavy areas like Tomba's dwarf village):

1. Cache read/write divergence. The loader reads <exe>/cache and the runtime
   writes captures to <exe>/overlay_captures.json, but the gcc autocompile path
   used the game.toml command verbatim, whose hard-coded --out-dir/--captures
   pointed elsewhere (build-stable/build-modern). So compiled shards landed where
   the loader never looked and captures were read stale -> the variant never went
   native. Fix: the runtime now injects PSX_OVERLAY_CACHE_DIR / PSX_OVERLAY_CAPTURES
   (the canonical loader paths) into every autocompile spawn; compile_overlays.py
   honors them over any CLI flag (--captures now optional). One source of truth for
   the cache location, uniform across all games, dev or prod. gcc/tcc stay separated
   by the existing <compiler> namespace.

2. Overlay DLL preamble missing psx_ws_cull_sltiu. auto_screen_x emits this helper
   at every width-cull site into EVERY build's C (identity at 4:3), and the main EXE
   links it from gpu.c, but the overlay DLL preamble never defined it -> any overlay
   variant with cull sites failed to link -> no shard -> interpreter. Fix: provide
   the self-contained helper (depends only on the already-forwarded psx_ws_x_margin)
   in the DLL preamble.

Proven on Tomba dwarf village: dispatch_native 29/s -> 11454/s (was ~97% interp,
now ~97% native).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:57:57 -07:00
Matthew Stanley c2a3f6ad79 overlay: add tcc tier (toolchain-free fallback) replacing sljit; extract overlay_backend
Compiler-neutral tier-selection policy extracted from the mis-named
overlay_sljit.{c,h} into NEW overlay_backend.{c,h} (OverlayBackend enum +
resolve/name/active). overlay_sljit.{c,h} is now just the deprecated emitter.

New tcc tier. gcc and tcc run the IDENTICAL pipeline (recompiler -> C ->
compiler -> DLL -> loader); only the compiler binary differs, so both map to the
autocompile-spawn provider (code_provider.c) and the COMMAND is chosen by the
resolved backend (main.cpp). compile_overlays.py gains --compiler gcc|tcc: tcc
writes a separate tcc/ cache namespace, BOM-strips the C + a copy of the include
headers (tcc 0.9.27 can't skip a UTF-8 BOM), and uses tcc's built-in linker.

Backends (overlay_backend): auto (gcc if a gcc TOOLCHAIN is present, else tcc --
was sljit), gcc, tcc, auto-no-gcc (dev/test: force tcc even with gcc present, to
exercise the toolchain-less user path), sljit (DEPRECATED, gated off, lowest).
Priority: static > gcc shard > tcc shard > sljit(off) > interp. The loader scans
gcc/ then tcc/ and dedups by region+crc (filename) so a gcc shard always wins a
tie.

Self-contained tcc: with no explicit overlay_autocompile_cmd_tcc, the runtime
CONSTRUCTS the tcc command from a toolchain bundled beside the exe
(<exe>/overlay_toolchain/: embedded python + tcc + recompiler + compile_overlays.py
+ runtime headers) -- no system python or gcc needed. config_loader gains
overlay_autocompile_cmd_tcc (a dev override). autocompile cmd buffers enlarged for
the constructed path-heavy command.

Compilation stays OFF the dispatch/render thread (off-process autocompile
subprocess, same model as gcc); the main thread only spawns it and dlopens the
finished DLL.

Validated: MMX6 with overlay_backend=auto-no-gcc fills the tcc/ namespace live
from the bundled toolchain and plays through save-load + gameplay -- the exact
path the sljit tier softlocked on. The gcc path is untouched.

Runtime-only, no regen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:10:57 -07:00
Matthew Stanley 71d6550eef overlay: off-thread sljit compile worker + observability; disable sljit tier by default (emitter bug)
Off-thread sljit compile worker (overlay_compile_worker.c/.h): on a dispatch
gap-fill miss the dispatch thread snapshots the region bytes and ENQUEUES; a
background worker JITs from the snapshot (never live RAM) and publishes the shard
to the on-disk cache via an atomic temp+rename; dispatch picks it up on a later
miss via an idempotent rescan, so the candidate table stays single-threaded. This
removes the synchronous JIT frame hitch. Validated on Tomba + MMX6: compiles
off-thread (one MMX6 compile was 26ms, paid entirely off the dispatch thread with
no frame-pacing spike), shards persist + reload, queue never backs up.

Observability (ring-buffer doctrine): always-on recent-compile ring + per-compile
timing in the worker, the resolved on-disk cache dir, and a tier-enabled flag, all
surfaced via a new sljit_async TCP debug command (documented in TCP_COMMANDS.md).

DISABLE the sljit tier by default (g_sljit_tier_enabled = 0): the sljit EMITTER
mistranslates some MMX6 overlay instruction(s) -- sljit-only save-load softlocks/
loopbacks while PURE INTERP works (discriminator), and it reproduces with the
worker OFF in the synchronous path (PSX_SLJIT_SYNC=1), so it is the emitter, not
the async worker. Until the emitter bug is found + fixed: no shard READ
(scan_sljit_cache_dir skipped), no GENERATE (worker not started + sljit_live
forced 0 + dispatch gap-fill gated), every overlay miss falls to the interpreter.
gcc (DLL cache + autocompile) and overlay capture are untouched, so priority is
gcc > interp and dispatch misses are still recorded for future gcc coverage. gcc
shards are bundled in release zips, so shipped builds are unaffected.
PSX_SLJIT_ENABLE=1 re-enables the tier for emitter-fix testing.

Runtime-only, no regen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:13:53 -07:00
Matthew Stanley 340d6e7074 boot snapshot: complete post-BIOS save-state for instant, rebuild-proof boot
Replaces the old incomplete RAM-only fast_boot snapshot with a COMPLETE
hardware save-state captured at the BIOS->game handoff. First launch (and the
first launch after any update) boots the real BIOS normally and captures the
full machine state at the game's PS-EXE entry_pc; every subsequent same-build
launch restores it and presents the game's first frame instantly.

This is not HLE: it persists the real hardware state a real BIOS run produced.

Format (boot_state.h v2): a sectioned file with an integrity key
(bios_checksum + entry_pc + PSX_OVERLAY_CODEGEN_HASH + ABI tag + codegen ver).
Any mismatch -> reject -> normal boot + recapture, so a user/app update can
never silently load a stale snapshot. Missing/short/unknown sections also reject
(no partial restore).

Complete capture (one section per subsystem), the fix for the old snapshot's
real bug (it left GPU/SPU/CD/DMA/SIO at reset while CPU/RAM assumed configured):
- CPU regs, 2 MB RAM, 1 KB scratchpad, IRQ (i_stat/i_mask), root timers, guest
  cycle counter, dirty-RAM page bitmap (guest-written code pages).
- GPU: display/draw-area/offset/mask/texpage + GPUSTAT bits + GP0 command
  machine + VRAM-transfer state; VRAM (1 MB) via the renderer-agnostic
  gr_vram_transfer_out/in hooks (so it is correct on the GPU-authoritative GL
  backend, where the FBO -- not gpu.c vram[] -- is truth, and the FBO re-upload
  on restore happens for free).
- SPU: register file + 24 voice decode/ADSR state + latches + 512 KB SPU RAM.
- CD-ROM: full controller FSM (regs/IRQ-latch/FIFOs/seek/read/pending/queued +
  XA decoder history + disc-speed model).
- DMA: channels[7] + dpcr/dicr + async-transfer state.
- SIO: registers + pad-config FSM + memcard FSM (incl. per-slot mc_slots[]) +
  cycle-paced shifter/ACK scheduler.

Host-side/recompiler-derived state (overlay/sljit tables, code-provider) is NOT
serialized -- it re-initializes fresh on the normal startup path.

Per-subsystem accessors use an X-macro field list so bytes/write/read can never
drift. Runtime-only; no recompiler/codegen change, no regen. Reuses the
[runtime] fast_boot key (semantics upgraded; type unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:10:37 -07:00
Matthew Stanley 01b4d91e8a WIP: forward-port Ape Escape framework (dev/ape-escape) onto master
Comprehensive 3-way merge of the dev/ape-escape branch into master for
the isolated wt/ape-escape worktree. Brought in every complementary ape
change automatically (dma.c, memory.c interrupt byte/half lanes,
function_analysis discovery, main_bios vector seeds, iso_reader
iso_sector_count, config_loader, crash-trace fields, headers). Resolved
the 13 conflicts by rule:

  - CPS dispatch / recompiler (code_generator, full_function_emitter,
    dirty_ram_interp, traps, main_psx) -> KEEP MASTER (evolved system).
  - CD model (cdrom.c) -> take ape (the model that booted Ape).
  - interrupts.c/.h -> reverted to MASTER (ape's interrupt-delivery is
    dispatch-coupled; the merged Frankenstein stormed).
  - sio/debug_server/freeze_heartbeat/main.cpp -> keep master (evolved).
  - crash_trace.c -> master (ape freeze-dump fns omitted to avoid the
    debug-ring cascade).

STATUS: does NOT boot yet. ape-cdrom + master-interrupts derails to
PC=0 at frame ~2092 (insta-close); ape-cdrom + ape-interrupts storms
(297M exception re-entries) at frame 2077. Root finding: ape's CD
anti-storm is coupled to ape's interrupt-delivery + call-depth + EPC
model, which master replaced with CPS continuation-passing, so the CD
subsystem does not forward-port as pure hardware simulation. Preserved
as the reference state for a design consult on bridging the two models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:55:23 -07:00
Matthew Stanley af303f3454 runtime: Tomba "Hybrid" legacy pad-config compatibility (per-game opt-in)
The launcher Hybrid controller mode (built first, for Tomba, to mimic Tomba:
Special Edition) flips the emulated pad's reported type 0x41<->0x73 on
d-pad<->stick. After the real DualShock config state machine was added for MMX6
(98aa688), that flip regressed every game's libpad re-detect into a 1-frame
"pad unplugged": Tomba unpauses its menu / drops input, MMX6 phantom-dashes.

The modern config SM is correct and required by MMX6, so instead of weakening it
globally, restore the pre-98aa688 "config commands always answer 0xF3" behaviour
as a per-game opt-in: [controller] legacy_pad_config (default OFF). Only Tomba
sets it; with the flag off the modern pad path is byte-for-byte unchanged, so no
other title is affected. A/B verified live: legacy = clean Tomba flip / MMX6
input; modern = Tomba disconnect + MMX6 dash.

Explicitly LEGACY: a correct findpad/re-detect refactor would make the flip
benign for every game with no per-game branch (real hardware tolerates unlimited
analog-button presses); when that lands this whole feature set is to be removed.
Full rationale in sio.c (g_pad_legacy_cfg).

Also includes the deferred coherent pad-type apply (pad_type_req, applied only at
an idle/non-config bus boundary so a flip can't split a poll/handshake) and a
`pad_cfg` debug command + tools/padcfg.py + tools/sio_flip_trace.py used to A/B
the config SM live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:43:34 -07:00
Matthew Stanley bb1d990f1b widescreen: MMX6 BG tile-ring freshness fix — kill the reveal-margin staircase
The 16:9 BG widen draws LEFT extra tile columns each side, but the engine's
streamer (FUN_800273e4 -> FUN_800274a0) only refreshes one column per side per
frame at the leading edge. On scene entry / fast scroll the inner reveal columns
held stale tiles from a prior scrollY -> real-but-misplaced tiles spilling
up-and-left (the "staircase"). Census confirmed this is NOT the BG packet-budget
overflow (674-816 prims/frame, well under the 1000-tile cap).

Fix (render window == refresh window): clone FUN_800274a0's inner metatile->ring
fill into mmx6_fill_column() and re-stream every column of the widened window for
all 3 layers each frame (psx_ws_mmx6_bg_refill_all), triggered once/frame from the
existing count_site hook (mmx6_bg_refill_tick, frame-gated). Uses each layer's own
scroll, omits the engine's scrollX wrap-bookkeeping writes (those drive the engine's
own incremental streamer), skips map-loop seams; off-map columns fill tile 0 (the
renderer skips them). Validated byte-exact against the engine's own streaming
(0 mismatches / 1113 cells x 3 frames; debug cmd mmx6_freshfix {validate:1}). Gated
native-wide <=384w so 4:3 and the 512 hi-res title stay byte-identical.

Also removes the 2-col reveal cap (WS_FULL2D_MAX_REVEAL): it never addressed the
staircase and cost screen real-estate; the full 53px/4-col reveal is restored.

Parked, gated-off experiments ride along (inert, default off): host-side reveal
(Attempt A: gpu_ws_mmx6_emit_reveal + sw_wide_emit_tile) and BG packet-buffer
relocation (Attempt B: [widescreen.bg2d] bufbase/cap recompiler hooks). These target
the separate dense-stage budget-overflow concern, not this fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:44:54 -07:00
Matthew Stanley 510d75f70b widescreen: MMX6 stale-margin fix — widen the BG tile-ring streamer
The BG renderer widen (a8f37c1) draws extra tile columns on both sides, but the
engine's 64-column tile ring is only refreshed at its leading edge: the streamer
(FUN_800273e4) re-streams ONE column per side per frame at world-X scrollX-16
(left) and scrollX+16+width (right). So the columns the widened renderer reaches
were never populated — they showed empty (black margin) or stale (old tiles as
the camera scrolled), the "stale margins" the user saw in motion.

Add two more [widescreen.bg2d] sites (stream_left_site / stream_right_site) that
push the streamer's left/right leading-edge world-X out by LEFT*16 via gpu.c
psx_ws_mmx6_bg_stream_{left,right}, so the incremental streamer keeps the ring
valid across the widened window as the camera scrolls. Same number of streams
(the single leading-edge target just moves out LEFT columns) — no extra cost.
Identity at 4:3 / 512 hi-res.

Verified: the previously-black left reveal margin now fills with coherent
adjacent stage; blackL/blackR = 0 across scenes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 12:40:16 -07:00
Matthew Stanley a8f37c1f76 widescreen: MMX6 2D true-FOV — full_2d opt-in + [widescreen.bg2d] BG widen
Pure-2D sprite games (Mega Man X6) render a true 4:3 field of view with no
overscan to reveal, so 16:9 requires making the engine draw more. Two parts:

- [widescreen] full_2d opts a pure-2D game into the native-wide present path
  (it never emits the 3D sprite-tag hook the gameplay detector keys on, so
  every in-game frame was misclassified as a 4:3 menu and pillarboxed).

- [widescreen.bg2d] is a gen-time hook on the per-layer background tile
  renderer (MMX6 FUN_800270d0) at its column-count / start-tile-column /
  start-screen-x instructions, routed through gpu.c psx_ws_mmx6_bg_{cols,
  startcol,startx}. It draws LEFT=ceil(nw_offset/16) extra 16px columns on
  BOTH sides so the loop fills the 16:9 reveal margins with real adjacent
  stage, existing columns staying pixel-aligned with objects. Identity at
  4:3 and in the engine's own 512 hi-res mode (so the title is untouched).

Tooling: wide_full debug command + sw_wide_dump_full dump the entire
native-wide compositor surface (both vertical-double-buffer y-bands and both
reveal margins) for diagnosis.

Known follow-ups: object screen-X cull widen (actors in the margins);
top/bottom flicker + stale-margin refresh on the vertical double-buffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:57:32 -07:00
Matthew Stanley 1f6713bfc0 overlay cache: auto codegen-hash in the cache path (cg<N>_<hash>)
The manual PSX_OVERLAY_CODEGEN_VER must be hand-bumped on every emitter change;
when it isn't (e.g. between CPS emitter iterations), a stale-but-cgN DLL is reused
-> the v0.3.0 black screen. Now hash_codegen.cmake hashes the recompiler codegen
sources into a (gitignored) overlay_codegen_hash.h at build time; overlay_api.h
exposes PSX_OVERLAY_CODEGEN_HASH and the cache path becomes gcc/<arch-abi>/cg<N>_<hash>/.
The loader and compile_overlays.py read the same value, so any emitter change
auto-invalidates the cache (old dirs coexist, no stale load). Falls back to hash 0
when the header is absent (fresh checkout) — identical to today's single-cgN path.
Verified: build emits hash 0x347751c1; autocompile + loader both use cg4_347751c1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:57:52 -07:00
Matthew Stanley 905b52da57 game_options: restore-time value-range validation + format version
A persisted option value outside its declared [min,max] is now REJECTED at load
(the game's own default is used instead), so a corrupt/stale options file can no
longer inject an out-of-range value into a config global the game may use as a
jump-table index (wild jump = softlock). Ranges declared per option in
game_options.toml (min/max; absent => no validation, e.g. signed screen offsets).
Adds a format_version header (newer-than-known files are rejected wholesale).
Restore stays name-keyed (offset-drift-safe). Runtime-only, no regen.
Validated: corrupt values rejected (defaults applied); valid file restores intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:47:27 -07:00
Matthew Stanley 0ac63b655c vulkan: 3rd renderer backend (WIP) — emitter, GPU-direct present, full-frame readback
Vulkan backend alongside software + OpenGL: geometry/textured-prim
pipelines, GPU-authoritative VRAM image, deferred-submit + drain-at-present
perf model, GPU-direct present (vk_renderer_present_vram), and a
full-VRAM-pack readback path (ensure_cpu rect_adds the whole VRAM rect
before pack_flush so screenshots/guest VRAM reads reflect live content,
not just the incremental pack-dirty rect — readback-only, no per-frame cost).

Runtime plumbing: --debug-port / --renderer / --window-title CLI overrides
(multi-instance fleet), latency_ring, SPIR-V shaders + embed_spirv.py,
config_loader renderer selection, debug_server screenshot wiring.

All on feat/vulkan-renderer; snapshot in backup/vulkan-wip-20260617.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:56:12 -07:00
Matthew Stanley 2d468ee5b8 recursion(stage3): overlay (sljit + gcc-DLL) CPS parity — full native overlay coverage under CPS
RECURSION_BUG.md §25.5. Brings the overlay native path under continuation-passing so the
idle-freeze leak fix holds with overlay_cache=true (the production gcc>sljit>interp path).

Runtime CPS-mode flag g_psx_cps_mode (overlay_loader.c) set by a __attribute__((constructor))
emitted in the BIOS + game CPS dispatch (full_function_emitter.cpp / main_psx.cpp). Gates all
overlay/sljit CPS behavior; legacy unaffected.

sljit JIT (overlay_sljit.c), scoped:
- jr $ra publishes cpu->pc (was a bare return). jr rX already tail-transferred.
- call-containing fragments DECLINE to the interpreter (CPS-safe, depth-1) — leaf shards still
  JIT. (Full sljit call coverage / per-fragment entry-switch is future work.)

gcc-DLL path — FULL native overlay coverage (validated):
- compile_overlays.py --cps sets PSX_CPS for the recompiler -> CPS overlay C (tail-transfer +
  per-function entry-switch; --overlay mode only affects widescreen, not CPS). DISPATCH_PREAMBLE
  psx_syscall decl fixed void->int.
- PSX_OVERLAY_CODEGEN_VER 3->4: fresh cg4/ cache namespace; legacy unit-model DLLs (cg3) ignored.
- overlay_loader_dispatch: under CPS, when a dispatched addr is not an overlay ENTRY but lies
  inside a registered candidate's code range (a tail-transferred call's continuation),
  overlay_find_by_range() re-enters that function with cpu->pc=addr so its entry-switch routes
  to the block. gcc DLLs are the trusted tier (diff-validated at entry) -> run native directly.
  This is the CPS equivalent of the old unit-model that fixed the dwarf->overworld blue screen.

Validated: CPS overlay DLLs compile + load + dispatch_native>0; with continuation routing
dispatch_interp_fallback -> 0 (full native), ce_profile FLAT (6 KB), boots clean. Addresses are
KSEG0-consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:35:31 -07:00
Matthew Stanley 94a1966444 recursion(fix): universal continuation-passing — FIXES the idle-freeze host-stack leak (BIOS+game)
The complete §25 fix: make BOTH recompiler emitters emit guest calls as
TAIL-TRANSFERS driven by the unified flat trampoline + the guest's own $ra/$sp,
instead of nesting one host C frame per guest call. The host stack can no longer
mirror unbounded guest re-entrancy — the architectural root of the ~14-min idle
freeze. Gated gen-time by PSX_CPS; legacy codegen byte-identical when unset.

VALIDATED (user): full-CPS Tomba boots BIOS -> FMV -> New Game -> village idle and
plays; ce_profile cur_max_kb FLAT at 5 KB from frame ~9.9k through 56,890 (turbo
soak) — blew past the ~50k-frame freeze threshold with the host stack bounded
(legacy climbed +1.17 KB/frame -> 48 MB -> freeze).

KEY CORRECTION to the prior session's §25.1: there are TWO emitters. The BIOS
(full_function_emitter.cpp) was done last session but the §8 leaking chain
(func_8001A954 ...) is GAME code, emitted by code_generator.cpp (via main_psx.cpp)
— never converted. Also, §25.1's "continuations not in the dispatch table"
hypothesis was wrong (they were always added); the real boot regressions were
found by dumping the always-on fntrace ring on the abnormal psx_dispatch return.

Emitter changes (gated PSX_CPS):
- full_function_emitter.cpp (BIOS): BIOS A0/B0/C0 vector runtime-fallback
  tail-transfers instead of nested psx_dispatch_call (was the frame-0 exit).
- code_generator.cpp/.h (game): jal/jalr/jr-$ra/jr-table/branch-split/j-split ->
  tail-transfer (jalr captures rs before the link, alias-safe); each call's
  return point registered as a dispatchable continuation; entry-switch (keyed on
  cpu->pc) at the top of every function AND every overlapping-alias body. 0
  psx_call_contract / call_by_address emitted under CPS.
- main_psx.cpp: psx_dispatch_game_compiled gains continuation cases
  (case cont: cpu->pc=cont; func_owner(cpu)); entries clear cpu->pc.
- strict_translator.cpp: syscall emits `if (psx_syscall(...)) return;` so a void
  syscall falls through to the inline post-syscall jr $ra (real-HW EPC+4).

Runtime changes (the legacy "return via nested dispatch / cpu->pc=0" idioms that
exit the flat CPS trampoline):
- traps.c: psx_syscall returns int (0=void/fall-through, 1=transfer);
  Enter/ExitCriticalSection return 0; case-3 ChangeThread-success returns 0 — the
  fiber scheduler leaves cpu->pc=0 on resume, which was the frame-712 exit.
- cpu_state.h / overlay_api.h: psx_syscall signature void->int; overlay callback
  ABI bumped 4->5 (stale overlay caches rejected).
- dirty_ram_interp.c: gate interp_enter_compiled behind PSX_HAS_GAME_DISPATCH
  (un-breaks the BIOS-only build).
- main.cpp: dump the always-on fntrace ring tail + stack to psx_cps_exit_trace.json
  on an abnormal psx_dispatch return (diagnostic; CLAUDE.md ring-buffer model).

Docs: RECURSION_BUG.md §25.2-§25.4. Remaining: dwarf->overworld playtest;
Stage 3 (sljit/overlay CPS parity); then consider making CPS default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:45:02 -07:00
Matthew Stanley ae47e69902 recursion(oracle): oracle disambiguation DONE + SP-fntrace instruments; verdict = §11 universal fix (docs §23–§24)
Oracle disambiguation (RECURSION_BUG.md §23–§24). Traced one func_8001A954
invocation on real HW (psx-beetle) vs the recomp in the IDENTICAL Tomba idle
state (New Game village dialogue; object 0x801FD950, state/scene/jumptable=0).

VERDICT: real HW's per-frame chain is BALANCED — func_8001A954 is entered once
per frame from main_loop (JAL 0x8001A630, sp=0x801FE3E0) and RETURNS once per
frame (jr ra @0x8001A9E8 -> 0x8001A638, SP fully restored); no re-entry, the
overlay 0x80063864 never calls back (refutes §21/§22's "call-cycle"). The recomp
runs the same guest flow and the guest SP unwinds identically, but the HOST stack
leaks +1.17 KB/frame (ce_profile, live-reconfirmed: 20099->22215 KB / 1807 frames)
because the per-frame compiled chain (func_8001A954->AC00->B2B4->B5A8->func_80046264,
emitted as DIRECT func_X(cpu) host calls) re-enters itself via func_80046264's
CD-DMA-dirty hop and never unwinds.

native_stack early-trip (PSX_STACK_GUARD_KB=6144, frame 7592, 6 MB) pins the
leaking host cycle. Cross-checked: xprobe mixed_depth bounded at 6, dirty-insn/
dirty-block show NO literal func_8001A954 transfer, and a954=1/frame is a SURFACED
guest tail-transfer (j/jr) to func_8001A954 that the oracle proves real HW never
makes — i.e. there is both (A) a correctness divergence (spurious per-frame
re-entry) and (B) the architectural host-mirroring limitation that amplifies it.

Fix scope CORRECTED from §23's "quick contained runtime patch": the UNIVERSAL fix
is §11 — route in-range guest calls through the depth-managed psx_dispatch_impl
trampoline instead of mirroring them as direct host calls, so the host stack stays
bounded regardless of guest re-entrancy (real or fabricated). §12 (CD-DMA dirty
precision) and fixing the specific divergence are surgical mitigations, not
universal. Performance loss accepted for now (static recomp: right before fast).

Instruments added (runtime-only, no regen):
- SP capture in the fntrace control-flow ring on BOTH backends (GPR[29] read live):
  psx-beetle (beetle_libretro.cpp + beetle_debug_server.c) and the recomp
  (fntrace.h/.c + debug_server.c) — enables the func_8001A954 SP/RA-lifecycle oracle.
- psx-beetle default debug port 4380 -> 4382 (+ --port N override): 4380 collided
  with psxref and cdirecomp/CdiRuntime.exe; two LISTENers on one port silently
  misroute connections (the phantom "oracle keeps dying" early in the session).

Oracle tooling: the control-flow oracle is psx-beetle (Beetle interprets -> records
every J/JAL/JR/JALR), NOT psxref (no PC trace; the assumed emu_step/emu_trace_addr
never existed). Loads Tomba via: psx-beetle.exe --disc tomba.cue <bios> --port 4382.

Next: implement the §11 universal fix (codegen + full regen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:03:04 -07:00
Matthew Stanley e13a1f6660 ws backdrop: prim<->pixel correlation gate (ws_dbg_stretch) + void-layer ID
Live-tunable selectable stretch gate to identify which drawn prims form the
native-wide 2D-backdrop void: ws_dbg_stretch modes = OT-range / clut / tpage /
tagged / untagged-tex / all-tex, with per-frame matched + matched-tagged counts
(psx_ws_dbg_gate_frame_snapshot). Routes through the existing GL centre-stretch.

Confirmed live: the void layer is the early-drawn, SPRITE-TAGGED 0x65 background
grid (6 cols x 7, step-64, cluts unique to the backdrop); stretching it about
screen-centre fills the void with no inter-tile gaps (GL scales width too).
Tagged fills it; untagged (3D) does not. The prior content-match plan is
DISPROVEN (template transformed beyond recognition before draw) -- corrected
model recorded in BACKDROP_PRELOAD.md. Fix gate chosen: native-wide + tagged +
background-phase (draw-time), since backdrop X is written by a shared sprite
helper with no clean per-emit source signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:35:22 -07:00
Matthew Stanley 5ef2ecaccb ws backdrop: native-wide 2D stretch (mechanism proven) + debug tooling; root cause corrected
The 16:9 dwarf/flower-field void is native-wide 2D-backdrop POSITIONING, not a
column-loading problem. The earlier auto_backdrop whole-row preload is proven
IRRELEVANT (ws_backdrop_ring: whole 41-col row loads, void persists; margin off
vs whole-row pixel-identical). The flower-field is a fixed-screen 2D tile field
(static 11-bit vertices, ~4:3 width) that bypasses the GTE, so native-wide (which
widens 3D via the GTE) never widens it. Fix = geometric X-stretch about
screen-centre of the 2D backdrop in the GL wide-mirror — USER-CONFIRMED it fills
the void (with the gate disabled). Full record: BACKDROP_PRELOAD.md (2026-06-16).

Remaining: gate the stretch to ONLY the backdrop (currently also stretches Tomba/
HUD/3D). Path forward = draw-time gp0_cmd_source_addr / sprite-tag classification.

Runtime WIP checkpoint (all behind native-wide + runtime toggles, 4:3 untouched):
- gpu_gl_renderer.c: u_xscale/u_xcenter geometric stretch in the geo+tex
  wide-mirror passes; per-frame backdrop-phase reset moved to gl_perf_present_enter
  (both present paths); textured-only phase mode; per-prim trace + dbg counters.
- gpu.c: ws_backdrop_ring (always-on rewrite ring); ws_backdrop_margin live
  column-widen (proven irrelevant); psx_ws_backdrop_value whole-row path.
- dirty_ram_interp.c: ring capture (extent/camx/count/a1/dl) at the interp hook.
- debug_server.c: ws_backdrop_ring / ws_backdrop_margin / ws_backdrop_stretch.
- main.cpp: keyboard ALWAYS drives player 1 (OR'd with any controller).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:15:08 -07:00
Matthew Stanley c983ef521f ws backdrop preload: camera-tracked window widening (auto_backdrop)
[widescreen.cull] auto_backdrop — generic far-backdrop column preload for
16:9. A shared detector (runtime/include/ws_backdrop_detect.h) finds each
scrolling-backdrop column-window generator by its /96-magic + 0x176-camX +
sra,5 divide-tail invariant and records the window's START/END bound
instructions + width (window_cols). All four execution paths route those
bounds through psx_ws_backdrop_value(orig, is_end, window_cols), which widens
the camera-tracked window by exactly the 16:9 reveal (margin scales with
window_cols; identity at 4:3 so the build stays byte-identical with widescreen
off). gcc emit + sljit + dirty-RAM interp + overlay ABI v4 / codegen ver 3.

Also: CodeGenConfig::overlay_mode — explicit [widescreen.*] per-address site
shape checks now SKIP (apply only where bytes match) instead of hard-exit in
overlay mode, because overlay regions reuse the same address for different
code across scene variants. Main-EXE mode keeps the hard error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:48:15 -07:00
Matthew Stanley 9ed788f125 sljit persist-cache Stage 2: serialize / reload / re-validate to disk
Persist validated sljit shards across launches (SLJIT_PERSIST_CACHE.md). On JIT,
serialize the (position-independent, Stage 1) LIR and write it to
<cache>/<game_id>/sljit/<arch-abi>/cg<N>/<entry8>_<crc8>.sljit with a small header
(magic, format/helper-order versions, entry/lo/len/crc/size). At init, scan that
dir, deserialize + sljit_generate_code for THIS process, and register each as a
dll<0 candidate carrying the blob's crc; dispatch re-hashes live RAM against it so
a reloaded shard re-earns trust exactly like a fresh JIT. Priority gcc > sljit >
interp is unchanged (a gcc DLL still supersedes a reloaded shard via the obsolete
path). Persist is write-on-generate (measured negligible; crash-safe).

Threads the serialized blob through OverlaySljitResult -> CompiledFragment;
register_sljit_candidate gains a crc_override (reloaded shards use the header crc
since the overlay RAM may not be loaded at init). sljit_status exposes
reloaded / persist / reload-diagnostic counters.

Two gotchas found + fixed in testing (vs the original design notes):
- serialize MUST precede generate_code (generate sets compiler->error =
  SLJIT_ERR_COMPILED, after which serialize returns NULL);
- serialize with options=0, not SLJIT_SERIALIZE_IGNORE_DEBUG, so an
  argument-checks build can deserialize its own blobs.

Validated: two-launch test reloaded 12 shards (deserfail=0), ran them at a fresh
process base with no crash, gameplay renders correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:52:18 -07:00
Matthew Stanley 5589966161 sljit persist-cache Stage 1: position-independent shards (cpu-relative helpers)
Prerequisite for persisting sljit shards to disk (SLJIT_PERSIST_CACHE.md). The
JIT baked absolute host-helper pointers as SLJIT_IMM operands at three sites
(emit_helper2: memx/cop2; emit_ws_cull; PEND_CALL: call), so a shard serialized
in one process would call stale addresses when reloaded at a different ASLR base.

Route all four helpers (SLJIT_HLP_MEMX/COP2/WS_CULL/CALL) through a cpu-relative
table CPUState.sljit_helpers[], loaded via MEM1(R_CPU)[offset] instead of a baked
immediate, so serialized LIR holds zero absolute host addresses. The table is
appended at the END of CPUState (existing gcc overlay-DLL field offsets stay
put) and populated once at startup (overlay_sljit_init_helpers). In-session JIT
is unchanged behaviourally (one extra indirection per helper call).

Smoke-tested: forced sljit with the gcc cache moved aside, 12 shards JIT'd and
ran, title renders, no crash. Broader playtest folds into the Stage 2 reload test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:09:15 -07:00
Matthew Stanley 578144a77e QoL (issue #5): seamless analog/d-pad input + native OPTION settings persistence
Controller (item 3): present an analog (DualShock) pad by default for
analog-capable games so the left stick gives variable run speed; fold the
physical D-pad / keyboard arrows onto the left analog axes at full deflection so
they still move the character (avoids the Special Edition full-throttle-D-pad
bug). Add a launcher-editable analog-stick deadzone option (settings.toml +
input.ini + game.toml [controller] default), modeled on snesrecomp.

Settings persistence (item 6): deterministic restore-at-init + save-on-exit for a
game's native OPTION-screen settings, declared per game in a dedicated
game_options.toml. The recompiler rewrites each setting's boot-init store (sb/sh)
to route its value through psx_game_option_store(addr,val), applying a value
persisted from a prior session over the game default (identity when none, so a
fresh install is byte-identical). The runtime saves the live config globals to
<memcard_dir>/<game_id>.options on exit (atexit). Adds a game_options debug
command. Requires a regen for the hook to emit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:50:51 -07:00
Matthew Stanley 8d58b494b2 frame_perf: textured-vs-flat primitive split (tex_frac)
Measured Tomba village = 93% textured prims, so draw batching must target the
textured path (per-prim uv-limits/texpage/clut/depth move to vertex attributes
so consecutive textured prims coalesce into one draw). Flat batching alone
would touch only 7% of prims.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:33:10 -07:00
Matthew Stanley 1964447438 frame_perf: per-frame scene primitive count + per-draw time
Adds prims/frame and per_prim_us to the frame_perf aggregate. Proved Tomba's
village is draw-call bound: ~1000 prims/frame at ~14us of GPU time PER
glDrawArrays (a 1-triangle draw should be <1us) — pipeline-flush overhead from
re-binding state between every primitive. 16:9 is 2x slower purely because the
native-wide double-draw issues 2x the draw calls. Points squarely at draw
batching (group consecutive same-state prims) as the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:06:20 -07:00
Matthew Stanley 1e3244db4a frame_perf: per-frame GPU/CPU phase timing (GL timer queries)
Always-on per-frame ring in the GL renderer: two GL_TIME_ELAPSED queries
bracket each frame's scene draws vs the present clear+blit (true GPU time,
independent of CPU/GPU overlap), plus SDL perf-counter CPU wall time
(present-to-present total, present call). Exposed via the debug server
"frame_perf" command, aggregated over the recent ring and split by 4:3 vs
native-wide so a 16:9-vs-4:3 A/B reads off one query.

Built to answer where a frame goes without guessing. First use proved
Tomba's 16:9 village lag is the native-wide DOUBLE-DRAW (every primitive
re-issued into the wide FBO), NOT supersampling (SS2==SS1) and NOT the
compositor (present_gpu ~1ms): scene_gpu 16:9=28ms vs 4:3=17ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:00:23 -07:00
Matthew Stanley fff384da0c perf(GL widescreen): GPU-direct wide present (drop the per-frame readback)
The GL native-wide present (Option B) did glFinish + glReadPixels of the whole
wide FBO to the CPU and re-uploaded it EVERY frame — a hard GPU pipeline stall +
round-trip that made OpenGL 16:9 run far slower than the software renderer (whose
wide path is pure CPU, no GPU sync). Confirmed by isolation: GL 4:3 smooth, GL
16:9 slow, independent of SS/AA.

Add gl_renderer_present_wide_fbo: blit the displayed buffer's wide FBO straight to
the window with glBlitFramebuffer (GPU-side, V-flipped, into the letterbox),
exactly like the canonical gl_renderer_present_vram — no readback, no glFinish,
no re-upload. main.cpp routes wide GL frames here (falling back to the CPU readout
only if the wide surface doesn't exist yet). USER-VALIDATED smooth. The CPU
readback path (glb_render_wide_display) stays as the fallback + the wide_shot
probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:35:32 -07:00
Matthew Stanley 0c1f005c35 feat(widescreen,sljit): shared cull helper across backends, cache versioning, sljit default-on
Three coupled landings on top of the sljit merge:

1. Shared widescreen render-cull helper. auto_screen_x now emits a call to one
   runtime helper psx_ws_cull_sltiu(sx,imm) (gpu.c) instead of an inline expr, and
   the sljit JIT (overlay_sljit.c) + dirty-RAM interpreter (dirty_ram_interp.c)
   route flagged render-funnel sltiu 0x140/0x141 through the SAME helper. So an
   overlay's GTE render funnel widens identically whether it runs as a gcc DLL, a
   sljit shard, or interpreted. Helper is byte-identical to vanilla at 4:3
   (margin 0), so widescreen-off stays bit-exact. psx_ws_func_has_screen_cull
   gates the widen to real render funnels (sltiu 0x140 + sltiu 0xE0 signature).

2. Versioned overlay cache path. gcc cache moves to <game_id>/gcc/<arch-abi>/cg<N>/
   where N = PSX_OVERLAY_CODEGEN_VER (overlay_api.h), parsed identically by the
   runtime (overlay_loader.c) and compile_overlays.py so they can't drift. A build
   whose emitter output changed (e.g. this auto_screen_x change) reads+writes a
   FRESH dir and never reuses a stale DLL; old versions coexist (no auto-delete).
   Pre-1.0: legacy flat/unversioned fallbacks dropped.

3. sljit default-on (validated). gcc and sljit are complementary, not exclusive:
   gcc fills the cache via prebuilt DLLs + async batch (compile_fragment NULL),
   sljit JITs gcc-absent regions synchronously on-miss. New code_provider_sljit()
   exposes sljit for the JIT-on-miss gap-fill independent of the "active" backend;
   the dispatch gate fires whenever sljit is available (head<0 == gcc-absent), and
   apply_live_policy defaults live ON when sljit is available. Priority gcc > sljit
   > interp; shards still route through the same-state diff gate (device-touch +
   diverging shards pinned to interp) so default-on does NOT reintroduce the
   save-load wedge; a declined/failing shard falls to interp (never a crash).
   Verified live: live=1, 62 shards compiled, 2 declines->interp, native:interp
   ratio ~31%->~87%, gcc still primary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:36:43 -07:00
Matthew Stanley 52ddde7808 merge: feat/sljit-backend (sljit Tier-2 JIT) into master (native-wide) 2026-06-15 18:28:46 -07:00
Matthew Stanley 4c33e8dade sljit: refresh stale docs/comments + ISSUES #7
Documentation only. Prior sessions kept re-deriving state from breadcrumbs that
were no longer true:
- overlay_sljit.c / code_provider.{h,c}: corrected "declines every fragment" and
  "compile_fragment not yet called from dispatch" — the MIPS->sljit emitter is
  fully implemented and wired (then validated via the same-state diff).
- ISSUES.md: add Issue #7 (sljit live execution was unvalidated; the pure-live
  save-load wedge), root cause and fix recorded.

(SLJIT.md — the §11 forward plan and architecture clarification — lives at the
workspace root F:/Projects/psxrecomp/SLJIT.md, outside this repo, updated
separately.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:12:39 -07:00
Matthew Stanley 5972164b50 sljit: wire the non-dev production trigger (real toolchain probe + auto-live)
Root-cause class: a toolchain-less machine never self-improved via sljit on the
normal play path. Two blocks: (1) `auto` resolved to gcc on EVERY machine because
the discriminator was autocompile_configured() — "is a command STRING set" — and
the shipped game.toml always carries overlay_autocompile_cmd; (2) live sljit mode
was reachable only via a hand-set env var (dead public setter, no policy).

Fix:
- autocompile.c: autocompile_toolchain_available() probes PATH for a real C
  compiler (gcc/cc/clang). main.cpp passes
  `autocompile_configured() && autocompile_toolchain_available()` to
  code_provider_init, so `auto` picks gcc only when one is actually reachable,
  else sljit.
- overlay_loader.c: overlay_loader_apply_live_policy() (called from main.cpp
  AFTER backend resolution) defaults live ON when the resolved backend is sljit
  — so a toolchain-less box runs VALIDATED shards live (self-improves on the
  normal path); PSX_OVERLAY_SLJIT_LIVE still overrides. The env-only block in
  overlay_loader_init is removed (the policy fn subsumes it).

Runtime-only (no regen). Refs SLJIT.md §11 Phase C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:12:27 -07:00
Matthew Stanley 8490d8b4cb feat(widescreen): native-wide via separate wide compositor surface (SW) + draw-census
Render the genuine 16:9 FOV at correct proportions instead of squash-then-stretch,
using a separate per-framebuffer wide surface so canonical PSX VRAM stays faithful
(4:3 / non-opted games byte-identical; the wide canvas only exists when opted in).

- Wide-aspect mode descriptor (off / squash / native-wide) in gpu.c; opt-in via
  [video] aspect_ratio. GTE fed the 4:3 ratio (identity, no squash) in native-wide;
  canonical draw_offset/draw_area left faithful (no VRAM widening, no wrap).
- Separate wide present-surfaces (gpu_sw_renderer.c) keyed by back-buffer base:
  framebuffer prims mirrored at local x = vram_x - base_x + OFFSET; margins cleared
  via the frame-clear mirror; present reads the displayed buffer's surface 1:1.
  Eliminates the A/B bleed + flicker (and the segfault) of the shared-canvas attempt.
  gr_* vtable extended; GL backend reports unsupported -> canonical present until
  the GL port (task 8).
- psx_ws_x_margin native-wide-aware (reuses the existing [widescreen.cull] emit).
  CONFIRMED a no-op for Tomba's edge objects: they spawn via a non-FUN_80022E44
  path (the foreground classifier is already 16:9-safe). See handoff.
- Draw-census extended with per-prim screen-X extent + back-buffer base for the
  native-wide void diagnosis; ws_nw TCP toggle; gpu_state ws.mode/nw_extra.
- Controller: "auto"/"gamepad"/"controller" device = first available SDL pad.

WIP checkpoint. SW compositor works: flicker fixed, faithful, terrain platform
fills the wide frame. REMAINING: discrete foreground OBJECTS (rock pillar, tree
branch, bushes) + the parallax backdrop are still culled at the 4:3 edge via a
separate generation/spawn path (wtrace the OT display-list writers next); GL
compositor port. Detail in NATIVE_WIDE_PLAN.md + the session handoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:49:30 -07:00
Matthew Stanley e5aea83496 sljit emitter: broad coverage — jr-tables, GTE/COP2, unaligned mem, link branches
One batch widening to cover most remaining overlay instruction classes;
validated end-to-end via the live differential rather than per-op.

- jr rX (computed / jump tables): CTRL_TAILJUMP — capture the target before
  the delay slot, then cpu->pc = target and return; the dispatch loop
  re-dispatches (matches the interpreter's jr). jr $ra stays the clean-
  return terminator. (A terminator-less tail-dispatch fragment still
  declines — safe.)
- GTE/COP2: MFC2/CFC2/MTC2/CTC2/cmd (op 0x12), LWC2 (0x32), SWC2 (0x3A)
  via psx_sljit_cop2(), a runtime helper mirroring the interpreter exactly.
- Unaligned mem: LWL/LWR/SWL/SWR via psx_sljit_memx(), likewise.
- BLTZAL/BGEZAL: link $ra = pc+8 before the delay slot, then branch.

Helper-emitted classes get parity by construction (the helper IS the
interpreter's code). Harness 66/66 (regression); runtime builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:52:29 -07:00
Matthew Stanley ad1e5a223f sljit emitter: jal/jalr with the call contract (+ KSEG entry fix)
The big coverage unlock — the emitter now accepts functions that call
others, not just leaves.

- New runtime helper psx_sljit_call(cpu,target,return_pc,check) in
  overlay_loader.c, mirroring the dirty-RAM interpreter's call path
  EXACTLY (psx_dispatch_game_compiled / overlay_loader_call_native /
  psx_dispatch_call + g_psx_call_bail + psx_call_contract). Returns 1 if
  the shard must return immediately (transfer/bail unwind), 0 to continue.
- Emitter: jal/jalr -> CTRL_CALL. Link reg + (jalr) dynamic target are
  written BEFORE the delay slot; the helper is called AFTER it via S2
  (helper address) with the result driving a conditional return. jal uses
  an absolute target, jalr a dynamic one captured in S1.
- KSEG integration fix: the loader now passes the VIRTUAL entry to the
  emitter (was passing phys with image_base_vram=0), so return_pc and
  jal/J targets carry the KSEG bits the guest uses. The standalone harness
  passed a virtual entry, which is why it stayed green; the live
  differential caught the phys/virtual mismatch (saved $ra off by the
  0x80000000 KSEG bit).

Verified: sljit_emit_test.c extended (jal, jalr, transfer/bail) -- 66/66
pass. Full psx-runtime build clean. Live differential re-validation of
the KSEG fix is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:52:29 -07:00
Matthew Stanley 4e932d05fe sljit §7 step 4 (first slice) + step 5: leaf-function emitter, diff-gated
The MIPS->sljit emitter, paralleling dirty_ram_interp.c's exec_one but
emitting host code. First slice = single-block LEAF functions ending in
`jr $ra`; everything else aborts the whole fragment to the interpreter
(precision over recall).

- overlay_sljit.c: memory-backed GPRs, 32-bit ops (R3000A wraparound),
  loads/stores via icall through the cpu read/write callbacks (same
  MMIO/page-watch/dirty-tracking path as the interpreter). Opcodes:
  SLL/SRL/SRA/SLLV/SRLV/SRAV, ADD(U)/SUB(U), AND/OR/XOR/NOR, SLT(U),
  ADDI(U)/SLTI(U)/ANDI/ORI/XORI/LUI, LB/LH/LW/LBU/LHU/SB/SH/SW, SYNC.
- compile_fragment now returns CompiledFragment{fn,code_lo,code_len};
  overlay_loader registers an sljit candidate (crc hashed from the live
  bytes it JIT'd from, same live-byte validation as gcc candidates).
- Step 5 wiring is VALIDATION-GATED: JIT-on-miss fires only when
  backend==sljit AND the same-state differential is armed (never in a
  shadow run). Normal play stays byte-identical; live sljit execution
  stays off until the differential gate passes.
- Debug: sljit_try <addr> probe; sljit_status reports shards_registered.

Verified: runtime/tests/sljit_emit_test.c (links overlay_sljit.c +
sljitLir.c only) JITs hand-built leaf functions and checks output —
26/26 pass incl. gpr0 invariant, sign/zero-ext, signed/unsigned compare,
and the decline cases. Full psx-runtime build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:52:29 -07:00
Matthew Stanley ea98402322 sljit §7 step 2: CodeProvider abstraction around the gcc path
Introduce one backend-agnostic code-production seam the overlay
capture/dispatch spine talks to, and make the validated gcc spawn->DLL
path its first implementation. Pure refactor; gcc stays the default and
its behavior is byte-identical.

- New code_provider.h/.c: a CodeProvider struct exposing BOTH production
  lifecycles so the interface isn't gcc-shaped -- batch/async
  (request/busy/poll_main, the gcc model) and sync-on-miss
  (compile_fragment, the sljit model). compile_fragment is defined and
  implemented per-provider but not yet called from dispatch (step 5).
  gcc provider = direct pass-throughs to autocompile_* (compile_fragment
  NULL: a compiler spawn is too slow for the dispatch path); sljit
  provider wraps overlay_sljit_* (still declines every fragment).
- Wire up overlay_backend_resolve, which was previously dead code:
  code_provider_init() resolves the backend at overlay-cache init and
  caches the active provider; code_provider_active() is the accessor.
- Add [runtime] overlay_backend config (auto|gcc|sljit; empty=auto) so
  resolution has a config source alongside PSX_OVERLAY_BACKEND.
- Route the 3 direct autocompile_* call sites through the active
  provider: overlay_capture.c autocapture tick, main.cpp per-frame poll
  + the init call, debug_server.c autocompile_run (now reports backend).
- runtime.cmake += code_provider.c.

Builds + links clean (TombaRecomp/build-dev, MinGW gcc 15.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:52:29 -07:00