- First-run pickers now state exactly which user-supplied file is being
requested: an info box + step-numbered dialog title for the BIOS
(Step 1 of 2, SCPH1001.BIN) and the game disc (Step 2 of 2, named
game + accepted formats), each noting what it is NOT. Game name comes
from the loaded game config.
- event_ring recording is now compiled out under PSX_NO_DEBUG_TOOLS:
it is only readable via the TCP debug server (stripped in production)
and is not part of the freeze dump, so recording was pure hot-path
cost. All other rings were already no-op gated.
- Overlay autocapture (which writes the player-shareable
overlay_captures.json) is now enabled whenever overlay_cache is on,
no longer coupled to the dev-only autocompile command — player builds
produce the contribution file the README promises; the background
compile still requires a configured command and no-ops without one.
Production surface after this: no console (-mwindows), no TCP server,
no ring recording; keeps overlay cache + captures, freeze watchdog JSON,
and crash report.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause (freeze dump 1781046069, addr2line-verified): the wall-clock
pacing loop in sdl_vblank_present read the performance counter twice per
iteration — once in the while-condition, once to compute remaining time.
If the frame deadline was crossed between the two reads (a preemption
window), the unsigned subtraction underflowed, *1000 overflowed, and
SDL_Delay received ~2^64/10^4 ms mod 2^32 ≈ 24.7 days at the standard
10 MHz QPC. The app parks in one SDL_Delay with every counter frozen,
wedged between 'i_stat |= VBLANK' (interrupts.c:286) and exception
delivery — which is why dumps showed i_stat=1 pinned. VBLANK was being
ACK'd every frame right up to the wedge in every dump examined; the
'VBLANK-ACK reentry storm' framing was a symptom misread.
- frame_pacing.c/h: shared race-free pacer (single counter read per
decision, compare-before-subtract, sleep clamped to one period).
Both call sites switched: main.cpp sdl_vblank_present and
beetle_main.cpp. Unit test pins the underflow case.
- Vsync self-heal: the 'recovered storm' dump (1781045865) was 8/8
main-thread samples inside wglSwapBuffers — NV driver blocking ~1.5s
per present. After 3 presents >250ms, drop driver vsync (we pace to
59.94 Hz ourselves). Counters surfaced in the freeze dump.
- Watchdog classifier: 'reentry_storm' threshold (5K/sec) was below the
healthy baseline (~2K reentry blocks/frame × 30-60 fps), mislabeling
normal play and host stalls — that label misdirected two prior
investigations. Now normalized per frame (>20K/frame), plus a new
slow_frames kind (<5 fps) with multi-sample stack capture.
Verified: test_frame_pacing ALL PASS; live Tomba paced at 59.962 fps
over 20s (single-process measurement).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runtime detects an uncovered overlay/kernel variant by sustained dirty-RAM
interp pressure inside the capture windows (g_dirty_window_dispatches,
sampled every 600 vblanks; fires only at a coherent non-loading moment via
cdrom_load_in_progress, 60s cooldown, 16/session cap), writes the capture
JSON, and spawns the game.toml-configured compile command
([runtime] overlay_autocompile_cmd, cmd.exe /C, cwd=project root, child
output kept in an in-memory ring — no log files). On child exit 0 the emu
thread rescans the cache (overlay_loader_rescan: idempotent dir re-scan +
checked-regions reset) so the new DLL loads on the next dispatch and the
variant goes native in-session. Dedup is tool-side (existing-CRC skip);
pressure drops to zero once code is native, so the loop self-limits.
New TCP: autocompile_status / autocompile_run / overlay_rescan.
compile_dll now uses absolute native paths and reports exit codes.
VERIFIED live (user-played route): 2 auto-triggers fired, captures written,
child ran recompiler+audit (new variants 1E7DDE2C, A62D0A7C discovered and
audit-clean), rescan picked the compiled DLL up in-session. OPEN ISSUE: gcc
exits 1 with no output inside the spawned cmd.exe child (same absolute-path
command succeeds from an interactive shell) — compile step currently
completed manually; suspect child-env difference, to be isolated next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While the game is loading, skip wall-clock pacing in sdl_vblank_present
so the guest runs at host speed (presents 1-in-30 for visual progress).
Gated three ways: game.toml [runtime] turbo_loads (OPT-IN — streaming
titles must stay off), post-BIOS-handoff only (fntrace_is_game_started),
and cdrom_load_in_progress() — a data-sector read stream is active or a
data sector landed within the burst-gap window (bridges per-file
Setloc/seek gaps); XA streaming (FMV/CD audio) is never a load. New
turbo_loads TCP command gets/sets the enable and reports load_active +
unpaced-vblank count.
Measured (Tomba boot, vs step-3 baseline; user-confirmed in play):
intro load 1066 sectors 7.59s -> 6.20s; later loads 3.3-5.1 ms/frame
vs 16.7 paced (3-5x); 704 vblanks ran unpaced; no watchdog events.
Compression ceiling is guest speed during loads, which is currently
interpreter-bound (the 0x11xxxx loader-region family, ~12M insns) —
step 2.8 variant-capture automation multiplies turbo directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CDROM_INSTANT_MAX_PER_FRAME becomes a runtime variable (default 32,
clamp [1,4096], period still floored at CDROM_MIN_DELAY). Three writers
share the knob: game.toml [runtime] instant_max_per_frame, the new
cdrom_instant_rate TCP command (in-session A/B without rebuilds), and
the upcoming turbo-through-loads predicate (step 4).
New always-on CD load-burst ring (ring-buffer rule): one record per
gap-separated run of delivered data sectors — start/end frame, host
wall ms, sector count, rate+divisor in effect. cdrom_bursts TCP command
dumps newest-first, making "load duration" a measured quantity.
MEASURED CONCLUSION (Tomba boot, identical deterministic workload):
the 32/frame cap is NOT the load-time bottleneck. Intro load at rate 32
= 1066 sectors / 456 frames / 7.59s; at rate 256 = 1064 / 449 / 7.48s
(identical within noise; actual delivery 2.2-4.8 sectors/frame, never
near either cap). Tomba's loads are paced by the game's own per-sector
processing at real-time speed — wall-clock load time is a job for
step 4 turbo (run the guest above real-time while loading), not for a
bigger IRQ budget. Default stays 32. No watchdog transient at either
rate (the old frame ~184-213 reentry storm did not reproduce — likely
calmed by step 2.7's native exception tail).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reinstates the runtime-loaded overlay DLL cache (Layer A) and the offline
overlay compilation pipeline that were built on the pruned codex/cache-discovery
branch (tip 2bbafc2) and never merged. Ported onto current master; rebuilt
rather than history-restored.
Runtime (off by default, opt in via [runtime] overlay_cache = true):
- overlay_loader.c/.h, overlay_api.h: CRC32-keyed DLL cache at
cache/<game_id>/<region>_<crc>.dll. On a dirty-RAM dispatch miss the loader
LoadLibrary's the matching DLL, wires runtime callbacks via overlay_init(),
enumerates func_XXXXXXXX exports, and dispatches them BEFORE the interpreter.
- overlay_capture.c/.h: write-once capture set; records overlay DMA bytes and
writes overlay_captures.json (execution-verified seeds) at clean exit.
- dma.c: capture + cd_dma_log hook at CD DMA completion (async model).
- cdrom.c: cdrom_get_setloc_lba() for disc-position correlation.
- dirty_ram_interp.c: g_dirty_ram_exec_pc_table (exec-verified PCs), a
decodability guard on JAL-into-dirty targets, and the overlay dispatch hooks.
- debug_server.c: cd_read_log + overlay_loader_status TCP commands.
- config_loader: [runtime] overlay_cache flag (default false).
Recompiler (compile_overlays.py / extract_overlays.py drive these):
- main_psx.cpp --overlay-exact: analyze only explicit entries + direct JAL
targets and set split_mid_function_targets=false, so overlay basic-block
targets are NOT split into bogus standalone functions (the mid-function-seed
failure mode that produced black-on-load / blue-on-new-game).
- function_analysis.cpp: analyze_exact_entries(); code_generator.cpp: gate the
mid-function split pre-pass behind split_mid_function_targets (default true,
so normal main-EXE codegen is unchanged - Tomba still regens to 2854 funcs).
Deltas from the original: save states dropped (never worked); overlay enable is
runtime-config-gated (off by default) instead of a build-time define; loader
stderr logging converted to the overlay_loader_status TCP path (CLAUDE.md Rule 3).
Recovered from _backups/2026-06-06-consolidation/psxrecomp-all-PRE.bundle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Framework work on codex/mmx6-support that makes Tomba build and play
(title screen, 0 dispatch misses) and MMX6 boot through BIOS to its
intro. Touches recompiler codegen (code_generator, function_analysis)
and runtime CD-ROM/DMA/MDEC/GPU/SIO timing (cdrom, dma, mdec, gpu,
psx_cycles, traps, fntrace, debug_server) + headers + runtime.cmake.
Milestone snapshot for collapsing onto master. Build dir excluded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CD DMA hook (overlay_log.c) could not distinguish code overlays from
FMV streaming data — Tomba buffers FMV through the same physical address
range and DMA channel. Ripped it out entirely.
Replace with overlay_dump TCP command in debug_server.c: scans the
dirty_ram bitmap for contiguous dirty pages above a configurable physical
threshold (default 0x98000), writes <crc32>.bin files to a caller-supplied
directory, and returns a JSON manifest. dirty_ram IS the oracle for
"executed as code" — no false positives from data transfers.
tools/dump_overlays.py queries overlay_dump post-session and appends new
entries to logs/<game_id>/overlay_map.jsonl with (crc32, load_addr, size,
bytes_file). First run on Tomba captured 4 regions (8KB, 264KB, 356KB,
748KB) matching the known dirty_ram miss clusters.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Hooks CD DMA channel 3 completion to log code overlay loads to
logs/<game_id>/overlay_map.jsonl for build-time pre-compilation.
Hot path cost: two integer comparisons (address + size filters) and
return. CRC32 is NOT computed inline — too expensive for FMV-heavy
scenes that stream many small DMA transfers through channel 3.
Filters:
- phys_addr < 0x80000: skip (main EXE, kernel, data)
- size_bytes < 4096: skip (streaming data, not code)
Deduplicates on (addr, size) in a 128-entry table; appends new entries
to an open file handle (no fopen/fclose per transfer). overlay_log_init
is called from main.cpp with the game_id and game.toml directory.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
dispatch_stats TCP command: g_dispatch_static_hits incremented on every
binary-search hit in generated dispatch; exposed alongside miss_total and
miss_unique so static coverage ratio is measurable numerically.
fast_boot = true in [runtime]: first launch runs BIOS normally (logos
visible) and captures full system state at game handoff to
fast_boot_<bios_cksum>_<entry_pc>.bin adjacent to the BIOS. Every
subsequent launch restores the snapshot and dispatches directly to the
game entry PC — no BIOS execution, instant start.
Snapshot captures: CPU (gpr/hi/lo/cop0/gte), RAM (2 MB), scratchpad,
dirty-RAM bitmap, i_stat/i_mask, timer state. GPU/SPU/VRAM not included
(game reinitialises). Display-disabled vblanks now clear SDL to black so
the window never shows OS-background white during game crt0 setup.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Boot stays at authentic 1x. Speed switch fires once when the game's
entry_pc is first dispatched — safely after the BIOS disc init and
Sony/PlayStation logo sequences complete. Uses entry_pc exact-match
rather than text range to avoid false-triggering on the BIOS shell
(RAM 0x30000-0x5B000 overlaps the game text range).
XA guard in apply_speed(): reverts to 1x whenever xa_stream_active,
protecting FMV playback from speed-up regardless of disc_speed setting.
CDROM_MIN_DELAY floor of 500 cycles prevents instant mode from firing
IRQs faster than the interrupt handler can process them.
Tomba game.toml: disc_speed = "4x" (instant proved too aggressive
for early game disc reads; 4x safe with XA guard protecting FMVs).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
SIO handler alias (0x0CF0 → func_0000641C):
Runtime capture showed BIOS installs lui+addiu+jalr to RAM 0x641C at 0x0CF0.
New [[recompiler.bios_aliases]] TOML mechanism emits a one-liner wrapper and
registers it in the dispatch table. Eliminates 30K dirty_ram blocks/session.
Instant disc speed (disc_speed = "instant" in game.toml [runtime]):
cdrom_set_speed(0) collapses all seek/read delays to 1 cycle. Correct INT
sequence still fires; no artificial wait. "2x" and "4x" divisors available.
Default remains authentic 1x. Tomba set to instant.
Persistent game miss collector (tools/collect_game_misses.py):
Appends newly-seen dirty_ram dispatch misses in the game text range to a
file (TombaRecomp/seeds/dirty_ram_misses.txt) for later seeding.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- open_configured_controller: two-pass scan — first tries the configured
device index, then falls back to the first available game controller.
Fixes Steam Remote Play where the virtual Xbox controller appears at an
unpredictable SDL joystick index, causing silent failure when device=0
in input.ini doesn't match.
- SDL_HINT_JOYSTICK_HIDAPI=1 / SDL_HINT_JOYSTICK_RAWINPUT=0 set before
SDL_Init so Steam Input's virtual controller is enumerated via HIDAPI
rather than the raw Windows HID path, making it visible as a proper
SDL_GameController.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- PSX_DEBUG_TOOLS now defaults OFF for Release/MinSizeRel, ON for
Debug/RelWithDebInfo. A plain -DCMAKE_BUILD_TYPE=Release produces a
production binary with no TCP server and no per-block recording.
Explicit -DPSX_DEBUG_TOOLS=ON/OFF overrides the default.
- Release MSVC: /SUBSYSTEM:WINDOWS + /ENTRY:mainCRTStartup — no console
window appears when launching the exe from Explorer/desktop.
- Release MinGW: -mwindows for the same effect.
- SDL_WINDOW_RESIZABLE added so the window can be dragged to any size;
SDL_RenderSetLogicalSize(640,480) already letterboxes correctly.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The "v4" suffix was a holdover from iterative project restarts.
There is now one psxrecomp; all identifiers, include guards, cmake
variables, function names, log strings, schema tags, and tool paths
drop the v4 suffix.
Changes:
- PSXRECOMP_V4_ROOT → PSXRECOMP_ROOT (cmake)
- PSXRECOMP_V4_RUNTIME_SOURCES/_INCLUDE_DIRS/_BIOS_GENERATED → no V4
- psxrecomp_v4_add_runtime_target → psxrecomp_add_runtime_target
- PSXRECOMP_V4_*_H include guards → PSXRECOMP_*_H
- "psxrecomp-v4" log/window strings → "psxrecomp"
- Schema version strings in recompiler JSON output
- Tool script hardcoded absolute paths updated
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The recompiler and runtime now build and run natively on macOS (Apple
Silicon & Intel) and Linux, alongside Windows. The recompiled BIOS boots to
the PlayStation logo, and Tomba boots into gameplay with working memory-card
save/load — the previously Windows-only path.
Core change — the BIOS thread scheduler:
- runtime/src/psx_fiber.{h,c} (new): one cooperative-fiber API, backed by
Win32 Fibers on Windows and ucontext (makecontext/swapcontext) on POSIX,
with a GetCurrentFiber analog (psx_fiber_current).
- traps.c: the BIOS thread switcher now uses psx_fiber_* on every platform.
The old non-Windows setjmp/longjmp fallback is removed — it ran target
threads nested on the caller's C stack and so couldn't resume an arbitrary
suspended thread, which hung the BIOS CD-boot WaitEvent handoff. Real
per-thread stacks (fibers/ucontext) fix it.
- interrupts.c: the exception-deferral path (g_exception_owner_fiber,
deferred_exception_longjmp) is now fiber-based on all platforms via the
same abstraction, instead of #ifdef _WIN32.
Cross-platform build fixes:
- recompiler/CMakeLists.txt: vendored fmt 9.1.0 force-enables consteval on
Apple clang (the __cpp_consteval clause overrides its own Apple exclusion),
which clang 21 then rejects; force FMT_CONSTEVAL empty on non-MSVC.
- runtime.cmake: add psx_fiber.c; add pkg-config's SDL2_LIBRARY_DIRS so
-lSDL2 resolves outside default paths (Homebrew /opt/homebrew/lib).
- crash_trace.c: include <stdarg.h> (not pulled in transitively off Windows).
- main.cpp: the forced "opengl" render-driver hint (a Windows-GDI-freeze
workaround) is now Windows-only so macOS uses Metal; create the renderer
with PRESENTVSYNC; SDL_RenderSetLogicalSize(640,480) so fullscreen
letterboxes; add an F11 / Alt+Enter / Cmd+F fullscreen toggle.
Verified: SCPH1001 BIOS boots to the Sony logo on Apple Silicon, and Tomba!
(SCUS-94236) boots into gameplay with memory-card saves persisting across
relaunches. Gamepad (SDL_GameController) was already cross-platform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The actual fix for TombaRecomp/ISSUES.md #6 ("Not Responding"
freezes). Earlier claim that SDL_RENDERER_SOFTWARE fixed it
(commit 9eb4ab3) was wrong — based on a too-short stress test
that didn't reach the freeze threshold. The corrected story:
1. SDL_RENDERER_ACCELERATED → froze.
2. SDL_RENDERER_SOFTWARE (Windows GDI backend) → still froze.
3. SDL_HINT_RENDER_DRIVER=opengl + ACCELERATED → no freeze
after 11+ min / 40k+ frames, well past every prior failure.
Why it intermittently triggered: the FMV-speed fix (b486c13)
raised guest cycle throughput, which raised the GDI present
rate enough to trip the underlying Windows GDI hang. Before
that fix the workload stayed below threshold.
Three changes in this commit:
* runtime/src/main.cpp + runtime/src/beetle_main.cpp:
switch from SDL_RENDERER_SOFTWARE to OpenGL via
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl").
* runtime/src/main.cpp: PSX_SDL_NO_AUDIO and PSX_SDL_NO_RENDER
compile-flag guards added for bisection. Pass via -DCMAKE_C_FLAGS
when needed; default behavior is unchanged.
* runtime/src/freeze_heartbeat.c:
- Always-on regardless of PSX_NO_DEBUG_TOOLS. This thread
was the key diagnostic — it survives main-thread stalls
because it runs in its own thread, so its ring captures
pre-freeze state when the TCP server can't respond.
- Added a 64-entry pre-freeze history ring (~6 s window)
to the heartbeat JSON. Each entry snapshots frame_count,
psx_cycle_count, exc_reentry_blocks, dirty_ram_insns,
current_func, last_store_pc, i_stat, sio_stat/ctrl,
in_exception, mc_max_state. When all those columns stop
advancing while wall_clock keeps ticking, that's the
stall point. This signal proved the freeze is in HOST
code (not guest), which redirected the investigation
from "fix the recomp" to "fix the SDL/Windows path."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two observability additions, both gated by the new
-DPSX_DEBUG_TOOLS=OFF flag for production builds.
1. Freeze heartbeat thread (`runtime/src/freeze_heartbeat.c`):
Background thread spawned at startup that snapshots runtime
state (frame_count, psx_cycle_count, in_exception, exception
counters, sio/mc state, i_stat/i_mask, dirty_ram counters) to
`psx_freeze_heartbeat.json` every 100 ms. Survives main-thread
freezes — writer is independent of the SDL loop and the TCP
debug server. Atomic file replace via .tmp + MoveFileEx so a
reader never catches a half-written JSON.
Already paid off: captured a freeze pattern (frame_count stuck,
psx_cycle_count stuck, in_exception=0, current_func in
install-stub kernel code) that the TCP server couldn't show
because its accept queue was wedged by the same stalled main
thread.
2. PSX_DEBUG_TOOLS CMake flag (`runtime/runtime.cmake`):
- Default ON: builds with the TCP debug server, heartbeat
thread, and per-block recording (debug_server_log_call_entry
fires for every recompiled function entry).
- OFF (`cmake -DPSX_DEBUG_TOOLS=OFF`): production build. The
hot-path log functions early-return; TCP server doesn't
start; heartbeat doesn't spawn. The ring buffer arrays still
exist in the binary (their definitions live in
debug_server.c) but no writes happen so the demand-paged
memory stays committed at a much lower level (~77 MB at
idle vs ~245 MB in debug mode).
The flag propagates to psx-beetle via the option declared at
the top of runtime.cmake.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SDL accelerated renderer (`SDL_RENDERER_ACCELERATED`) lets
`SDL_RenderPresent` block inside the GPU driver, which has been
reproducibly hanging the SDL main thread mid-frame after extended
uptime. Both psx-runtime and psx-beetle showed the same signature:
process alive, debug socket dead, partial framebuffer (top half
rendered, bottom half stale) — a classic GPU-driver-side hang.
Switching both binaries to `SDL_RENDERER_SOFTWARE` removes the GPU
driver from the path. Verified under stress: five consecutive
`wtrace_dump count=200000` (~6.2 s each, 31 s cumulative
main-thread block) without a single freeze. Beetle, which had been
freezing at ~15 min of plain title-screen idle with constant 90 MB
RSS (so memory pressure was definitively not the cause), now stays
responsive indefinitely.
Cost: a few percent more CPU. Worth it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Config loader (recompiler/src/config_loader.{h,cpp}): single source of
truth for both BIOS and game runtime settings (debug_port, window_title,
memcard_dir, disc path). psxrecomp-bios and psxrecomp-game both accept
--config <toml>; runtime takes --game <toml> (no more CLI overrides).
- runtime/src/main.cpp: replaced --disc/--memcard-dir/--game-root CLI
overrides with --game <toml>; game.toml is the single source of truth.
Discless launch (no args) goes to BIOS shell.
- runtime/src/dma.c: fixed update_master_irq() re-asserting I_STAT bit 3
on every DICR read. BIOS shell DMA handler at 0x80058628 only clears
DICR bit 27 (ch3 flag), so level-sensitive re-assert pinned the kernel
in the exception loop. Bit 3 is now edge-triggered (one assert per DMA
completion). Discless boot now reaches MAIN MENU / MEMORY CARD / CD
PLAYER. With-disc Tomba boots through to Whoopee Camp splash.
- Audit tooling: codegen_audit.py (BIOS), codegen_audit_game.py (game),
audit_config.py (config loader for tools), verify_audit_schema.py.
Replaces older tools/_codegen_audit.py probe.
- Docs: CURRENT_STATE.md (project state), UNIFICATION_PLAN.md (Phase C
translator merge plan), docs/config_schema.md (TOML schema reference),
docs/audit_inventory.md (audit tier inventory).
- .gitignore: tools/_*.py convention for ad-hoc scratch probes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recompiler: register jump-table targets with the cross-function
continuation pass before filtering local goto labels — BIOS jump
tables can legally branch into another discovered function's body,
so the prior local-only check dropped valid emit-side edges.
Dirty-RAM interpreter:
- Dispatch non-local JAL/JALR targets back through psx_dispatch_call
instead of forcing them into the interpreter when the target sits
in static-recompiled code (only targets in dirty pages stay local).
- New always-on flow + per-instruction ring buffers (insn_log,
flow_log) capturing reg state, current_tcb, and task fields for
post-hoc analysis. Block log gains t0/t1/t2.
- guard_yields counter; richer last_unsupported state (entry PC,
entry RA/SP, insn count) for dead-end diagnosis.
SIO: ACK is now visible across a small read window (2 STAT reads)
matching BIOS poll patterns that re-read STAT after CTRL_ACK clears
IRQ but expect ACK to still latch. Per-channel ack_irq_en captured
at shift start so a CTRL write that disables ACK_IRQ_EN mid-shift
doesn't suppress the firing. sio_init now zeros all paced-model
state. Read/write paths poll the debug server every ~1k accesses
so long pad/card spins don't starve diagnostics.
Memcard: auto-format slot files on first init when missing, so
the BIOS shell sees a present-but-empty card instead of "no card".
Runtime main: SDL nearest-neighbor texture scaling (no bilinear
blur on the integer-scaled framebuffer).
Debug server: large expansion of ring-buffer queries and dump
commands feeding the new insn/flow logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New runtime/src/mdec.c + runtime/include/mdec.h: MDEC register port
and DMA word ports. Reads command stream from DMA channel 0 (in);
emits decoded macroblocks via DMA channel 1 (out). mdec_init wired
into main.cpp init sequence.
dma.c — execute_ch0_mdec_in and execute_ch1_mdec_out implementations
for MDEC pipes. transfer_word_count handles sync mode 1 block-count
form (BCR upper half * BCR lower half) plus default block size.
complete_transfer(ch) clears active/start bits and raises DICR + i_stat
bit 3. Channels 2/3/4 still default.
memory.c — dirty-RAM bitmap promoted from u32 (16-page kernel-only) to
DIRTY_RAM_BITMAP_WORDS covering all 2 MB RAM. CD-ROM DMA flags overlay
load ranges via new dirty_ram_mark_executable_range(phys, len), without
treating arbitrary data writes as executable. CPU writes still only
mark the kernel-RAM code region (0..0xFFFF) as before.
dirty_ram_interp.c — opcode coverage extended for game-side install
stubs: MFHI/MTHI/MFLO/MTLO/COP0 MFC0/MTC0/RFE. Add
psx_dispatch_game_compiled hook gated on PSX_HAS_GAME_DISPATCH so
recompiled game EXE entries can take precedence over interpretation
on RAM PCs that are also covered by static codegen.
dirty_ram_interp.h — expose dirty_ram_get_bitmap_word(i) +
_word_count, dirty_ram_mark_executable_range, and the
last_unsupported_pc/insn/reason for the debug_server query landed
in a later commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Boot was running at ~17 simulated FPS (12x slower than realtime) and
audio dropouts followed because the SPU pump generated 735 samples
per simulated vblank. Two changes:
* fntrace_record now no-ops by default (record only when armed). The
legacy "record-all-when-unarmed" path made the runtime burn ~10%
CPU writing every dispatch into a 4M-entry ring. Opt-in to
record-all via fntrace_arm(0xFFFFFFFF).
* function_trace_record gated behind s_fn_trace_active flag (default
off). Previously did a stack walk + tail-call detection + 9-field
ring write on every dispatch, even when no fn-trace filter was set.
Flipped on by handle_fn_filter; new fn_disable command turns it
off again.
Combined: ~12x speedup. Recomp now hits 200+ simulated FPS uncapped,
matching beetle's ~230 FPS.
* Wall-clock pacer in sdl_vblank_present and beetle_main.cpp locks
both backends to PSX-native 59.94 Hz. Audio queue (SDL device
drains at 44100 Hz) now matches generation rate (735 samples * 60
≈ 44100/sec). TAB still works as turbo override on both windows.
The COPY/DELETE flows were silently no-oping the SIO write protocol:
60 dispatches per copy were missing the 3-instruction predicate fn at
0xBFC0989C (the WRITE-command sibling of 0xBFC09890), so the BIOS's
write-handler fork test returned junk via uninitialized v0 and the
BIOS skipped sending 0x57 over SIO entirely. The in-memory card cache
appeared updated (visual UI worked, leave/re-enter showed copies) but
nothing reached memcard_write_sector or the .mcd files.
Also reorganize memcard directory resolution: production builds put
cards next to the exe; dev builds (CMake build tree detected) keep
them in the project root next to bios/ so a runtime rebuild doesn't
delete them. --memcard-dir still overrides.
New runtime/src/crash_trace.c writes psx_last_run_report.json with:
reason / timestamp / frame / last_func_addr / last_store_pc
full CPU register state (all 32 GPRs + hi/lo/sr/cause/epc)
dispatch_ring tail (last 256 dispatched func entries)
unknown_dispatch tail (last 50 entries with addr/phys/ra/a0/a1)
dirty_block_log tail (last 100 entries)
Windows SEH info (when triggered from SEH filter)
Triggered from:
- SIGSEGV / SIGABRT signal handlers
- Windows SetUnhandledExceptionFilter (catches access violations)
- atexit
- psx_unknown_dispatch fail-fast path (set PSX_FAIL_FAST_UNKNOWN_DISPATCH=1)
- trap_crash (existing fatal trap path)
File overwrites per dump (last-write-wins). Per CLAUDE.md §3 this is not
a log — it's a one-shot final state snapshot for crashes the running
TCP server cannot intercept.
Pattern mirrors F:/Projects/SuperMarioWorldRecomp/src/post_mortem.c.
Used the new infrastructure to capture a controlled crash with
0xBFC0989C re-seeded. Crash dump revealed the chain:
- JAL at 0xBFC09788 is jalr $s3, NOT jalr $a0
- $s3 was set in func_1FC09598 prologue: $s3 = $a2 (callee saves arg)
- Caller at 0xBFC093C4 in func_1FC092EC: $a2 = $s3 - 128 (delay slot)
- func_1FC092EC at 0xBFC09314: $s3 = $a3 (callee saves arg)
So real bug is upstream-of-upstream caller passing $a3 = 131 (0x83)
to func_1FC092EC, propagating down to a misaligned function pointer.
The 0xA000E1E8 we suspected as a function-pointer table was a
red herring — $a0 in the loop body is loaded from $fp+s1*4 (the
ARG to pass to the called fn), not the call target.
0xBFC0989C reverted out of seed list (was diagnostic-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Outcomes:
- YES/NO COPY confirm modal closes correctly on CROSS press (returns to
COPY menu — matches Beetle behavior)
- No crash, no freeze
- Memory card 2 visible with a known-good save (sourced from Beetle's
dummy.1.mcr; backup of prior card2.mcd kept as .bak.before_beetle_copy)
- TAB held = turbo mode (skips VRAM->ARGB conversion + SDL_RenderPresent
every frame except every 30th); useful for navigation cycles
Remaining bug:
- The actual save data is NOT being copied. Source card content is not
written to destination. card1.mcd hash unchanged after a copy attempt.
- 0xBFC0989C still hits psx_unknown_dispatch 60x per copy. Bisect
confirmed seeding 0xBFC0989C alone causes a misaligned-dispatch crash
from caller 0xBFC09790 (function-pointer table at runtime addr
0xA000E1E8 indexed by predicate result, gives garbage target 0x3).
Seeding both 0xBFC09890 + 0xBFC0989C causes a softlock instead of crash.
Therefore 0xBFC0989C is intentionally NOT seeded; the copy data path
short-circuits because the predicate's stale-v0 result fails to enter
the actual write code.
Status of the function-discovery-miss bug class:
- VALIDATED for 0xBFC1A1BC (commit 6889f5c) — bandaid worked, modal
closes
- PARTIALLY VALIDATED for the BFC09xxx area — adding 0xBFC09890 alone is
safe but doesn't unblock the copy. 0xBFC0989C cannot be safely seeded
because it's likely a mid-function label, not a function entry. The
proper fix requires recompiling the full function containing 0xBFC09790
(the caller of these predicates) and ensuring the function-pointer
table at runtime address 0xA000E1E8 is populated with valid recompiled
function targets.
Turbo mode: hold TAB on the SDL window. Suppresses 29/30 frame presents,
keeps simulated CPU advancing at full speed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches diagnostic methodology from a single binary running both backends
in lockstep (psx-beetleoracle.exe) to two independent processes:
psx-runtime.exe (recomp only, port 4370)
psx-beetle.exe (Beetle libretro only, port 4380)
Both expose the same JSON wire protocol for the common debug commands
(read_ram, press, set_input, clear_input, pad_status, screenshot_file,
wtrace_*, fntrace_* on Beetle). Cross-process comparison is done by
querying both ports from a tool, NOT by sharing input or memory.
Why: the embedded-oracle setup required keyboard input to drive both
backends in lockstep. Once their state diverged, the same keypress
drove them to different screens and "compare on the same press" became
unreliable. Two independent processes lets each backend be navigated
to its own state and queried on its own timeline.
Removed:
- runtime/src/beetle_psx_bridge.cpp (cannibalised into beetle_libretro.cpp)
- runtime/src/duckstation_bridge.cpp
- runtime/src/psx_oracle_cmds.c
- runtime/src/psx_oracle_stub.c
- runtime/include/psx_oracle_backend.h
- psx-beetleoracle and psx-dsoracle cmake targets
- 22 dual-backend tools (emu_read_ram / find_first_divergence / etc)
Added:
- runtime/src/beetle_main.cpp (SDL window + libretro driver + keyboard)
- runtime/src/beetle_libretro.cpp (libretro callbacks + wtrace/fntrace/sio rings)
- runtime/src/beetle_debug_server.c (TCP server, port 4380, same wire protocol)
- psx-beetle cmake target
Also bumps tooling caps surfaced during modal investigation:
- DIRTY_RAM_BLOCK_LOG_CAP: 16K -> 4M (28ms eviction -> ~7s retention)
- WTRACE_MAX_RANGES: 16 -> 64 (boot defaults filled the prior cap)
- DirtyRamBlockLogEntry: added a0/a1 fields for richer dispatch context
CLAUDE.md section 16 rewritten for the new methodology. New memory note
locks the direction so future sessions don't reach for the deleted
embedded-oracle commands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new always-on diagnostic rings, plus tools to drive Beetle in
parallel and toggle the SDL window between ours and Beetle for visual
validation. Built to support deterministic root-cause analysis of the
memcard directory-load failure.
Card transaction ring (sio.c, sio.h): one entry per 0x81-rooted card
protocol run with full TX/RX bytes, cmd, sector, ack count, terminal
state, end_reason classification (success/abort_reselect/abort_reset/
abort_slot/abort_bad_cmd/abort_other). 4096-entry cap. Surfaced via
card_txn_dump TCP cmd. Live (in-progress) txn included separately.
SIO IRQ #7 delivery ring (sio.c, sio.h): every IRQ #7 fire with byte
seq linkage, mc_state at fire, active_device, ctrl, counter_7514,
i_stat before/after, source classification (card/pad), slot, applied
delay. 4096-entry cap. Surfaced via sio_irq_dump.
EvCB walk ring (debug_server.c): captures full kernel EvCB table
(class/status/spec/mode/fhandler) on every DeliverEvent entry. Reads
ToT layout per nocash: 0x120=ptr, 0x124=size. Exit detection tracked
via direct RA-watch (shadow stack unreliable due to
psx_restore_state_escape longjmp). Surfaced via evcb_snapshot,
evcb_walk_dump, evcb_walk_stats.
card_txn_diff (psx_oracle_cmds.c): aligns our structured txn ring
against Beetle's 64K SIO ring split into transactions, finds first
byte-level divergence, returns context window. SIO_FFD_MAX_BYTES
bumped 16K -> 64K so historical Beetle reads remain in scope.
emu_press (psx_oracle_cmds.c, beetle_psx_bridge.cpp): drives Beetle's
pad input for navigation parity with our `press` cmd. 180-frame cycles
verified to navigate Beetle to MC screen.
F7 toggle (main.cpp): swaps SDL window display source between ours
and Beetle's framebuffer for visual A/B comparison without restart.
Hygiene: 162 stale probe artifacts moved into probes/. .gitignore
extended for probes/ + root-level *.json/*.png/*.bmp/*.txt scratch.
Runtime data (card*.mcd, dummy.*.mcr) intentionally not covered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recompiler set $ra to ROM addresses (0xBFC18xxx) for jal/jalr in
shell-region functions. On real hardware, $ra uses RAM addresses
(0x80030xxx) because the shell runs from RAM. This caused handler/
callback tables to contain ROM addresses instead of RAM, breaking
pointer comparisons that gate the BIOS shell's 3D rendering pipeline.
Also adds:
- tools/find_divergence.py: golden-oracle RAM comparison tool that
diffs recomp (port 4370) vs DuckStation (port 4371) byte-by-byte
using persistent TCP connections
- gte_exec counter in oracle VSync output for interpreter validation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Oracle interpreter:
- JR and JALR were missing delay slot execution (exec_one recursive call),
causing the entire boot sequence to malfunction — C0 table never populated,
oracle stuck at ~65K instructions. Now boots to shell and VSync incrementer
hit 272 times in 100M instructions (counter = 271).
- Added NULL-PC halt trap (jr to address 0 = corrupt function pointer).
- Reworked oracle main loop: counts VSync hits, steps past breakpoints,
dumps trace ring correctly (ring-relative indices), reads VSync counter
and init flags at end of run.
Code generator (longjmp-return):
- RestoreState (A0:0x14) loads $ra from a save buffer via $a0, so jr $ra
must set cpu->pc for the dispatch loop to tail-call to the restored
address. Detected by: first instruction is `lw $ra, 0($a0)` (0x8C9F0000).
- Applied in both code_generator.cpp (block-based) and full_function_emitter.cpp
(linear emitter used by psxrecomp-bios.exe).
Dispatch seeds (+5 new, 832→837):
- 0xBFC421D8: RestoreState longjmp target (mid-function in func_1FC42160)
- 0xBFC4231C: SaveState v0=0 init body (callback table setup)
- 0xBFC42370: func_1FC42160 epilogue (init flag gate branch target)
- 0xBFC42910: post-callback cleanup (was dispatch miss)
- 0xBFC42BF0: shell_register_callback (callback table was empty without it)
Runtime survivability:
- psx_unaligned_access: survivable ADEL with diagnostic dump (was exit(1))
- DMA ch2 linked-list: break on cycle instead of exit(1)
- NULL dispatch (addr=0): silently absorb in psx_unknown_dispatch
Regenerated BIOS: 1056 dispatch entries (was 1051).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Undefined SPECIAL/opcode encodings treated as NOP (R3000 doesn't trap)
- Added VBlank scheduling + interrupt injection to interpreter loop
(fires exception at SR/I_STAT/I_MASK conditions, same as interrupts.c)
- Extended memory control register range to 0x1F80103C
- Added boot trace + PC breakpoint diagnostics
Found: BIOS C0-table lookup returns NULL function ptr at instruction
~65K → jr $t0 where $t0=0 → NOP slide through zero RAM → stuck.
The interpreter needs to handle this gracefully to boot past kernel
init. DuckStation doesn't have this issue because its C0 table is
populated by the time the call happens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds psx-oracle.exe alongside psx-runtime.exe, following the Genesis
recomp hybrid pattern (two targets from shared sources):
- psx-runtime: recompiled BIOS (stub_interpreter.c), port 4370
- psx-oracle: R3000A interpreter (psx_interpreter.c), port 4371
The interpreter implements the full MIPS I instruction set:
ALU, shifts, mult/div, branches, jumps, loads/stores (incl LWL/LWR),
COP0 (MFC0/MTC0/RFE), COP2/GTE (delegated to gte.cpp), SYSCALL/BREAK.
Includes 1M-entry per-instruction trace ring and 64 PC breakpoints —
the tooling gap that blocked VSync counter diagnosis (DuckStation's
TCP debug server only had 2-3 breakpoint slots, no PC breakpoints).
Also extends memory control register range to 0x1F80103C (was 0x1F801020)
for the interpreter's early BIOS init path.
Verified: oracle boots from BFC00000 and correctly executes the first
20+ BIOS instructions. Hits unknown SPECIAL func at PC=0x68 later in
boot — instruction coverage issue to debug next session.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CDROM (adapted from v3, 487 lines):
- Removed event_deliver() — HLE disease. IRQ delivery is now purely
via i_stat bit 2; the recompiled BIOS exception handler processes
CDROM events natively through its own EvCB table.
- Removed CPUState* from all function signatures — hardware registers
don't need CPU state.
- Removed hard-coded RAM writes (0x800DEE54 etc.) — BIOS kernel state
manipulation disguised as hardware sim.
- Removed all fprintf.
- Kept: register banking, command processing, sector reads, DMA interface.
SPU (new, 89 lines, register map from v2 reference):
- Accepts all SPU register reads/writes at 0x1F801C00-0x1F801FFF
- 512KB SPU RAM with transfer address tracking and FIFO writes
- DMA4 write interface for bulk transfers
- SPUSTAT returns 0 (ready, not busy)
- No audio synthesis — placeholder for SDL_audio integration later
Memory.c: replaced inline spu_regs[] array and stub CDROM handlers
with calls to the proper cdrom.c and spu.c modules.
Validated: BIOS boots identically (16,859 GP0 writes, 1,149 draws).
No regression from previous commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three interrupt delivery bugs fixed that were blocking shell progression:
1. RFE after handler exit: The exception handler's normal exit path
(jmpbuf longjmp at 0xBFC10944) does not execute RFE. In our model
the handler returns to psx_check_interrupts, not to the WaitEvent
caller. Without RFE, IEc stays 0 forever after the first interrupt.
Fix: apply RFE in psx_check_interrupts if IEc is still 0 after the
handler returns.
2. GPR save/restore around handler dispatch: The handler's jmpbuf exit
restores callee-saved registers from the kernel jmpbuf (intended for
a non-local jump to WaitEvent's caller). In our model this corrupts
the interrupted code's registers. Fix: save all GPRs before handler
dispatch and restore them after, matching real hardware where the
interrupted code's registers are preserved via TCB save/restore.
3. VBlank tick gated on !in_exception: The handler runs thousands of
block leaders, each calling psx_check_interrupts. Without gating,
dispatch_count advances inside the handler, firing new VBlanks that
stack up and starve the main code. Fix: only increment dispatch_count
and fire VBlank/timer ticks when not in exception handling.
4. DMA IRQ edge-triggered: update_master_irq() was level-triggered,
re-asserting i_stat bit 3 on every call while master_flag=1. Combined
with no BIOS callback to acknowledge DMA IRQs, this caused an
interrupt storm. Fix: edge-detect master_flag 0->1 transition, and
suppress DMA completion IRQs entirely since transfers are synchronous.
Also adds GPU debug diagnostics: GP0 command counters (nop/fill/draw/
env/copy), draw area/offset in gpu_state, vram_peek debug command.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>