New debug server commands for the GL backend: gl_fbo_peek reads a rect
of GPU-side VRAM (via the pack pass) without touching the CPU array,
gl_vram_diff reports full-VRAM FBO-vs-CPU mismatch count/bbox/samples,
and gl_coh_ring dumps the always-on coherency event ring (last n events,
optional frame_min filter).
crash_trace SEH dumps now report the faulting module + base + offset and
scan the stack for executable return addresses, so dumps stay
addr2line-able despite ASLR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The FBO-present crop shader sampled a sub-region of the 1024x512 VRAM
texture with vertex-interpolated UVs; sub-texel precision over the
fullscreen triangle produced a vertical seam and text smearing on screen
(the GPU render itself was correct — forcing the software present path
showed a clean image, which localized the bug to the present sampling).
Replace the crop shader with glBlitFramebuffer: an exact integer-rect
copy of the display region from the FBO to the window, with the vertical
flip folded into the dst rect. No per-fragment UV math, no sub-texel
error. linear=GL_LINEAR (AA) else GL_NEAREST.
Also:
- screenshot now calls gl_renderer_sync_cpu() first, so captures reflect
the on-screen frame under the FBO-present path (was reading stale CPU
VRAM). Safe: the debug server is pumped on the main GL-context thread.
- PSX_GL_FORCE_CPU_PRESENT=1 forces the software readout present path —
a fallback/diagnostic that also keeps CPU VRAM current every frame.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handle_screenshot_file emitted a 24-bit BMP regardless of extension, so
"screenshot_file path=foo.png" produced a file that PNG-only consumers
(and the agent Read tool) rejected. Replace with a self-contained PNG
encoder: 8-bit truecolor, IDAT carried as a zlib stream of stored
(uncompressed) DEFLATE blocks with correct CRC32 + Adler32. No new link
dependency, so the static self-contained runtime stays self-contained.
Default name is now psx_screenshot.png.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two pre-fix attract-idle 'degradations' (2026-06-10 dumps ...393 and
...707) were observer interference, not guest bugs:
- both wedge dumps show 8/8 main-thread stack samples inside WS2_32!send
(same call site) — the main-thread-pumped server blocked sending to a
trickle-draining client, throttling the loop to ~6 fps;
- the 'frame counter reset to 0' was a second process instance (no reset
path exists for s_frame_count or psx_cycle_count; dump ...707 has lower
cycles than ...393);
- the 'kernel-only dispatch loop' signature (0xB0/0x650, restore target
0x80068024, EPC 0x80000048, dispatch_count 0) is the normal Tomba
title-attract idle pattern — identical in a healthy 60-min post-fix soak.
Fix the loophole and make recurrence self-evident:
- send_all_blocking gains a 15 s TOTAL budget per response (the 2 s
SO_SNDTIMEO only bounded zero-progress chunks; a slow-but-alive client
could throttle the emulator indefinitely while heartbeating the
starvation watchdog). Over budget -> client dropped, runtime unharmed.
- cumulative tcp_send_stall_ms + tcp_clients_dropped exported in the
freeze heartbeat (top level + per-tick ring 'tcp_ms') and in every
wedge/fatal dump header, so a TCP-throttled window is attributable at
a glance. Documented in TCP_COMMANDS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- psx_fatal_halt: deliberate fatal sites (unhandled MMIO access,
trap_crash) now write the structured crash report AND a full
freeze-style ring dump (wedge kind "fatal"), then keep pumping the
TCP debug server forever in debug-tools builds so every ring stays
queryable against the exact crash state (ring-buffer-first: crashes
are interrogated, not snapshotted). Release builds exit(1) as before.
Re-entry guarded; heartbeat JSON gains a "fatal" field.
- gp1_dump: dedicated always-on GP1 (0x1F801814 display control) ring,
512K entries (~15 min of gameplay) with frame-window server-side
filtering. The general MMIO ring rolls over in under a minute of SPU/
DMA traffic, which kept evicting boot-window display history before a
probe could ask "who toggled the display during the logo" (bug 1).
- mmio_dump/mmio_clear documented in TCP_COMMANDS.md.
The halt-and-serve path is what made the pig-crash root cause a
one-repro diagnosis.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- debug_server.c: dump_ram = alias of read_ram (one request, one response,
up to full 2 MB); screenshot = screenshot_file (24-bit BMP, optional
"path", default psx_screenshot.bmp); vram_peek cap 64->128; read_ram hex
encode via nibble table (was 2M snprintf calls stalling the main thread);
send_all_blocking bounded (1 MB chunks, SO_SNDTIMEO 2s, heartbeats the
starvation watchdog per chunk) and drops the client on send failure
instead of letting the watchdog kill the runtime; handle_quit tags exit
origin.
- beetle_debug_server.c: read_ram cap 64 KB->2 MB via heap+send_raw;
dump_ram/screenshot aliases; screenshot w/h->width/height parity;
set_input now holds until clear_input (frames param optional) matching
psx-runtime semantics — the old default expired after 1 frame and the
off-by-one in the getter cleared the override before it was ever read,
so scripted beetle navigation silently did nothing; wtrace_dump response
heap-built + send_raw (per-entry send_fmt dropped chunks on multi-MB
responses, producing broken JSON).
- crash_trace: new psx_crash_trace_set_exit_origin(); report gains
exit_origin (tcp_quit / sdl_window_close / unknown).
- debug_client.py: O(n) string-aware brace parser (old per-byte loop was
O(n^2) — minutes on a 2 MB response, which starved the server's send and
got the runtime killed); generic key=value passthrough for any server
command without a bespoke CLI mapping.
- TCP_COMMANDS.md updated for all of the above.
Verified: 2 MB read_ram round-trip 0.75 s; scripted beetle navigation
reaches the memcard file-select; wtrace_dump returns valid JSON at 21 MB.
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>
Two pieces from the variant-coverage investigation:
1. dirty_ram_stats per_pc rows gain an `entries` counter: dispatches
that arrived from NATIVE code (call / dispatch loop), distinguished
from the interpreter's own block-to-block chaining via a consumed
chain-target latch. `hits` conflates the two — below the local-flow
floor every taken branch re-dispatches, so chain blocks dominate and
misattribute hot families. First use REFUTED the main-EXE-interior-
entry hypothesis: the 0x1DFF8/0x1E0D8 family (~1M insns) has ZERO
external entries — it is purely downstream of overlay interp runs,
and collapsed to 57K insns once the overlay variant was compiled.
No main-EXE aliasing needed.
2. compile_overlays.py merges prior-manifest evidence on rebuild: every
F entry in an existing .ranges for the SAME image bytes was proven
compilable, so a fresh (poorer) capture cannot regress coverage.
Callable entries re-enter as captured function entries; non-callable
ones (alias wrappers from a prior build) re-enter as dispatch entries
so the classifier re-derives their interior disposition — feeding
them back as walk roots would truncate their hosts.
Village variant DLL (000E7000_9707AAB5) force-rebuilt with merged
evidence: 195 -> 244 functions incl. interior aliases. Verified on a
user-played village+overworld lap: 0x12A60C (~1M interp insns),
0x138084, 0xED2F4 now native and absent from per_pc; 0 dispatch misses,
0 aborts, 0 invalidations.
Remaining interp load is (a) the variant long tail — the same overlay
region holds scene-dependent variants where one variant's code address
is another variant's DATA (0x800E889C: code in the overworld-family
image, a pointer table in the village image); per-entry hashing
correctly refuses stale native code, and each variant gets covered when
captured — and (b) the kernel-RAM SIO stub at 0xCF0 (~4.6M insns/
session), a separate class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Inline multi-MB dumps wedge the single-threaded debug server (third
occurrence: dirty_block_log count=16384). Block ring joins the insn ring
and event ring in having a file-dump path; client plumbing included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the dwarf-village->overworld native-overlay blue screen
(deterministic, every run, at native call #70):
The dirty-RAM interpreter's jal/jalr handlers preserved the call's
return-address obligation for statically-compiled callees
(psx_dispatch_game_compiled -> *next_pc_out = return_pc) and for non-local
targets (dispatch_nonlocal_call passes return_pc as stop_addr), but a LOCAL
DIRTY target fell through to a bare `cpu->pc = target; return 1`. When that
target was a NATIVE overlay candidate, the dispatch loop ran it as a C
function; its pc==0 return made psx_dispatch_impl unwind to the C caller,
silently dropping the suspended interpreted caller's continuation.
Concrete case: main-EXE field dispatcher F=0x800338A8 (undiscovered entry —
discovery walked it from 0x338B0; reached via data-table jalr, so it runs
under the interpreter) does `jal 0x8011B0D4` per entity. Under native exec
F's epilogue never ran: guest sp leaked 0x18 per entity until the world
corrupted. Interp-only mode is immune (the callee's own jr ra is interpreted).
Evidence chain (full-register fingerprint diff ON vs OFF localized first
divergence to sp at exec #70; frozen dirty-block ring shows F re-entered at
sp-0x18 with no epilogue between) is in docs §9 and the session memory.
Fix: overlay_loader_call_native() — run a validated native candidate as a
unit and resume at return_pc — called from both jal and jalr handlers,
mirroring the psx_dispatch_game_compiled contract exactly. Side effect:
native overlay coverage rose from 86 calls per village->overworld run to
~112,000 (interpreted calls now route to native candidates).
Verified: dwarf->overworld renders and plays under native overlays.
Same-scene A/B: host CPU 7.31 (native) vs 7.83 (interp) CPU-s per 15s wall.
Timing hypotheses refuted by experiment before fixing: IRQ-check cadence
(suppress armed, identical failure) and device-event granularity
(conservative stepping, identical failure).
Also in this commit (the capture tooling that found it):
- fingerprint ring now stores full entry/exit register files + guest cycle
(FP_CAP 2^20 crc-only -> 2^16 full-reg); overlay_fp_dump takes a path.
- insn_freeze <addr> <nth>: one-shot latch freezing insn/block/flow/fntrace
rings immediately BEFORE the Nth dispatch of a watched candidate, so the
pre-divergence window survives until dumped (ring-first, no arm-then-hope).
- dirty_insn_gate <lo> <hi>: runtime-settable extra PC range for the per-insn
interp log; native entry/exit markers (transferred=200/201) in that ring.
- dirty_insn_dump_file: file dump for the insn ring — the 16MB inline TCP
dump wedged the single-threaded debug server.
- freeze gating in the flow-log path gates ONLY the ring write, never flow.
- debug_client plumbing for the new commands + fntrace_arm/fntrace_dump.
- docs/overlay-recompilation-design.md §9: the dispatch call-contract
invariant, the Bug A case study, verification numbers, and known residue
(data-jump-table discovery-miss class; one unreproduced black-screen
one-off; §8.3 tail-position caveat).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reload-on-return does not fire against the current merged-capture DLL. Added
code-span watch/hash (vs whole-region) + diagnostics, and the counters pinpoint
why:
r0_fn_lo=0x000E73C0 r0_fn_hi=0x0013B184 -> 343 KB function span
r0_writes_since_invalid=107222 -> below span/2 threshold (171 KB)
reval_attempts=0 -> never triggered
The DLL was compiled from the merged 1.1 MB roaming capture, so its functions
are scattered across the whole region with mutable data interleaved. There is no
coherent reload unit to threshold or hash: the re-validate trigger never fires,
and a 343 KB hash including data would never match anyway. OV-1 (correctness) is
unaffected — invalidation still fires (no blue screen); only the native speedup
on return is blocked.
Conclusion: reload-on-return requires coherent per-overlay segmentation (split
the merged region into real load units). The reload machinery stays in as inert
scaffolding until segmentation lands. Diagnostics exposed via
overlay_loader_status (r0_*, reval_attempts, reval_crc_miss, last_reval_crc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two additions:
1. overlay_capture_dump TCP command — flush overlay_captures.json on demand,
so a freeze/kill can't lose a capture session (no clean window-close needed).
2. Reload-on-return: when an invalidated overlay region is loaded back with the
SAME content, re-enable its functions natively instead of staying interpreted.
- At register, hash the live region (crc_live).
- On invalidation, keep watching and count writes into the region (instead of
clearing the watch).
- When the region is substantially rewritten (>= size/2 bytes — a real reload,
not incidental data writes), the next dispatch re-hashes once; on a match to
crc_live, flip the region valid again. O(1) — dyntab entries never left, no
DLL reload. Cheap-gated so steady-state churn doesn't pay per-dispatch hashes.
- New counter: revalidations (in overlay_loader_status).
Ghidra note: searched the Tomba main EXE for the overlay load address (0x800E7000
virt / 0x000E7000 phys) — no flat overlay table; the address is computed and the
overlay is copied/decompressed to the code region by a BIOS routine. So overlay
identity must be a runtime content hash (this commit), not a static table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A registered overlay function is only callable while the RAM it was compiled
from is unchanged. Adds coarse, region-level write-invalidation:
- overlay_loader: each loaded DLL gets a region record {base,size,generation,
valid}; dyntab entries carry their region index. dyntab_lookup returns a
function only if its region is still valid (else counts stale_blocked and
falls through). Invalidation is O(1) (flip valid=0) — no open-addressing
deletion. DLL stays loaded; only callability is revoked
(Compiled != Registered != Callable).
- memory.c: a watched-overlay-pages bitmap, set when a region registers; one
bitmap test added to the RAM-store branch of psx_write_{word,half,byte}
(the single audited store chokepoint — all CPU + DMA writes funnel here).
A write into a watched page calls overlay_loader_invalidate_at(), which
flips the region invalid and stops watching it.
- Counters via overlay_loader_status: loads, invalidations, unregistered_funcs,
dispatch_native, dispatch_interp_fallback, stale_blocked, last_write_{pc,
addr,size}, regions.
Coarse by design: ANY write into a registered region invalidates the WHOLE
region (over-invalidation is safe — it falls back to the interpreter, which
self-heals). Per-function/dependency-range narrowing waits for the Inc2
manifest. After invalidation the region is not auto-reloaded this session
(re-registering a CRC-matched cache is also Inc2); the new content runs
interpreted, which is correct.
Co-Authored-By: Claude Opus 4.8 <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>
Purely additive diagnostic instrumentation (0 deletions):
- debug_server.c: always-on BIOS-call ring (psx_bioscall_record) hooked at
the central dispatch chokepoint + unique-call summary table.
- interrupts.{c,h}: psx_check_interrupts_at(cpu, resume_pc) compat shim that
forwards to psx_check_interrupts, so the baseline runtime can link
ape-flavored generated code for instrumented good-vs-bad comparison.
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>
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>
Two distinct bugs were causing OPTIONS-black hard freezes after 12K-65K
frames:
1. interrupts.c + traps.c: exception_jmpbuf is a single global. setjmp
captures the running fiber, but longjmp may be requested from a
different fiber via psx_exception_longjmp / psx_restore_state_escape
during chain-handler dispatch. Cross-fiber longjmp restores RSP to
the wrong stack while Win32 still tracks the calling fiber, corrupting
fiber state until SwitchToFiber eventually hangs.
Fix: track g_exception_owner_fiber at setjmp time. New
deferred_exception_longjmp checks the current fiber; if it's not the
owner, sets a pending flag and SwitchToFiber's back to the owner.
psx_change_thread_fiber's post-SwitchToFiber path observes the flag
and executes the longjmp on the correct stack.
2. gpu.c: gpu_read_gpustat fired gpu_vblank_tick() after 1000 polls,
which invoked sdl_vblank_present (SDL_Delay -> Sleep) from inside
an MMIO read code path. That meant recompiled MIPS spent wall time
in Sleep deep inside the fiber call tree -- confusing to reason
about and a contributor to fiber/longjmp interactions.
Fix: synthetic VBLANK from a poll-storm only updates emulation state
(LCF + I_STAT). The host-side render+sleep happens only at the proper
cycle-paced VBLANK trigger in psx_check_interrupts.
Also adds: dirty_block ring slice to the freeze auto-dump, Win32 main-
thread stack capture for wedge_kind=1 hard freezes (SuspendThread +
StackWalk64 + SymFromAddr), and dbghelp link.
Validated: TombaRecomp psx-runtime ran 30+ minutes / 105K+ frames at
OPTIONS with no hard freeze. Previous freezes consistently hit at
11900, 20493, and 64596 frames.
Diagnostic tooling under tools/_options_session/ that produced this
diagnosis is included.
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>
Pure observability change — not a fix claim.
Both psx-runtime (was 1) and psx-beetle (was 4) had small accept-
queue backlogs. When the SDL main thread stalls (the exact state
we want to probe during a freeze investigation), the kernel
queues the first pending connect and rejects subsequent attempts
with RST. From the client side this looks like ConnectionRefused,
making it impossible to diagnose the stalled process from another
shell.
Backlog 16 lets a handful of probes queue up. Main thread will
eventually accept them in order if it ever resumes. If the
main thread is permanently dead the probes still fail, but at
least the failure mode changes from "looks like the server is
gone" to "the queue is genuinely deep + still stalled."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NES recomp template PRINCIPLES.md §13a calls a dispatch miss a
"SILENT GAME-BREAKING BUG" and mandates checking
`dispatch_misses.log` after every game run. We don't write that log
(no log files per CLAUDE.md §3) — we have an in-memory ring +
`unknown_dispatch_log` TCP command instead. But nothing routinely
surfaces the count, so silent misses can accumulate across sessions
unnoticed.
Now `ping` includes `dispatch_miss_total` and `dispatch_miss_unique`
fields. Every routine health check reveals if discovery has gaps.
0 = healthy. Non-zero on any session = stop and add the addresses
to game.toml / regen before continuing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per CLAUDE.md global rule #2 (and direct user instruction): pause-and-
inspect is the wrong observation primitive for this codebase. It
synthesizes a snapshot at probe time rather than reading the system's
own continuously-recorded history. It also accidentally became a
freeze vector — a Python client that sent `pause` without `continue`
left s_paused=1 with no client to clear it, so the runtime spun in
debug_server_wait_if_paused forever and looked frozen.
Replacement is the ring buffers that already exist:
- fn_entry_dump / fn_entry_tail what code ran
- wtrace_dump what memory was written
- gpu_frame_dump frame=N what GP0 commands were issued
- mdec_trace MDEC events
- sio_trace / sio_pc_trace SIO history
- frame_range / get_frame per-frame state snapshots
Changes:
- handle_pause, handle_continue, handle_step, handle_run_to_frame
now return send_err with a migration message pointing at the
appropriate ring. The state variables (s_paused, s_step_count,
s_run_to) stay declared and zero-valued so freeze_check fields keep
reporting them as 0 (protocol stability).
- debug_server_wait_if_paused is now a no-op (kept exported so
main.cpp's vblank callback stays unchanged).
- The post-frame `if (s_step_count > 0)` / `if (s_run_to > 0)` hooks
are gated behind `if (0)` — dead code, kept as a comment marker.
- debug_server_dirty_break_maybe_pause no longer sets s_paused; it
still records the hit (target, ra, regs, sp, frame) so
dirty_break_state can be queried for the latest event.
- Reverted the client-disconnect auto-clear-of-pause logic added in
40e9b79 — no longer needed, pause is gone.
Verified: pause/step/continue all return the migration error,
frame_count advances steadily (~50 fps) for 30 seconds, no freeze.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the apparent "freeze" observed after issuing debug
commands: tools/_dbg.py and similar one-shot Python clients open a
fresh TCP connection per command, send their request, read the reply,
then close. If the command was `pause` or `step`, the runtime is left
with s_paused=1 and no client to issue `continue`. The runtime then
spins in debug_server_wait_if_paused() forever — SDL window stops
updating, frame counter stuck — and the user reads this as "frozen"
even though the process is healthy.
The legitimate "open a persistent connection, pause, investigate,
continue" workflow keeps the socket open across all of that, so the
disconnect-then-clear behavior doesn't affect it.
Fix: when the client disconnects, clear s_paused / s_step_count /
s_run_to. Verified by sending pause via _dbg.py and watching the
frame counter keep advancing on the next freeze_check query.
Visibility: freeze_check now reports frame_count, paused, step_count,
run_to, client_connected. A "frozen" state can now be classified
without guessing — paused=1 = leftover-pause, paused=0 + frame_count
stuck = real game-loop wedge.
Ring cap reductions:
- WRITE_TRACE_CAP 4M -> 256K (-360 MB worst-case)
- SIO_PC_TRACE_CAP 4M -> 256K
- MMIO_TRACE_CAP 4M -> 256K
- FN_TRACE_CAP 1M -> 256K
- FN_EXIT_TRACE_CAP 4M -> 256K
At 60Hz and ~thousand events/frame each ring still covers ~4 sec —
plenty for the diagnostic flows we actually run. Worst-case runtime
RSS drops from ~1 GB potential to ~100 MB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Always-on ring captures every GP0 command (header + first 6 payload
words) stamped with the frame number issued. Hooked at
gp0_execute_command() for fixed-length commands and at the polyline
start path for variable-length ones. ~70 MB at 1 048 576 entries
(34 bytes/entry); lazy-allocated on first record.
New debug-server commands:
- gpu_ring_stats: total/capacity/oldest+newest frame in buffer
- gpu_frame_dump frame=N count=M: per-frame GP0 entries with raw words
Designed per CLAUDE.md ring-buffer rule (CLAUDE.md global #2):
capture is continuous; observers query a window of interest
post-hoc. No arming, no timed attach.
Use case driving this: fuzzy menu text bug on Tomba title screen
(TombaRecomp ISSUES.md #3 'D'). Lifetime opcode counts couldn't
distinguish which 0x64/0x2C calls draw the menu vs the static
text. With per-frame dump we can now show that frame N has 88 GP0
commands and isolate the 6 0x2C entries that render with degenerate
top-right vertices (= the fuzzy text).
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>
Recompiler — `recompiler/src/function_analysis.cpp` now stops a
function's end_addr at its mapped `jr $ra` + delay slot instead of
stretching through trailing data. Fixes Tomba's 0x80076DB0, a real
136-byte function that was being emitted as a psx_unknown_dispatch
stub because data after it poisoned the boundary heuristic.
`code_generator.cpp` emits `func_<next>(cpu); return;` as a tail
call when a mid-function seed split leaves the physical fall-through
in a different C function, plus a `debug_server_log_call_entry` at
every function entry. `full_function_emitter.cpp` splits dispatch
into `psx_dispatch_impl(cpu, addr, stop_addr)` with public
`psx_dispatch` and new `psx_dispatch_call(cpu, addr, return_addr)`
so JAL/JALR into RAM stubs can return cleanly without growing the
native stack.
Runtime — `runtime/src/dma.c`/`include/dma.h`/`src/memory.c` now
expose `dma_write_masked()`; the 16-bit and 8-bit mmio paths route
DICR/DPCR/CHCR partial writes through it so a partial DICR write no
longer accidentally clears unrelated channels' pending IRQ flags,
and CHCR only triggers DMA when the written mask actually includes
the start bit. Unified `complete_transfer(ch)` across all channels,
removed `prev_master_flag` edge detection.
MDEC (`runtime/src/mdec.c`, `include/mdec.h`) gains a full debug
surface: `MDECDebugState` snapshot, an always-on 4096-entry
`MDECDebugEvent` ring with 11 event kinds (reset, ctrl_write,
cmd_begin/done, decode_done, dma_in/out start/end, output_drained,
read_underflow), and `mdec_state` / `mdec_trace` / `mdec_trace_clear`
debug-server commands. DMA ch0/1 emit start/end events around MDEC
transfers.
CDROM (`runtime/src/cdrom.c`, `include/cdrom.h`) — read stream
separated from one-shot pending state (`read_cmd`, `read_delay`,
`start_read_stream`, `stop_read_stream`, `deliver_read_sector`);
`cdrom_state`/`cdrom_trace` report the new fields.
Thread/fiber tracing — `traps.c`'s Win32-fiber TCB save/restore now
emits 12 thread-event kinds; new `ThreadTraceEntry` ring (64 K) plus
`thread_trace` / `thread_trace_clear` debug-server commands report
both live CPU regs and the target TCB's full saved register block.
wtrace / mmio rings — `WriteTraceEntry` grew (cpu_pc, sp, v0/v1/a0-a3/
t0/t1); capacity 1 M -> 4 M. `MmioTraceEntry` grew (pc, cpu_pc, sp,
a0-a3, sr, epc, i_stat, i_mask). Both dump commands accept
`count`/`newest=1` for reverse iteration. `restore_trace` and
`dirty_block_log` emit extended register fields (a2/a3/sp).
Misc — `interrupts.c` polls debug-server every 16K checks so it
stays responsive without a separate thread. `psx_runtime.h`'s
`call_by_address` routes through `psx_dispatch_call`. Dirty-RAM
interp JAL/JALR consult `psx_dispatch_game_compiled` when the game
is wired in. iso_reader gains the entry points needed by the game
disc path. New `tools/_tomba_probe.py` for port-4470 health checks.
Generated — regenerated `SCPH1001_dispatch.c` against the new
`psx_dispatch_impl` shape.
ISSUES.md — opened Issue #3 (BIOS PS-logo glyph missing on the
disc-detected screen, cosmetic).
.gitignore — added `recompiler/build-asan*/`, `build-checked/`,
`__pycache__/`.
Validated end-to-end: BIOS boots, disc detected, Whoopee Camp FMV
plays with audio. Frame count advances through ~40K frames without
freezes. `unknown_dispatch_log.total = 0`. Remaining FMV cluster
issues (1/4-size render, FMV-2 audio missing, no skip) tracked
under TombaRecomp/ISSUES.md.
Co-Authored-By: Codex (OpenAI) <noreply@openai.com>
traps.c — ThreadWaitFrame setjmp stack plus a Win32-fiber-based
HostThreadFiber pool for BIOS thread context switches. TCB save/restore
maps SR low-4-bits → low-6-bits via the standard PSX exception entry
shift. Backs the BIOS OpenTh/ChangeTh/ReturnFromException paths that
the kernel uses to schedule its own threads on top of the recompiled
exception handler.
debug_server.c — new TCP commands:
- cdrom_state: snapshot of index/stat/irq regs, FIFOs, pending cmd,
seek/read MSF, i_stat.
- cdrom_trace: paginated dump of the cdrom_trace ring (kind/addr/val/
func/pc/frame + state snapshot per entry).
- dirty_ram_unsupported: counts and last PC/insn/reason for opcodes
the dirty-RAM interpreter rejected.
- dirty_ram_stats now also exports the full bitmap as
dirty_bitmap_words[] (matches the bitmap promotion in this branch).
Both surfaces are query-only against always-on ring buffers, no
log files or stderr emission — consistent with the
no-stdio-debug rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause: BIOS music engine reads voice register 6 (byte offset 0x0C,
CURVOL / ADSR_LEVEL) to find a free voice; recomp returned the never-
written spu_regs value (always 0), so the engine saw every voice as
silent and over-recycled voices 0-3 instead of fanning across all 24.
After fix, recomp KEYON-per-voice counts match Beetle exactly (e.g.
v0 2/2, v18 2/2, v22 1/1).
Also:
- Full ADSR envelope (Attack/Decay/Sustain/Release) ported from Beetle's
CalcVCDelta/RunEnvelope. KEYOFF now triggers Release-phase decay
rather than immediate silencing.
- End-without-loop triggers Release (matches RunDecoder), not active=0.
- ENDX latch at 0x1F801D9C/9E now reports actual block-end-reached
state instead of active-mask.
- SPU event ring (KEYON/KEYOFF/END_STOP/END_LOOP) on both backends with
frame-stamped per-voice payload; 1 M entries each (32 MB) for chime +
game-scene headroom. Beetle-side env>0 filter so silent-loop noise
doesn't bury the audible-voice timeline (matches recomp behavior
where deactivated voices don't fire further events).
- spu_voices / spu_events / spu_events_reset debug-server commands on
both ports. send_fmt buffer kept single-shot to avoid extra newlines.
- tools/_spu_ring_diff.py: frame-aligned event-ring diff with per-voice
count summary; chunk-count cap removed (was aborting reads at 98 KB).
- tools/_spu_capture.py, _spu_probe.py, _probe_one.py: targeted probes
used during diagnosis.
Local-only beetle-psx change (gitignored): same env>0 filter at
mednafen/psx/spu.cpp around line 345/352. Rebuild beetle-psx static
lib if reapplying.
Co-Authored-By: Claude Sonnet 4.6 <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.
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>
Modal close on YES/NO COPY confirm now works on recomp. Cause: function
discovery (Ghidra + transitive walk) missed the function at 0xBFC1A1BC
which the BIOS shell calls indirectly during modal close. The 5 nearby
"missing" PCs the diagnostic identified are all in the same gap from
0xBFC1A1B8 to 0xBFC1A3B8; seeding 0xBFC1A1BC discovers the rest
transitively.
Status: modal closes cleanly on X. Actual copy execution still has
downstream missing-function-discovery issues (3 more PCs hit
psx_unknown_dispatch on copy: 0xBFC09890, 0xBFC0989C, 0xBFC02918)
plus likely additional bugs that softlock when those are seeded.
Those remain for follow-up; this commit is the modal-close milestone.
This is a BANDAID per user permission. The proper fix is to improve
function discovery so missed functions like 0xBFC1A1BC are found
automatically. Ghidra didn't see this function, and our transitive
walk didn't reach it either.
Tooling additions:
- psx_unknown_dispatch_record() ring buffer + unknown_dispatch_log
debug command. Replaces the prior file-based miss log per
CLAUDE.md §3 (no log files, ever). Always-on; queryable summary
(per-target counts) or tail (last N entries with addr/phys/ra/a0/a1).
- WTRACE_MAX_RANGES bumped from 16 -> 64 (boot defaults were
crowding out investigative arms).
- DirtyRamBlockLogEntry +a0/a1 fields, ring CAP 16K -> 4M
(previous 28ms eviction made retroactive press-window analysis
impossible).
Diagnostic scripts (kept):
tools/_unknown_dispatch.py — query unknown_dispatch_log
tools/_dispatch_check_v3.py — was a PC ever dispatched (RAM->ROM map)
tools/_dispatch_tail.py — last N dispatched targets
tools/_freeze_diag2.py — softlock investigator
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>
Adds 0x66BC0..0x66BD0 to the always-on wtrace ranges. Used to find that
the freeze on COPY click in BIOS shell memcard menu happens because:
- shell state-machine index at mem[0x80078320] advances to 7
- state-7 struct at 0x80113C80 has handler=0x800321BC (RAM, non-null)
and input_mask=0x00F000F0 (D-pad bits only)
- non-D-pad buttons (CROSS/CIRCLE/TRIANGLE) are masked out in this
state, and the D-pad handler at 0x800321BC never advances state
Memo follows separately. This commit is purely the diagnostic addition
so the wtrace coverage is in master for future repro.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Python clients open a fresh socket per command — every press call would
return its OK, the client would close, and the server's recv path would
see the close and reset s_input_override = -1 the same frame. Net effect
was that every scheduled press lasted ~1 frame regardless of the
requested frame count, leaving the BIOS to see at most a single edge
event before the override evaporated.
Removing the reset lets s_input_frames count down naturally, so a press
holds for the full requested duration even though the issuing client
isn't connected for the duration. pad_status now reports
override=0xBFFF override_frames=110/99/88/... across half-second polls
during a 120-frame press, instead of override=-1 the moment after the
press is scheduled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: only sector 0 read succeeded; sector ≥1 reads aborted at 2 bytes
because mc_flag was loaded from mc_slots[].flag on every 0x81 select but
never saved back. The clear in MC_CMD lived only in the working static, so
every reselect re-loaded the stale 0x08 ("new card / changed battery") init
value, and the BIOS bailed every multi-sector op as "card was just inserted,
re-init."
After: BIOS shell memcard menu reads the full 15-sector directory area for
both slots. Real save filenames decode (e.g. "BISLPS-01234SAVE00") and per
card icons render in the bottom panes — the Phase 4 success criterion.
Also bundled this session:
- debug_server: B9D0..B9F0 added to always-on wtrace ranges; fn_dump_parse
default window scoped to 1M most-recent entries (filter scans against the
full 128M ring used to freeze the debug server thread for many seconds).
- ISSUES.md + diagnostic tools/*.py written during the audit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two passive, always-on rings for auditing memory-card reads end-to-end
without arming/timing probes:
card_read_summary (non-evicting, first 32 successful reads):
Captured at the MC_READ_END transition in sio_process_byte. Records
slot/cmd/sector/checksum/data_idx/first 16 bytes/cycle/caller func.
TCP cmds: card_read_summary, card_read_summary_reset.
card_data_writes (4096 entries, rolling tail):
Arm side: sio_read(SIO_RX_DATA) sets a pending flag while the card
protocol is in MC_READ_DATA..MC_READ_END.
Check side: psx_write_byte/half/word capture the next RAM write
whose new_val matches the armed RX byte, then disarm.
Records dest addr / value / store_pc / func / mc_idx / slot / cycle.
TCP cmds: card_data_writes, card_data_writes_reset.
Helpers:
tools/probe_card_reads.py
tools/probe_card_writes.py
tools/probe_card_writes_raw.py
tools/peek_cards.py
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TX_DATA writes now route through the SIO0 shifter under
SIO_MODEL_CYCLE_PACED. Pad and card share the single-byte shifter and
TX buffer; bus owner is tagged on the first byte of each SELECT cycle.
sio_advance() is driven from psx_advance_cycles() so the shifter and
ACK pipeline progress on the guest CPU clock without touching the
legacy IRQ countdown.
The SELECT-deassert and TX_EN 1->0 paths now restore TX_RDY|TX_EMPTY
when they kill an in-flight shifter. Without this, sio_stat retained
the TX-clear bits set by SHIFT_START and the next handler busy-waited
forever on TX_RDY=0 inside the exception, deadlocking the BIOS.
Watchdog dump captured the deadlock at chain handler 0x445C with
stat=0x0000 across 4 seconds of PC samples.
Diagnostics added (passive/gated):
- starvation_ring records SIO MMIO events + periodic PC samples,
watchdog dumps to JSONL on TCP-poll starvation
- starvation_walk.py queries the dump (last/last_change/why_idle)
Validation: shell-only sanity stable; one CROSS press no longer
hangs; mc_read_done climbs from 0 -> 30+ with mc_aborts=0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>