Commit Graph

160 Commits

Author SHA1 Message Date
Matthew Stanley 184a18a2a8 Merge fix/mmx6-sprites: mirrored-2D uv sampling fix + shared uv model + display ring
- gpu_uv.h: the ONE shared PS1 uv-sampling model for GL/VK/SW (limits +
  Beetle Calc_UVOffsets mirrored-2D compensation)
- fixes the MMX6 sprite-sliver class (detached 1px sprite-colored lines at
  quad seams of X/Y-flipped sprites; pink line under wheel mechaniloids):
  poly path AND the gpu.c axis-aligned-mirrored-quad -> rect_scaled path
- always-on display ring (display_ring_get/aux/stats): frame-exact display
  + full-VRAM capture for cross-renderer forensics
- user-validated clean on MMX6 (GL); cross-title gate: Tomba GL boot +
  attract verified clean against this branch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:27:52 -07:00
Matthew Stanley e8ad8ade3d debug: always-on display ring + full-VRAM aux capture
New always-on ring in the debug server: every vblank captures the display
area's raw 15-bit pixels AND the full 1024x512 VRAM as the ACTIVE RENDERER
sees it (GL: FBO truth via fbo_peek; SW: CPU truth), 64 frames of lookback.
Commands: display_ring_get (frame -> PNG), display_ring_aux (frame -> raw
u16 VRAM blob), display_ring_stats.

Purpose: FRAME-EXACT visual forensics. screenshot_file returns whatever is
on screen when the command lands (1-3 frames late), which cannot support
cross-renderer same-frame comparison; the ring can. Proven use (MMX6 sprite
triage): pulled the SAME deterministic frame from a GL run and a software
run, micro-cosimed the suspect sprite prim from the GP0 ring against the
same-frame captured texels/CLUT, and established GL and SW render the scene
pixel-identically with byte-identical texture content - reclassifying the
reported artifact from renderer bug to (pending Beetle oracle) game content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:26:31 -07:00
Matthew Stanley 0cb2a77bff fix(irq): gate deferred thread-switch off dirty-pump and low-kernel sites
Ape Escape memcard save/load. The deferred in-exception cooperative
thread-switch could be honored at a boundary where a candidate resume PC
was committed but the live CPUState (stack/GPRs) still described the
previous local frame, corrupting the resumed thread.

- Keep the deferral pending when g_cosim_dirty_pump_site != 0: the
  dirty-RAM interpreter pump sites expose committed PCs for IRQ timing
  but the matching CPUState may not be materialized yet. Honor the
  switch only once execution reaches a normal interrupt poll (site 0).
- Do not defer when the interrupted EPC is in low BIOS/kernel space
  (< 0x10000): that code is already scheduler code, and deferring it
  re-enters the same VBlank EPC forever, starving the target thread.
- debug_server: name thread-event kinds 31/32/33 (defer / escape /
  stale) for observability.

Validated: Ape Escape save+load confirmed working by user; MMX6 and
Tomba 1 regression-checked with no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:50:09 -07:00
NyperYuhgard ae89a8fdd6 compat with me 2026-07-08 00:01:29 -05:00
Matthew Stanley 4ca767f90a obs(debug): phase profiler, idle-skip toggle, shadow-diff dump counters
TCP surfaces for the new instrumentation:
- phase_profile / phase_hot: a ~1 kHz background sampler over g_exec_phase
  reporting wall-time share per backend, with a since-boot histogram and
  windowed diffing;
- idle_skip: idle-loop cycle-skip counters and a runtime enable/disable
  toggle;
- overlay_shadow_dump gains interior_gated / in_shadow / native_exec /
  escapes / escapes_native for the shadow-diff hardening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:39:51 -07:00
Matthew Stanley f29b0b7e74 diag(wtrace): always-on capture of Ape LOAD-GAME libcard op-state
Add compiled-in always-on wtrace ranges (slots 16-21) for the Ape memcard
higher-layer investigation: libcard op struct count/state (0x800b4e30),
save-name ptr+buffer (0x800b4e50), op-handler ptr (0x800b4ed0), top-scene
SCENE/TRIG (0x800e3880), game card-menu substate (0x8013af50), and kernel
card-driver per-state byte (0x7264). Per the ring-buffer rule these extend
the always-on capture path (query via wtrace_dump addr_lo/addr_hi) rather
than probe-time arming.

Checkpoint of the memcard save-stall investigation (not yet fixed): both
failure modes (hard freeze at 0x801382CC register smear; soft stall = async
read 0x25AC aborts at MC_ID2) trace to one root: the interrupt-driven async
card read per-byte SIO ACK-IRQ clocking is unreliable under the HLE-scheduler
+ dirty-interp + IRQ-delivery interaction (Stage-5 SIO axis). Low-level
protocol + completion bridge confirmed working; masking, low-level
event-delivery, compiled-resume-latch, and deferred-switch all ruled out.
Full detail in .claude/GOAL.md. Fixes #2/#3 kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:13:47 -07:00
Matthew Stanley b7880141a6 feat(debug): screenshot tool emits PNG by default on both backends
Extract the runtime's dependency-free RGB->PNG writer into a shared header
(png_write.h, static linkage so each self-contained binary keeps its own copy,
no new link deps). The Beetle debug server's screenshot_file now writes a PNG
(top-down 24bpp RGB) instead of a BMP and defaults its path to .png, matching
the runtime backend — so screenshots are viewer/Read-friendly with no manual
BMP->PNG conversion. Runtime already emitted PNG; this unifies the encoder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 09:15:16 -07:00
Matthew Stanley a6afa3ef8a diag(irqctx): record entry_sp + pump_site per IRQ delivery
Extend the always-on irqctx_ring with the live sp and dirty pump-site captured
at exception entry — the interrupted-thread TCB save records exactly this
(resume_pc=take_pc, sp=entry_sp). A pair where take_pc is a dirty return point
(0x801384AC) but entry_sp belongs to a native callee's frame is the corrupt
snapshot behind the Ape memcard native<->interp resume desync. Reader typedef +
JSON emit updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 09:11:03 -07:00
Matthew Stanley 938f89c8d8 wip(ape): in-exception thread-switch handling for the memcard scene
Ape Escape runs its own cooperative scheduler inside the kernel exception
handler (VBlank callback moves PCB[0]); handle the in-exception thread switch,
bump the IRQ-context ring to 4096 with a matching debug_server dump cap, and
carry the scheduler-escape trace state. WIP snapshot on the Ape branch (the
thread-smear investigation), preserved so the shared checkout can return to
master cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:31:18 -07:00
Matthew Stanley d31be0e758 gte: dome-locate probe (ra-based) + ws_dome_probe TCP cmd
Tally which GUEST function (guest ra at gte_execute, not the libgte leaf
0x80000F40 that g_debug_current_func_addr reports) projects far/sky vertices,
to identify the sky-draw fn for the per-function dome-expand bracket. ws_dome_probe
on=1 thr=<SZ> arms; dump lists ra + count + max_sz. FINDING (Ape): the title sky
is a curved dome with a RANGE of depths, so the depth-gated dome-expand only
scales its farthest verts (warps it, can't cleanly fill) — confirms the per-
function bracket is required. Lead: far-sky verts came from game code in the
~0x80138xxx overlay region. Probe stays as a tool; Ape dome wiring is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:30:11 -07:00
Matthew Stanley 402fe01b11 gte: native-wide sky-dome expand (opt-in, off by default) + ws_dome tuning cmd
Depth-gated dome-expand: in native-wide (no squash), scale far-depth
(SZ >= ws_far_threshold) GTE-projected X outward from the projection centre by
(3*num)/(4*den) so a FINITE sky-dome mesh grows to fill the wider FOV instead
of leaving black corners. Live-tunable via the ws_dome + ws_far_threshold TCP
commands; off by default (no config wires it on), so inert unless toggled.

NOTE: depth alone does not cleanly isolate Ape's title dome (its sky is not the
uniquely-farthest layer — distant world geometry shares the depth range), so the
Ape title needs the per-function bracket route (find the dome draw fn, bracket
with gte_ws_set_dome_expand like the far-backdrop unsquash_funcs). The mechanism
lands here; the Ape wiring is a follow-up. Correct as-is for titles whose sky IS
the farthest layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:30:10 -07:00
Matthew Stanley c6dc87fdfa native-wide GL: fix center-blit double-buffer artifact + add GL wide_full TCP tool
- wide_blit_center now copies the canonical column into the wide-surface CENTRE
  over the FULL VRAM height, not just [disp_y, disp_h]. Ape vertical-double-
  buffers (display_y 0<->256, both bands in one wide surface); a scrolling
  cutscene (the cityscape intro) exposed centre rows outside the display band,
  which the disp_h-only blit left black -> horizontal black-band tearing. Full-
  height blit makes the wide centre bit-identical to the full-mirror output for
  every band. Fixes the regression; keeps the perf win.
- sticky gte_game_mode threshold/hysteresis retained (cutscene flicker fix).
- New GL wide-surface inspection over TCP: wide_dump_full backend method
  (glb_wide_dump_full) so the `wide_full` command dumps the ENTIRE GL compositor
  surface (both double-buffer bands + margins), matching the SW path. wide_shot
  (displayed band) already worked on GL. Lets native-wide be debugged entirely
  over the TCP server, no window focus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:30:10 -07:00
Matthew Stanley ee09ab9bbb widescreen: generic Ape-Escape-class support + native-wide GL perf + cutscene flicker fix
Adds full 16:9/21:9 native-wide support for fully-3D titles (Ape Escape,
SCUS-94423) as a generic, opt-in, 4:3-byte-identical framework capability, plus
a native-wide GL compositor perf fix and a cutscene present-mode flicker fix.

Widescreen (generic, per-game opt-in via [widescreen*]):
- Shared screen-extent cull detector (runtime/include/ws_cull_detect.h) used by
  all three backends (recompiler, dirty-RAM interp, sljit). Per-game signature
  immediates ([widescreen.cull] screen_w_imms/screen_h_imms) — Ape's funnel
  tests 0x181/0xF1 on its 368px display, not Tomba's 0x140/0xE0.
- Three funnel idioms widened: unsigned per-vertex sltiu (psx_ws_cull_sltiu),
  signed min/max slti right edge + bare-bltz left edge (psx_ws_cull_slti/bltz,
  new helpers + SLJIT_HLP appends), and billboard center+/-halfwidth. Explicit
  [widescreen.cull] slti_sites for X-only funnels the auto-detector can't
  qualify.
- gte_game_mode: GTE-activity gameplay detector for 3D titles with no sprite-tag
  helper (gte.cpp notes every RTPS/RTPT). STICKY (>=3 verts, 45-frame hysteresis)
  so sparse cutscene frames never flip to a 4:3 pillarbox = fixes intro flicker.
- nw_hud_corners: push outer-third screen-space HUD sprites to the true wide
  corners in native-wide (else inset by the reveal).
- nw_backdrop: stretch a full-frame axis-aligned 2D backdrop quad to fill the
  wide frame (for titles with a flat sky plane).
- Pattern-scanned interp/sljit widen hooks now gated per-game (auto_screen_x /
  auto_backdrop): fixes an ungated auto_backdrop false-positive that rewrote a
  live GPR = the ~24s native-wide crash on Ape.

Native-wide GL perf (fixes 16:9 frame drops):
- The wide surface centre equals the canonical 4:3 framebuffer, so wide_blit_center
  copies canonical->wide-centre at present (both present paths) and the per-prim
  mirror SKIPS prims fully inside the 4:3 frame (mirror_x/geo/batch_center_only).
  Only reveal-margin prims re-rasterize. Peak frame time 38.8ms -> 17.5ms, no
  vsync misses. Toggle gl_wide_fast; 4:3 untouched (all gated on g_wide_cur).

Runtime-only except the recompiler cull emit (regen needed for cull widening).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:30:09 -07:00
Matthew Stanley d78e7a784f text_xlate: [[vram_patch]] pre-rendered-strip layer + GP0-ring builder attribution
Two interlocking pieces from the Tsumu HUD/menu-label investigation:

- GP0 ring builder attribution (gpu.h/gpu.c/debug_server.c): op-0x80
  entries now carry a bounded guest-stack unwind (bld[6]) + copy-time
  $sp (csp); gpu_frame_dump emits func/ra per entry and csp+bld[] for
  copies. This cracked the HUD compose: whole label strips are
  MoveImaged from the glyph sheet (src 834/852,352 -> HUD cache y=32).

- [[vram_patch]] (text_xlate.cpp/.h + gpu.c hook): third translation
  layer for text that exists only as pre-rendered pixels in an
  uploaded texture asset (Tsumu HUD labels, menus, prompts live in
  the glyph-sheet image; the 144-glyph font has no katakana at all).
  On every GP0 0xA0 upload completion the configured rects are
  verified halfword-for-halfword against expected JP pixels and only
  then rewritten with the target-language pixels via the renderer
  facade (software/GL/supersample coherent). Re-applies on every
  re-upload; verify-fail leaves foreign assets untouched. Debug:
  xlate stats gains vram_patches/vram_applied; new "vpatch"
  force-apply subcommand for config iteration without rebuild.

Verified on-screen: HUD "STAGE: 1-1 / BATTERY: 100", pause menu
(CONTINUE/RESTART/GIVE UP/CHANGE CONTROLS, TARGET BATT), title-menu
strips. docs/STRING_TRANSLATION.md Appendix Z documents the model,
mechanism, authoring recipe, and remaining strip backlog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 01:41:42 -07:00
Matthew Stanley 46d43555e6 runtime: on-the-fly string translation mechanism (capture + apply)
Implements the framework from docs/STRING_TRANSLATION.md: an always-on
capture inventory + a draw-time apply hook, both hung off the psx_dispatch
chokepoint via fntrace_record (no generated-code edits, no BIOS regen,
works in Release).

- text_xlate.{h,cpp}: EncodingProfile abstraction (shift_jis profile:
  SJIS record reader with NUL/0xFFFF terminators + halfwidth-kana + control
  framing; UTF-8 -> fullwidth-SJIS transcoder so English renders with the
  font's existing fullwidth Latin, zero glyph injection). Capture inventory
  (FNV-1a64 keyed) queryable over TCP; apply repoints the string-arg reg at
  guest-stack scratch (KV-gated: only known records are swapped, stray
  pointers untouched; own scratch is skipped to avoid recapture).
- fntrace.c: call text_xlate_on_dispatch() at the dispatch chokepoint.
- config_loader: [localization].language (+ [runtime].language alias);
  env PSX_LANG overrides. Loads translations/*.toml per project.
- main.cpp: text_xlate_init() at game-config load.
- debug_server: "xlate" command (stats|dump|todo|reload) — observability
  over TCP, no log files.

Verified: builds (Release), Tsumu boots headless, capture live
(distinct records accumulating), apply disabled until a table is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:42:07 -07:00
Matthew Stanley db4016b685 cdrom: model read-start latency + far-seek completion delay (MMX5 FMV fix)
Give ReadN/ReadS an initial read delay before the steady-state sector
cadence, classify SetLoc as near vs far and drive SeekL/SeekP completion
timing off it (the far-seek case PCSX calls out for Rockman X5), and fix
GetlocP to report the drive sub-Q position during an active read rather
than the last data-ready sector.

Previously the CD data-ready cadence was too eager, so the MMX5 intro
FMV / CD script never accumulated a valid decode window and the movie
player bailed with zero decodes. With faithful read/seek latency the
intro cutscene now decodes and plays.

debug_server: add a cdrom_command_history TCP command exposing the CD
command/seq ring (kind, cmd, params, read MSF, pending/queued state, pc)
to back this investigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:51:32 -07:00
Matthew Stanley 4e6208a6e6 debug/fmv: always-on CD data-ready + CD-IRQ-delivery counters in fmv_state
Add two always-on counters (per the no-printf / ring-buffer rule) to diagnose the
MMX5 intro-FMV stall:
- cdrom.c: s_dataready_fires — CD INT1 (data-ready) raised per streamed sector,
  exposed via cdrom_get_dataready_fires().
- interrupts.c: g_cdrom_deliver_count — CD IRQ (IRQ_CDROM) exceptions actually
  taken by the guest.
Both surfaced in the fmv_state debug command (cd_dataready_fires / cd_irq_delivered).

These proved CD INT1 + CD-IRQ delivery are NOT the MMX5 FMV blocker (fires=627,
delivered=746 during the movie while the STR ring still never commits a frame) —
narrowing the bug to the CD-DMA-ch3-completion -> frame-commit path. Harmless
observability; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:51:32 -07:00
Matthew Stanley e8b6ddc343 irq: gate post-exception cooldown on guest cycles, not check-calls (MMX5 fork fix)
The post-exception IRQ cooldown counted psx_check_interrupts CALLS. A call is a
backend-dependent unit: compiled checks at block edges (plus extra sites like a
`jr ra` return transition), the dirty-RAM interp checks per-instruction. So the
per-call countdown reached zero at a DIFFERENT guest cycle in each backend, the
next IRQ was delivered at a different cycle/EPC, and the two backends forked
(cosim first-divergence cp 3259 @ cyc ~213.5M). Convert the cooldown to a
guest-cycle DEADLINE (post_exception_cooldown_until): guest cycles advance
identically in both backends (shared, ruler-validated cost model), so the
delivery decision is now backend-independent. Magnitudes preserve prior
breathing room (claimed ~= old 8 blocks, unclaimed ~= old 500 blocks).

Also:
- gpu: GP1(00h) reset no longer clears VRAM. VRAM survives a GPU reset on real
  hardware; only power-on gpu_init() clears our backing store (gpu_reset_state).
- codegen hash: include the emitter/translator/discovery .cpp TUs, not just
  their .h. A change confined to full_function_emitter.cpp altered generated
  dispatch+overlay code while leaving the hash unchanged -> stale overlay-cache
  hazard (same class as the ws-emission-not-in-hash gap).
- debug: ring-buffer WINDOW-QUERY primitives -- frame_lo/frame_hi filters on
  wtrace/mmio dumps + a capture-freeze frame so a causal window's tail is not
  evicted by a post-window event storm before a probe reads it (query the
  always-on ring for the window of interest, not arm-and-hope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:51:32 -07:00
Matthew Stanley dcb63cb570 gpu/a0_history: add per-upload frame stamp (load-vs-upload ordering proof)
a0_history had no timestamp, so an upload's timing couldn't be compared against
the CD-load timeline. Add frame_stamp (s_frame_count at the upload) to the entry,
return it from gpu_get_a0_src, and emit "frame" in the a0_history JSON.

This pinned the MMX5 font bug: the (896/832/960,256) font uploads run at frames
1374/1381/1388 reading zero/0x77 garbage, while the CD delivers that buffer run
(LBA 10473 -> 0x800D5208..0x800DD208, real data) at frame ~1508 — the upload
races ~130 frames ahead of the font's CD load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:51:32 -07:00
Matthew Stanley ba33564dc5 wtrace/parity: attribute DMA-sourced RAM writes to channel + kick PC
DMA-to-RAM writes were captured (all dma.c writes route through psx_write_word
-> the wtrace/parity hooks), but with misleading provenance: `pc`/`ra` came from
g_debug_last_store_pc, i.e. whatever CPU store ran last — meaningless mid-DMA,
and worse for async channels whose RAM writes run frames after the kick. There
was no way to tell a DMA write from a CPU store, or which channel produced it.

Fix (framework-level, all channels):
- dma.c: capture the DMA kick PC (the CHCR store) into g_dma_initiator_pc at
  trigger, stash per-channel, and re-publish it for the async channels whose RAM
  writes run in a deferred advance step (CDROM ch3; MDEC-out ch1, which also
  lacked the g_dma_exec_depth marking entirely and is now wrapped like CDROM).
- debug_server.c: WriteTraceEntry gains dma_ch (0-6, or -1 = CPU store); when a
  DMA is in flight, the entry's pc is the kick PC, not the stale store PC.
  Emitted as "dma_ch" in wtrace_dump.
- memory.c: parity note_write uses effective_store_pc() (kick PC during DMA).

Verified on MMX5: CD-streamed font buffer fills (0x800D5208) now tag dma_ch=3
with pc=0x80067264 (the CD loader kick); BIOS scratch CPU stores tag dma_ch=-1.
This is what let write-provenance see the font buffer's clear->CD-load->clear
cycle and localize the upload-races-the-load bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:51:31 -07:00
Matthew Stanley c7a7a67d0e gpu/a0_history: capture true LoadImage source pointer (s2), not just the RECT
a0_history recorded $s1 and labeled it "source data pointer", but for the MMX
LoadImage (FUN_80069dfc) $s1 is the scratch RECT descriptor (x/y/w/h, reused
every call) — the true pixel source is $a1, preserved in $s2 (`move s2,a1` at
entry). The mislabel sent font-load investigation to the wrong buffer.

Capture s2 (gpr[18]) = true source, plus a0/a1, expose via gpu_get_a0_src, and
emit s2/a0/a1 in the a0_history JSON. This is what let us find the real MMX5
font source buffers (0x800D5208 / 0x800DD208) and prove the upload reads them
during a post-clear/pre-CD-load zero window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:51:31 -07:00
Matthew Stanley 4c7451d829 debug: suppress lockstep recording across ALL debug command handlers
The #2 lockstep comparator's record trace was polluted by debug-server
observer reads: handle_read_ram's psx_read_byte loop (and any other
handler touching guest RAM) executes at debug_server_poll safe points,
which can land inside an armed leader-to-leader record window. The
observer ops entered the trace as phantom guest ops and produced false
read-addr divergences whose 'compiled_actual' address was exactly the
TCP client's polled address (seen live on MMX5: probe reads of
0x8006FC24 and 0x800D1C00 flagged block 0x62FE8).

Fix at the process_command chokepoint: wrap every handler in
ls_suppress_begin/end so no individual handler can forget. Verified on
MMX5: with probes polling every 15s, 189M+ blocks check clean past the
point where the leak previously false-flagged at ~58M.

NOTE (negative result, do not re-attempt): also making the memory.c
g_ls_mode==2 replay fast-paths honor g_ls_suppress_record ("observer
reads must not consume trace ops") corrupts guest RAM — replayed guest
ops can run under a suppress held by in-guest instrumentation, and the
fall-through turns their side-effect-free replay writes into real
writes (deterministic FATAL ADEL at the MMX5 title press, IntRP walk
from addr 7). Replay integrity needs no suppress check: handlers only
run at safe points, never inside ls_at_leader's replay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:51:31 -07:00
Matthew Stanley 9179c1ad5d debug: stop observability from perturbing SIO; add fmv_state command
debug_server_record_frame (every vblank) and the sio_state handler read
SIO_STAT/CTRL/RX via the GUEST-BUS sio_read(), which has hardware
side-effects: sio_tick(0) advances the delayed-IRQ state machine, reading
SIO_STAT decrements sio_ack_visible_reads and clears the ACK bit, reading
SIO_RX_DATA pops the RX FIFO. So the observer silently mutated the SIO/pad
handshake every frame in dev builds (the cardinal observability-alters-state
violation the event_ring header itself warns against).

Add side-effect-free sio_peek_stat/ctrl/rx_data() and use them on the debug
path; the guest bus keeps sio_read() with its real side effects. Also add an
fmv_state TCP command reporting the resolved config path + auto_skip_fmv +
frame-count-table params + live MDEC/XA/pad state, so the FMV-skip + config
state can be observed rather than inferred.

Note: this removes a real per-vblank SIO perturbation but is NOT the full fix
for the dev-build FMV-skip divergence (residual is the cycle-timing-core
class; see precise_irq_slice_state). It is correct observability hygiene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:51:31 -07:00
Matthew Stanley e9b4ed5bbf gl: frame_perf CPU-side attribution - flush/wide wall time + plumbing counters
Tomba2 16:9 GL reproduced the reported collapse (12-14fps) and the GPU-side
split EXONERATED the GPU: scene_gpu ~10ms, mirror ~0.2ms, while emu_cpu is
~82ms (vs ~15ms in squash). gl_ws_ablate=1 (mirror passes fully off) leaves
it at ~75ms, so the wedge is CPU work outside the mirror draws. The GL
submission paths run INSIDE guest emulation (GP0/DMA processing), so driver
CPU cost lands in emu_cpu where no GPU timer sees it.

frame_perf now reports, per wide/4:3 frame class: CPU wall inside
flush_tex_batch (cpu_flush_ms_avg), CPU wall inside the glb_wide_* entry
points (cpu_wide_ms_avg), batches/frame, wide target sets/frame, and wide
FBO creations/frame (a nonzero steady value = surface recreation churn).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 20:23:06 -07:00
Matthew Stanley f5362f4ce9 gl: frame_perf mirror-pass GPU split + gl_ws_ablate ablation toggles
Native-wide perf attribution instrumentation (permanent):
- Each wide-mirror pass (tex-batch mirror, flat-geometry mirror, overlay
  full-width rect, wide_clear) is bracketed with a GL_TIMESTAMP pair
  (glQueryCounter; TIME_ELAPSED cannot nest inside the frame scene query).
  Pairs pool per buffered frame (1024/frame), summed at readback into
  mirror_gpu_ms + mirror_passes per sample; frame_perf now reports
  mirror_gpu_ms_avg/max, canon_gpu_ms_avg (scene minus mirror),
  mirror_passes_avg and mirror_pass_us for wide frames.
- New debug cmd gl_ws_ablate mode=<0..3>: 0 normal, 1 skip mirror passes
  entirely, 2 mirror state churn without draws, 3 mirror draws stay on the
  hr FBO (no per-pass FBO rebind; content-corrupting perf probe).

First MMX6 16:9 measurements with the split: mirror passes cost 20-125us
EACH (FBO ping-pong penalty per pass) but MMX6 2D batches to ~3-15
passes/frame so 16:9 attract holds ~60fps on this branch; the per-pass
cost x ~900 immediate flat prims is exactly the Tomba2 3D 60ms scene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 19:59:53 -07:00
Matthew Stanley 2d2f4aebb4 gl: fix black-frame flicker (ISSUES #7) — exact pending-upload rects, not a union
Root cause (proven via the new present ring + gl_coh_ring correlation):
flush_cpu_upload() merged all pending CPU->VRAM writes into ONE union
bounding box. A frame with two disjoint uploads (sprite column + tile row —
routine MMX6 texture streaming) produced a union spanning the display
framebuffers, which the flush painted from the STALE CPU VRAM mirror (the
FBO is authoritative under GL) — stomping live frames with black for
exactly two presents (one per double-buffer parity). Scene-dependence of
the uploads made it look config/timing dependent; the software renderer is
immune because its CPU array is authoritative.

Fix: exact pending-rect list (16 rects; merges only when zero uncovered
pixels are added — containment or same-band extension; wrap-aware GP0(A0)
transfers split into up to 4 exact rects instead of the old "wrapped: take
all" full-VRAM union; overflow -> order-preserving flush-all; pre-context
union retained where the CPU array is authoritative). Merge rule verified
by a 20k-randomized-rect host test (0 stale / 0 missing painted pixels).

Observability (always-on): gl_present_ring records every SwapWindow (path,
src/letterbox rects, drained glGetError, wall-ms, backbuffer + blit-source
pixel samples) — the instrument that gave the 1:1 black-capture
correlation. debug_server_send_fmt now heap-formats responses >64KB (they
previously truncated into unparseable JSON, breaking large ring dumps).
(debug_server.c also carries the Vulkan screenshot_file sync hunk from the
concurrent VK bring-up; see the next commit.)

Validated: ~18-min MMX6 GL attract soak, ~1600 window captures across 3+
attract cycles — zero isolated black frames, zero GL errors (pre-fix:
19/40 captures black at the intro). Tomba1 build-gl boots clean; its full
attract soak follows. ENHANCEMENTS.md added (framework enhancement ledger,
R1 OpenGL / R2 Vulkan state).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:26:48 -07:00
Matthew Stanley 4c39958226 dispatch: Class-A shell-window fix covers OVERLAY code — Tomba2 Whoopee-logo wedge
Root cause of the fresh-launch logo freeze (present at every commit in the
stack; window-capture-verified 13/13 repros, never a regression): the BIOS
dispatch normalize() maps RAM phys 0x30000-0x5AFFF to the shell ROM alias.
The Class-A fix routed post-game-start shell-window targets to the game/dirty
path first, but its gate also required psx_game_address_in_text() — which
exempts runtime-loaded OVERLAY code above the boot-EXE text end. Tomba2's
text ends at 0x38800; its post-logo main-thread resume PC 0x50B80 fell
through to normalize() and executed func_1FC38B80, a stale shell-image
data-as-code self-loop, starving the main thread forever (one VBlank
delivery per frame at the same EPC, zero main-thread instructions retired;
the game's ISR ran fine, so TCP frame counters kept ticking — the freeze
was invisible to every telemetry-only validation). Even PSX_FORCE_INTERP
missed it: normalize() shadowed the address before the interp path saw it.

Fix: post-game-start, the WHOLE shell-overlap window routes to
dirty_ram_dispatch before normalize() — real hardware executes the RAM
bytes regardless of who loaded them (boot EXE, overlay loader, DMA stream).
Pre-start shell addresses still normalize to the shell as before.

Also:
- interrupts/debug_server: nestgate_* per-guard veto-attribution counters on
  the nested-delivery gate (freeze_check), used to exonerate the IEc/SR
  guards and redirect the hunt from interrupts.c to dispatch.
- tools/window_capture.py: PrintWindow-based SDL-window capture with
  LIVE/FROZEN series verdict — window-liveness ground truth; TCP/VRAM
  screenshots provably lie about presentation (this bug's entire history).

Validated: fresh launch now runs logo -> intro FMV -> title -> attract with
sustained full-frame motion (window-level capture series).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:04:17 -07:00
Matthew Stanley 2dc938816b debug-server: parse bare json numbers as decimal, not saturated hex
json_get_str fed every consumer the raw value text, and address fields
went through hex_to_u32: a client passing a bare int addr (python
json.dumps of 0x8013DDC4 emits '2148784580') got it parsed AS HEX;
strtoul saturates 10-digit decimals to 0xFFFFFFFF, so read_ram/write_ram
silently operated on junk. This fabricated a false 'stream overwrites
overlay code' lead during the Tomba2 attract hunt (2026-07-02).

json_get_str now normalizes bare pure-decimal values to 0x-hex text
(JSON bare numbers are decimal by definition; quoted values keep the
hex wire convention), and json_get_int scans the raw json itself so
integer fields keep decimal semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:42:26 -07:00
Matthew Stanley 4cc757f0c9 bios: seed all 30 ROM-resident kernel-table targets; unknown dispatch is FATAL by default
Root cause of MMX6 "A game data could not be found" (weeks-old, cross-backend):
the game's save-scan calls memset through the kernel A0 jump table; entry
A0:2B points into BIOS ROM at 0xBFC02B8C (one of the entries the kernel never
patches into RAM). Transitive discovery can never find it (reachable only via
the data table, no jal), so the dispatch missed and psx_unknown_dispatch
silently no-op'd — killing the scan before firstfile ever ran. Every card
layer (SIO, IRQs, SwCARD events, sector data, kernel directory parse) was
verified correct along the way; the sole defect was the undiscovered ROM
function plus the silent miss.

- recompiler/seeds: enumerate the post-init A0/B0/C0 tables (kernel RAM
  0x200/0x874/0x674) and seed every ROM-pointing entry with no dispatch
  coverage — 30 functions (memset, memcmp, memmove, strncat, rand-family,
  card/dev services...). memset was merely the one MMX6 called; the rest were
  identical latent bugs. BIOS regen: 4406 -> 4439 dispatch entries.
  MMX6 CONTINUE now lists and loads saves (user-validated + scripted run:
  full FILE SELECT, 1093 card txns, unknown_dispatch_log total=0).

- traps.c: an unresolvable dispatch target now dumps a crash report and halts
  BY DEFAULT (PSX_FAIL_FAST_UNKNOWN_DISPATCH=0 opts out for diagnostic
  sessions; the miss is ring-recorded either way). The old silent-no-op
  default left stale registers and a severed call chain, and masked this bug
  for weeks. Deliberately NO interp fallback: the dirty-RAM interpreter is
  for runtime-written code (Rule 18); interpreting around a static-discovery
  hole would hide it exactly like the no-op did.

- runtime.cmake: EXE_NAME option — per-game process names (mmx6-runtime.exe)
  so one game's `taskkill /IM psx-runtime.exe` dev sweep no longer kills
  every other game's instance.

- debug rings (the tooling that found this): HLE call ring gains
  consecutive-duplicate collapse w/ repeat counts (a 4-way TestEvent poll at
  175K calls/s no longer flushes the ring in 0.1s), per-entry TCB + in_exc
  (thread attribution), a "since" paging param and 8MB dump buffer;
  card-trace EvCB watch widened to all 7 card event slots (slot 0 =
  SwCARD-IOE, the success event, was previously invisible).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:39:25 -07:00
Matthew Stanley 496d1dbbc8 runtime: HLE tier wiring + carried instrumentation from the card-load hunt
HLE tier wiring (completes the previous commit — HEAD builds again):
- main.cpp: [runtime] bios_hle/keep_intro consume + settings.toml layer +
  PSX_BIOS_HLE / PSX_BIOS_HLE_KEEP_INTRO env resolution + backend banner;
  boot-turbo window ORed into the turbo predicate; the fast_boot snapshot
  restore/capture path and s_fast_boot_active are removed (deprecated by
  the HLE boot shell-skip); psx_hle_scheduler_set_default call.
- traps.c: psx_hle_scheduler_enabled default now flows from config
  ([runtime] hle_scheduler) with the PSX_HLE_SCHEDULER env winning.
- debug_server.c: hle_dump command (always-on HLE ring query).

Carried instrumentation/state from the MMX6 card-load sessions (kept
deliberately; documented in memory + handoff):
- dirty_ram_interp.c: XOP_RES interp JAL call-resolution probe + xprobe
  watched dump section (emitted first), watch targets.
- overlay_loader.c: probed wrapper around dispatch_call + psx_sljit_call.
- debug_server.c: card_mgr_trace range widen, TestEvent spin-body
  collapse, 64MB response cap.
- interrupts.c: PSX_SAME_THREAD_RESTORE selector (0/1/2; TCB-equality
  hardening candidate).
- memory.c: PSX_POLL_PROOF temp gate (remove when card work resumes).
- savestate.c/.h (new): user save states (F1-F12), HLE-scheduler-gated
  load via psx_scheduler_resume_at; load-crash known, do not use for
  repros yet.
- psx_cycles/autocompile/cycle_testrom: timing-fastpath + exe-path
  session state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:27:57 -07:00
Matthew Stanley 13c5e0c5b5 stale-recompiler shard guard + exception-exit ring; Tomba menu wedge resolved
Root cause of the Tomba pause-menu wedge (loaded saves only): autocompiled
overlay shards were emitted by a recompiler binary OLDER than the last emitter
change, yet stamped with the CURRENT cg tag (compile_overlays derives the tag
from --runtime-include; nothing tied it to the emitting binary). The stale
shards corrupted the display-list task queue during load-game. NOT the
IRQ-resume class the prior handoff claimed - disproven with the new ring.

- recompiler: bake the emitter-source hash (shared canonical list
  runtime/codegen_hash_sources.cmake + hash_codegen.cmake) into psxrecomp-game;
  new --codegen-hash flag prints it (machine-parseable, pre-banner).
- compile_overlays.py: HARD-FAIL when the recompiler binary's baked hash
  differs from the cg tag hash it stamps (or the binary lacks the flag).
  Applies to both cache and --static modes. Closes the read==write-tag /
  stale-content drift class structurally.
- runtime.cmake: codegen-hash source list moved to the shared canonical file.
- interrupts.c: always-on exception-EXIT half added to irqctx_ring (take_pc,
  real_epc, exit_pc, escape reason, same-thread/restore flags, v1/ra
  saved-vs-exit, RestoreState redirect count) - proves per delivery which
  escape path fired and whether the interrupted GPRs were restored. Kept the
  same_thread_resume GPR-restore refinement (ring-verified equal-value in
  practice; faithful: hardware restores all GPRs on every exception return).
- debug_server.c: irqctx_ring dump extended with the exit-half fields.

Validated live: Tomba menu opens on loaded save (Stormy Mountain) with the
cache fully enabled after regenerating shards with a rebuilt recompiler
(build-cosim + build-prod caches). Guard verified positive and negative.
MMX6 gate passed: boot -> title -> opening cutscene -> gameplay handoff OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 19:50:26 -07:00
Matthew Stanley 553d9933d1 fix mmx6 timing divergences with cosim oracle 2026-07-01 09:28:50 -07:00
Matthew Stanley 0f34c29c45 diagnostics: add compiled block lockstep comparator 2026-06-30 16:59:58 -07:00
Matthew Stanley 18c94a1394 device_trace: general two-process device-event (IRQ) cycle ring
New always-on ring (device_trace.{c,h}, compiles into both binaries like
parity_trace) recording every hardware IRQ-raise edge with its guest cycle,
source (I_STAT bit) and detail. All native device sources funnel their raise
through one choke point psx_irq_raise(bit,detail) in interrupts.c (VBLANK/GPU/
CDROM/DMA/timers/SIO routed; DMA passes DICR channel flags, timers pass index),
so the device-timing audit sees every event from one place. devtrace_dump/ctl
debug commands (cycle-window + source filters) + tools/devtrace_diff.py align
the two device-event timelines by source ordinal and flag where cycles drift.
The Beetle oracle side (mednafen IRQ_Assert rising-edge hook) lives in the main
checkout.

This is the level below parity_trace: parity names the divergent control-flow /
gate input; device_trace names the divergent HARDWARE EVENT behind it.

First result (MMX6 cutscene asset-load over-fast wedge): the load's CD+DMA
activity is compressed into ~232M cycles on native vs ~384M on Beetle (~1.65x),
completing the func_8001CB3C scene setup ~100M cycles / ~176 frames early. The
producer is CD-read / DMA-completion timing (DMA channels 2/3/4 = GPU/CD/SPU
asset streaming) -- confirming the device-completion-latency hypothesis and
ruling in the CDROM/DMA cycle model as the over-fast subsystem. (VBlank verified
correct: native VBlank edges are exactly 564480 cyc apart.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:53:27 -07:00
Matthew Stanley 5da57aa478 parity_trace: per-watch-word LAST-WRITER provenance (name the early producer)
Extends the two-process parity ring so each row carries, per watch word, the
exact store PC + guest cycle + frame + (approx) writing TCB of the most recent
write to that word. parity_trace_note_write() maintains the table from BOTH
processes' write paths (native: memory.c psx_write_* with g_debug_last_store_pc;
Beetle: beetle_libretro.cpp wtrace_callback with the store pc). Both parity_dump
serializers emit the new wwpc/wwcy/wwf/wwt arrays (identical wire format), and
parity_diff.py prints a side-by-side WATCH PROVENANCE block at the divergence.

This turns "the gate-input value diverged" into "who/when produced it" -- the
instrument ChatGPT recommended for the MMX6 over-fast wedge.

First result (MMX6 cutscene->gameplay, watch 0x80071088/0x800CD3F8/0x8006D9AC/
0x800CD3FC/0x800CD404/0x800200F4): the cutscene-setup func_8001CB3C (writer pc
0x8001CB7C of countdown 0x800CD3FC) runs ~99.7M guest cycles / ~176 frames
EARLIER on native (cyc 998,597,937 frame 1769) than Beetle (cyc 1,098,296,890
frame 1942) -- same writer PC, fired early. On Beetle the state struct
0x800CD3F8 is never touched by game code (stays BIOS-init 0 -> setter path); on
native it advances. The over-fast wait is localized one level above the state
machine, at whatever schedules func_8001CB3C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:43:40 -07:00
Matthew Stanley 2ed1dbbec7 tooling(parity): server-side transitions filter (collapse identical-state runs)
parity_dump gains transitions=1: emit only rows where the watched STATE changes
(watch words + epc + tcb_state; pc/ra/sp ignored), collapsing runs of identical-
state dispatch rows into one with a reps count. A 130k-row cutscene window trims
from ~9.9 MB to ~224 KB (44x) and fits with no truncation — fixes the large-dump
transport limit and makes cross-process divergence fall out of a compact state
timeline. Beetle dump handler mirrored in the main checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:27:39 -07:00
Matthew Stanley 7224248c82 tooling(parity): stamp guest-cycle into the parity ring (cycle-drift ruler)
Add absolute guest-CPU-cycle (since boot) to every parity row via a per-process
parity_host_cycle() accessor (native psx_get_cycle_count; Beetle
beetle_core_get_guest_cycles — directly comparable). Widen ParityEntry.cycle to
64-bit; both dump handlers emit it. This turns the cutscene divergence from "273
frames early" into a hard cycle delta: Beetle runs 566k cycles/frame (≈ real PSX
VBlank 564480); native reaches the cutscene state-advance ~150-200M guest-cycles
early — an over-fast cycle deficit (DMA-cycle-steal signature) in the faithful-
timing stack. Beetle producer side mirrored in the main checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:19:08 -07:00
Matthew Stanley f8aefefec1 tooling(parity): general two-process control-flow parity trace (native producer)
End the "somewhere upstream" blindness with a reusable first-divergence
instrument (PRINCIPLES.md "find the first divergence; is the value WRONG or the
behavior MISSING"). A TCB-scoped, freeze-on-trigger control-flow ring with an
identical struct + JSON wire format on both processes, so a generic sequence-diff
(tools/parity_diff.py) locates the first logical divergence between recomp and
oracle — not a frame diff.

- parity_trace.{h,c}: armed-from-boot ring, gated on current_tcb==watched_tcb,
  latches on a configured trigger target so the pre-divergence window survives
  the wedge loop. Up to 6 watch-word snapshots/row. Self-contained TU shared by
  both binaries; frame source decoupled via parity_host_frame().
- fntrace.c: per-dispatch producer hook (cheap armed gate on the hot path).
- traps.c: YIELD control-event hook in the scheduler switch.
- main.cpp: env-armed from boot (PSX_PARITY_TRACE=1; TCB/trigger/watch tunable),
  defaults target the MMX6 cutscene wedge.
- debug_server.c: parity_dump + parity_ctl commands.
- tools/parity_diff.py: pulls both rings, sequence-diffs by (kind,pc,target).

First use (MMX6 freeze) confirmed MISSING behavior, not a wrong value: native
thread1 is permanently parked at func_8002000C resume (TCB EPC 0x800200F4),
handshake flag 0x8006D9AC never set (0 across 16384 rows), setter func_8001344C
never runs; thread1 re-resumes a dead func_8002000C frame whose saved-ra slot was
reused -> jr 0x800CD3F8 wedge. Upstream context-staleness/scheduling root.
Beetle mirror producer + the cross-process diff are the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:38:38 -07:00
Matthew Stanley 4ddccccaa9 checkpoint: exact-store-PC tooling fix (verified) + freeze/scheduler diagnostics + faithful-timing WIP
recompiler/code_generator.cpp: stamp g_debug_last_store_pc before EVERY game
store (SB/SH/SWL/SW/SWR, SWC2, + the ws-backdrop / game-option special paths)
and declare the extern in the game-C preamble. Previously only the BIOS emitter
(strict_translator.cpp) stamped it, so game stores inherited the last BIOS store
pc -> wtrace/readtrace mis-attributed game writes to the BIOS. VERIFIED LIVE:
MMX6's producer trace for the 0x800CD3F8 write now reports the real game store
pc 0x8001A708 (matches Beetle), not the stale 0xBFC11BF0.

runtime: g_pc0_reason slice-exit provenance, fail-closed exception-sentinel
asserts, named thread-event kinds, freeze-heartbeat RVA emission (diagnostics,
default-on, additive).

Carries the in-progress faithful-timing / MDEC work (cpu_state.h, memory.c,
dma.c, interrupts.c, dirty_ram_interp.c, full_function_emitter.cpp) as an
uncommitted-WIP checkpoint. tools/: regen_bios.sh, bios_emitter_fingerprint.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:20:17 -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 2b5e69fe8f feat(timing): two-anchor REGION mode for cyc_watch — validated stable gate
cyc_watch + cycle_compare.py now take an optional END anchor: each recorded value
is Δcycles of one START->END pass (cost of the known code path between two dispatch
points). [A,A] = Δ between consecutive A hits (call-to-call cost). Mirrored EXACTLY
on both backends (native debug_server.c + Beetle libretro.cpp/beetle_debug_server.c,
the latter uncommitted in main checkout, additive). VALIDATED: region [0x80017FC4,
0x80017FC4] = native Δ 56 vs Beetle Δ 91, gap -35, ZERO variance across 8 passes —
a stable, offset-independent per-region gate to drive Stage-2 components to
convergence (faithful components shrink the gap monotonically; can't overshoot).
Note: [A,B] with distinct B needs B to reliably follow A (a dispatch point on the
deterministic path); B=0x8001EFFC failed (func has multiple exits) — pick wrapper/
single-exit pairs or use [A,A].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:18:26 -07:00
Matthew Stanley ebf5698435 feat(timing): native<->Beetle cycle comparator (cyc_watch) — built + validated
Native cyc_watch (ported from agent branch) + the missing universal hook:
debug_server_log_call_entry now samples cyc_watch_observe at every compiled
function entry (game AND BIOS; the agent's debug_server_trace_dispatch hook only
covered the BIOS trampoline, so game-text anchors fired 0). tools/cycle_compare.py
arms the same anchor on native(4500)+Beetle(4382) and diffs per-hit cycles.
Beetle side (libretro.cpp cyc_watch state/observe + cpu.cpp RunReal loop hook +
beetle_debug_server.c commands) is in the MAIN checkout (additive diagnostic,
uncommitted) and built into psx-beetle.

VALIDATED end-to-end: both backends capture 16 hits at game-text anchor 0x80017FC4.
FINDING: native is ~121M cycles BEHIND Beetle at that anchor = a gross boot-cycle-
path divergence (turbo-loads/fast-CD + overlay/dirty charge far fewer cycles than
Beetle's real-time CD boot), NOT the -8 fine drift. => absolute-cycle comparison
through boot is dominated by load-model differences; the -8 needs a controlled
anchor (early/pre-divergence or an interp-convergence point). Relocated BIOS-shell
funcs (phys 0x30000-0x5AFFF, e.g. 0x80050B0C) dispatch at a different native phys —
anchor on game-text or BIOS-ROM funcs, or the relocated phys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:29 -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 ff714d1ee1 diag(overlay): confirm Tomba 2 FMV-freeze root cause (break range pollution)
Adds overlay_cps_probe (debug cmd) to record what the CPS interior-continuation
dispatch decides for a watched PC. Used it to CONFIRM the root cause:

func_80050B08 calls jal 0x89788 (init-array). 0x89788 isn't a registered entry,
so overlay_find_by_range(0x89788) runs -- 6 loaded candidates have a range
containing it -- and returns func_800896E0 (the crt0 bootstrap), whose range
wrongly spans 0x89788. 0x89788 is not a case in its entry-switch, so default
re-runs the bootstrap from the top: InitHeap/A0 spin, VBLANK-masked deadlock.

func_800896E0's range covers 0x89788 because function_discovery.cpp:493-499
treats BREAK as fall-through; the bootstrap's "jal 0x50B08; break" (break guards
the main-returned case) falls through at 0x89784 into the separate function at
0x89788, absorbing it. So the early break-no-op finding WAS the root -- damage is
at discovery/range time, not runtime break execution. Interp is immune (executes
0x89788 bytes directly).

Fix options + recommendation (clip function range at next entry / don't cross a
function boundary on break fall-through) documented in
docs/TOMBA2_NATIVE_FMV_FREEZE.md. Diagnostic only; no fix applied yet (needs
regression on Tomba1/MMX6/Ape). Workaround: overlay_native_block 0x80050B08.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:22:37 -07:00
Matthew Stanley 0acc6f2dbb feat(overlay): per-function native-disable for divergence bisection
Adds a phys-keyed blocklist in overlay_loader.c that forces a named overlay
function through the sanctioned dirty-RAM interpreter (it still RUNS — not
skip/stub/HLE), as if s_native_exec were off but for that one function only.
Exposed as the `overlay_native_block` debug command (add/clear/report). This is
the localization tool for native<->interp divergences (e.g. the Tomba 2 FMV
freeze): binary-search which compiled overlay function's native execution flips
the bug, without rebuilding or losing candidate visibility. Runtime/tool-only.

Used it to localize the Tomba 2 end-of-Whoopee-Camp FMV freeze to
func_80050B08's OWN native body (blocking it passes the freeze with callees
still native; all 33 jal targets + continuations verified clean). Narrowed to
the main-loop region with a strong interior-entry suspect at 0x80050CE8.
Findings in docs/TOMBA2_NATIVE_FMV_FREEZE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:22:36 -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 2e2795f147 fix(tomba2): route sub-FLOOR boot-text overlays through local-dirty-flow
Tomba 2 discards its boot/init code and loads a gameplay overlay OVER its
own boot-EXE text region (phys 0x1DB8C, below the 0x38800 text-end FLOOR).
The dirty-RAM interp's local-flow gates (is_local_dirty_target,
allow_local_dirty_flow, transfer-chain gate) keyed on OVERLAY_REGION_FLOOR,
so they misclassified that live overlay as immutable static text and routed
the splash loader's `jal 0x8001DB8C` through the bail-prone non-local-call
contract instead of interpreting the overlay locally. That produced a wild
`jr ra` reading a stale saved-ra stack slot (0x801FE994 = "T.BI", bytes 4-7
of START.BIN left by the file-open strcpy), killing the worker thread before
it could ChangeThread to the START.BIN loader -> Whoopee-Camp splash softlock.

Verified against ground truth: EXE + generated C have `move a2,a0` at
0x8001DB8C (mid func_8001DB7C); runtime RAM has a fresh prologue there
(page 0x1D dirty=true). Always-on wtrace_all ring pinned the clobbering
strcpy at pc=0x8008B9A8, frame 2068.

Fix: gate local-dirty-flow on DIRTY_RAM_KERNEL_WINDOW_END (0x10000) instead
of OVERLAY_REGION_FLOOR, via new helper phys_is_overlay_flow_region(). A dirty
page above the kernel window IS an overlay regardless of FLOOR; the inline
dirty_ram_is_dirty() check keeps clean boot text on the compiled fast path,
and the kernel window [0,0x10000) still stays per-block (exception context).
Runtime-only; no regen.

Result: worker no longer wild-jumps; it ChangeThreads to the loader thread
(TCB 0xA000E374); aborts=0; threads switch cooperatively. NEW failure point:
the loader thread does not read START.BIN data sectors (CD idle at directory
sector 373) -> state byte 0x1F80019C stays 0, splash not yet advancing
(next cascade stage).

Also includes the diagnostic tooling that localized this: insn_freeze_target,
insn_freeze_snapshot, ra_load_watch (debug_server.c / dirty_ram_interp.c) and
thread sys3 / dispatch-return traces (traps.c).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:15 -07:00