USER PIVOT 2026-07-02 (CLAUDE.md §0 amendment included): psxrecomp now carries an opt-in High-Level Emulation backend alongside LLE. LLE (the recompiled BIOS) stays the default, the reference implementation, and the correctness/timing oracle; every service HLE does not implement falls through to LLE, so HLE is never load-bearing beyond what it covers. Mechanism: full_function_emitter.cpp emits a null-by-default hook g_psx_bios_hle_hook consulted at the top of every psx_dispatch_impl iteration, on the pre-normalize physical address, BEFORE any backend (game image / dirty-RAM / static table) claims the target. A handled call resumes at $ra through the trampoline's existing return/tail contract. NULL (default) = pure LLE, dispatch-identical. Regenerating BIOS+game images is required (emitter change). v1 call-HLE (runtime/src/bios_hle.c): the B0 event family — DeliverEvent (incl. callback dispatch with the kernel-true $ra 0x1720), OpenEvent, CloseEvent, TestEvent, EnableEvent, DisableEvent — each ground-truthed instruction-by-instruction against the SCPH1001 kernel disassembly (0xBFC11644..0xBFC11A84) and operating on the REAL guest EvCB table ([0x120]/[0x124], stride 0x1C). WaitEvent/threads/pads/card/A0/C0 fall through to LLE. Cycle charges are documented approximations; LLE remains the timing oracle. No-stubs stands. Boot HLE: with the tier on (and no keep-intro opt-out) the hook one-shot intercepts the first dispatch of the BIOS shell entry (RAM 0x30000 — LoadRunShell's indirect call always routes through the dispatcher), so BIOS Main() proceeds straight to SYSTEM.CNF + game EXE load with kernel state built entirely by the real recompiled kernel init, unpaced (boot turbo) until game handoff. This REPLACES the fast_boot snapshot capture/restore; fast_boot=true now aliases the boot-skip alone. Selection: [runtime] bios_hle / bios_hle_keep_intro (+ settings.toml bios_hle mirror), PSX_BIOS_HLE / PSX_BIOS_HLE_KEEP_INTRO env overrides, startup banner bios_backend=/bios_boot=. The PSX_HLE_SCHEDULER spike is folded in as the tier's standing subsystem replacement: default from [runtime] hle_scheduler via psx_hle_scheduler_set_default, env wins. Observability: always-on 16K-entry HLE ring (route LLE-fallthrough / HLE / boot-skip per record) + hle_dump TCP command (TCP_COMMANDS.md). VALIDATED on Tomba (build-wt): LLE pass byte-identical behavior (real intro, card check, save/load OK); HLE pass boots shell-less, TestEvent/ DeliverEvent route HLE, ChangeTh/ReturnFromException/OpenTh/CloseTh and the card stack fall through to LLE, save/load OK (user-driven). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 KiB
TCP Debug Server Commands
Protocol: JSON over newline, one object per line, responses on same connection.
- Request shape:
{"id": N, "cmd": "<command>", ...params} - Success:
{"id": N, "ok": true, ...data} - Failure:
{"id": N, "ok": false, "error": "<msg>"}
There are two servers, both implementing this protocol with overlapping command sets:
| Server | Port | Source |
|---|---|---|
| Native (our recompiled runtime) | 4370 |
runtime/src/debug_server.c |
| DuckStation (oracle) | 4371 |
duckstation/src/core/psxrecomp_debug_server.cpp (patched, see tools/duckstation/psxrecomp_oracle.patch) |
The debug_client.py CLI can target either, or compare two at once to diff state live — that's how divergence hunts work.
python tools/debug_client.py <cmd> [args] # native (port 4370)
python tools/debug_client.py --port 4380 <cmd> # psx-beetle
python tools/debug_client.py --ds <cmd> [args] # duckstation (port 4371)
python tools/debug_client.py compare <cmd> # run on both, diff results
Commands without a bespoke CLI mapping pass through generically: extra
args of the form key=value become JSON fields (ints when numeric, else
strings), so every server command is reachable, e.g.
debug_client.py --port 4370 gpu_frame_dump frame=14528 count=65536.
Command inventory
Columns: N = native, D = DuckStation oracle.
| Command | N | D | Params | Description |
|---|---|---|---|---|
ping / frame |
✓ | ✓ | — | Heartbeat + current frame number |
get_registers (regs) |
✓ | ✓ | — | All 32 GPRs + PC + HI + LO (native also: COP0 SR/Cause/EPC, I_STAT, I_MASK) |
read_ram |
✓ | ✓ | addr, len |
Read bytes from PS1 address space as hex string — up to the full 2 MB in ONE response line. dump_ram is an alias (the old chunked multi-line variant is gone: it broke the one-request/one-response protocol and wedged the server) |
write_ram |
✓ | ✓ | addr, hex |
Write bytes to PS1 address space |
read_scratch |
✓ | addr, len |
Read PS1 scratchpad (0x1F800000 region) | |
read_vram / vram_peek |
✓¹ | ✓ | x, y, w, h |
Read 16-bit VRAM pixels (max 128×128) |
gpu_state |
✓ | ✓ | — | Display area, display depth, draw offset, GPUSTAT, clip rect, xfer state |
sio_state |
✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history |
irq_state |
✓ | ✓ | — | I_STAT, I_MASK (both), plus chain state on native |
dma_state |
✓ | ✓ | — | DPCR, DICR, all 7 channel states (madr/bcr/chcr) |
event_state |
✓ | — | EvCB table summary (stub on DS — events are BIOS-level) | |
overlay_state |
✓ | — | Current overlay info | |
sljit_async |
✓ | — | Off-thread sljit compile-worker telemetry: worker_running, async_on, always-on counters (enqueued/compiled/failed/queued_now), last_compile_ms/max_compile_ms (off-thread compile time), resolved cache_dir, and a recent[] ring of the last 64 compiles (per-shard phys/crc/code_len/ms/ok). Query it to prove the worker compiles off the dispatch thread without a frame hitch |
|
cdrom_sector_dump |
✓ | offset, len |
Dump bytes from the last CD-ROM sector observed by the controller, including LBA/mode metadata | |
cdrom_sector_history |
✓ | count, optional lba |
Dump newest CD-ROM sector history entries, including raw XA subheader fields, CPU/audio delivery flags, and the first 128 bytes | |
cdrom_sector_history_clear |
✓ | — | Reset the CD-ROM sector history ring | |
watch |
✓ | ✓ | addr |
Set byte-level memory watchpoint (fires per-frame on change) |
unwatch |
✓ | ✓ | addr |
Remove memory watchpoint |
set_input |
✓ | ✓ | buttons, optional frames, optional lx, ly, rx, ry |
Override pad1 buttons and optional analog axes (PS1 inverted bitmask, 0 = pressed; axes 0-255). Holds until clear_input on both backends; pass frames=N (beetle) to auto-release after N frames |
clear_input |
✓ | ✓ | — | Remove input and analog axis overrides |
turbo |
✓ | enabled |
Enable/disable TCP-controlled frontend turbo for fast-forward validation | |
turbo_state |
✓ | — | Query TCP-controlled turbo state | |
pause |
✓ | ✓ | — | Pause emulation |
continue (c) |
✓ | ✓ | — | Resume emulation |
step |
✓ | ✓ | [count] |
Step N frames (default 1) |
run_to_frame |
✓ | ✓ | frame |
Run until frame number, then pause |
history |
✓ | ✓ | — | Ring buffer stats (frames available) |
get_frame |
✓ | ✓ | frame |
Full frame record from ring buffer |
frame_range |
✓ | ✓ | start, end |
Range query, max 200 frames |
frame_timeseries |
✓ | ✓ | start, end |
Compact timeseries, max 200 frames |
set_snapshot |
✓ | ✓ | slot, addr, size |
Configure per-frame RAM snapshot region (slots 0-3) |
get_snapshots |
✓ | ✓ | — | Show snapshot config |
screenshot |
✓ | ✓ | path (optional) |
Write a 24-bit BMP of the current display to path (default psx_screenshot.bmp in the runtime cwd); single metadata response {path,width,height}. screenshot_file is an alias; the old inline-hex-row screenshot is gone (it streamed h+1 response lines per request and poisoned the connection) |
first_failure |
✓ | — | Find first divergence point between runs (native-side tracking) | |
read_frame_ram |
✓ | addr, len, frame |
Read RAM as of a specific frame (from ring buffer) | |
wtrace_range |
✓ | lo, hi |
Set RAM-write trace range (ring of 1024 writes with RA) | |
wtrace_dump |
✓ | beetle | optional addr_lo, addr_hi, count, newest |
Dump RAM-write trace entries as JSON. The address filter is applied server-side over the FULL ring before the emit cap — always pass it when hunting a specific buffer, otherwise you only see the oldest count entries of the whole ring |
wtrace_clear |
✓ | — | Reset the trace ring | |
mmio_dump |
✓ | optional addr, count, newest |
Dump the always-on MMIO write ring (256K entries, ALL 0x1F801xxx writes — SPU/DMA traffic rolls it in well under a minute of gameplay; for display history use gp1_dump) |
|
mmio_clear |
✓ | — | Reset the MMIO write ring | |
gp1_dump |
✓ | optional frame_lo, frame_hi, count, newest |
Dump the dedicated ALWAYS-ON GP1 (0x1F801814 display control) ring — 512K entries ≈ 15 min of gameplay (Tomba writes ~10 GP1/frame), survives the general MMIO ring's eviction. Frame filter is server-side over the full ring. Each entry: val + func/pc/cpu_pc/ra/sp/a0/a1/sr/epc/frame | |
pc_break |
✓² | addr |
DS execute breakpoint, state captured on hit (via pc_hit_last) |
|
pc_unbreak |
✓² | addr |
Remove an execute breakpoint | |
pc_break_list |
✓² | — | List active execute breakpoints | |
pc_hit_last |
✓² | — | Captured state (PC, $ra, all GPRs, COP0) from most recent PC break hit | |
pc_hit_clear |
✓² | — | Clear the last-hit record | |
quit |
✓ | — | Shutdown native runtime |
¹ Native vram_peek is the legacy name; DS calls it read_vram. Same semantics.
² The pc_* family is specific to the DS oracle: DuckStation's CPU core honours CPU::AddBreakpointWithCallback, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks).
Divergence-hunt workflow
When a recompiled-BIOS bug is suspected, the two servers let you find the first divergence instead of chasing symptoms. Standard procedure (inherited from v3's DEBUG.md):
- Sync state via PC + registers, not frame number. Frames drift after even a single timing glitch. Pause both servers; compare
get_registersuntil they match. - Dump both sides fully. Compare
get_frame,gpu_state,irq_state,dma_state(DS),dump_ramover the same regions. - Byte-level comparison. Tiny mismatches usually point at one subsystem. Use
debug_client.py compare <cmd>for automatic diff. - Find the earliest mismatch, not a later symptom. Ring-buffer queries (
frame_range,read_frame_ram) help locate which frame went wrong. - Trace the write. Use
watchto catch the divergent store, or DS'spc_breakon the suspect function entry. Look at$rainpc_hit_lastto identify the caller chain. - Classify. codegen (recompiler generates wrong instruction), runtime (MMIO or kernel simulation wrong), timing (IRQ cadence), or BIOS (real-hardware quirk we didn't model).
- Minimal fix in the correct subsystem. Never hand-deliver state to hide the symptom (see CLAUDE.md §0).
Server send budget + serve-stall telemetry
The server is pumped on the main thread: every millisecond it spends
sending a response is a millisecond the emulator does not run. Inline
responses are bounded — 2 s per zero-progress chunk and 15 s total per
response; a client that exceeds the budget is disconnected (the runtime
never stalls indefinitely). Responses bigger than the budget allows must
use the *_dump_file variants, which write to disk instead of the socket.
Cumulative serve-stall is exported as tcp_send_stall_ms /
tcp_clients_dropped in psx_freeze_heartbeat.json (plus per-tick
tcp_ms in its ring) and in every wedge/fatal dump header. Check these
first when diagnosing slow or stalled frames — on 2026-06-10 two
"attract-idle degradations" turned out to be a TCP client trickle-draining
mega-dumps, throttling the main loop to 6 fps (all 8 watchdog stack
samples inside WS2_32!send). A slow-frames wedge with a large
tcp_send_stall_ms delta over the same window is observer interference,
not a guest bug.
Call-contract (bail) telemetry
The dispatch call contract (Bug D family fix, 2026-06-10) guards every
generated continuation: it may only run if the guest actually returned to
the call site ($ra == site return address, $sp == caller's sp at the
call). Violations begin a "bail" unwind that abandons stale C frames and
re-dispatches the guest's true target. Counters in
psx_freeze_heartbeat.json and dump headers:
bail_first— contract violations detected (wild returns). Nonzero during gameplay means the game executed a wild control transfer (e.g. Tomba's dead jumptable casejal 0x80120B3C, the chest freeze). A small count with the game continuing normally is the fix working.bail_resolved— unwinds that resolved at an enclosing call site whose contract matched (multi-level return).bail_flattened— unwinds that reached the outermost dispatch loop and re-dispatched the wild target on a clean host stack.bail_anomaly— bail flag observed at exception entry (must stay 0; anything else is a runtime bug).
hle_dump — BIOS-HLE tier call ring (native only)
Always-on ring (runtime/src/bios_hle.c, 16K entries) recording every
A0/B0/C0 kernel-vector dispatch the HLE tier's hook observes, plus the boot
shell-skip event. Empty in pure-LLE mode (the tier installs no hook there —
use bioscall_dump for LLE-side vector observation).
{"cmd":"hle_dump"}— status:backend(HLE (LLE fallback)/LLE (recompiled BIOS)),boot_skip,boot_turbo_active,total.{"cmd":"hle_dump","tail":N}— last N entries:seq,cycle(guest cycle),vec(0xA0/0xB0/0xC0, or 0x30000 for the boot skip),fn($t1 function number),a0..a3,ra,v0(result when HLE-serviced),route(0 = fell through to LLE, 1 = serviced in HLE, 2 = boot shell-skip).- Filters:
"fn":N,"route":0|1|2.
Rule when the server can't answer your question
If an inspection need isn't covered by the existing commands, do not fall back to printf or log files. Instead:
- Add a handler in
runtime/src/debug_server.c(native) - Add the matching handler in
duckstation/src/core/psxrecomp_debug_server.cpp(DS), regenerate the patch via the instructions intools/duckstation/README.md - Keep field names parallel between the two
- Update this file
The TCP server is the canonical instrumentation surface. Rule 3 in CLAUDE.md is absolute: no fprintf(stderr, …) in source code, ever, for any reason.