diff --git a/.gitignore b/.gitignore
index f702fa5b..ecd0f9f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,12 @@
# BIOS ROM (copyrighted, not distributable)
bios/
-# DuckStation (2.1GB modified build, tracked separately)
-duckstation/
+# DuckStation — upstream source is a git submodule pointing at stenzek/duckstation.
+# Our custom changes live in tools/duckstation/psxrecomp_oracle.patch (committed).
+# Only the build artefacts and prebuilt binary deps are untracked at the parent level;
+# the submodule's own .gitignore handles in-tree ignores.
+duckstation/build/
+duckstation/dep/prebuilt/
# Ghidra project files (large, regenerable)
ghidra/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..0723e81e
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "duckstation"]
+ path = duckstation
+ url = https://github.com/stenzek/duckstation.git
diff --git a/CLAUDE.md b/CLAUDE.md
index 1ab048ba..39280b42 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -58,8 +58,14 @@ Truth comes from two sources:
- **Ghidra** for what the BIOS code is supposed to do (static analysis
of `SCPH1001.BIN` loaded at `0xBFC00000`)
-- **DuckStation** at `F:/Projects/psxrecomp-v4/duckstation/build/bin/`
- for what real PS1 hardware does at runtime (dynamic oracle)
+- **DuckStation** at `F:/Projects/psxrecomp-v4/duckstation/build/bin/duckstation-qt.exe`
+ for what real PS1 hardware does at runtime (dynamic oracle). The `duckstation/`
+ directory is a **git submodule** pinned at upstream `stenzek/duckstation`
+ commit `ffb33c281` (release-20260328 era). Our PSXRecomp TCP debug server
+ patch lives at `tools/duckstation/psxrecomp_oracle.patch` and auto-applies
+ via `tools/duckstation/setup.sh`. Never commit changes into the submodule —
+ edit files freely for local experimentation, then regenerate the patch with
+ `git -C duckstation diff > tools/duckstation/psxrecomp_oracle.patch`
Use both, never just one. Don't guess. Don't say "probably". If you
cannot answer a question from Ghidra or DuckStation, the answer is
@@ -244,4 +250,27 @@ If something is unknown:
→ STOP
→ produce artifact showing unknown
-Do NOT guess behavior.
\ No newline at end of file
+Do NOT guess behavior.
+
+---
+
+## 15. DuckStation oracle setup (from fresh checkout)
+
+On a fresh clone of this repo, the DuckStation oracle is not yet ready. Run:
+
+```bash
+git submodule update --init --recursive duckstation
+bash tools/duckstation/setup.sh # clones deps, applies psxrecomp_oracle.patch
+bash tools/duckstation/build.sh # builds Release x64 via MSBuild
+```
+
+The result is `duckstation/build/bin/duckstation-qt.exe` — the oracle binary.
+For headless launch it needs `settings.ini` in `duckstation/build/bin/` with
+`PathNTSCU = SCPH1001.BIN` under `[BIOS]` and `SearchDirectory = bios`, plus
+`bios/SCPH1001.BIN` copied next to the exe. `tools/duckstation/setup.sh` handles
+these post-build config steps. See `tools/duckstation/README.md` for the full
+layout and how to regenerate the patch.
+
+**Never edit the submodule source to work around upstream bugs.** If a change
+is needed in DuckStation's side, edit in the working tree, then regenerate the
+patch against the pinned base — so the change is reviewable in a single diff.
\ No newline at end of file
diff --git a/DEBUG.md b/DEBUG.md
index f57b9aa1..dec7b5c8 100644
--- a/DEBUG.md
+++ b/DEBUG.md
@@ -81,3 +81,104 @@ discovery:
- Fix: function finder pipeline
NEVER fix generated output. Fix the tool that produced it.
+
+---
+
+# END-TO-END COMPLETION RULE (NON-NEGOTIABLE)
+
+When debugging boot/runtime flows, **never accept an internal milestone as success
+unless the full user-visible loop closes.**
+
+Examples of FALSE success:
+- "counter reached 0" is NOT success if the shell still retries
+- "IRQ fired" is NOT success if the waiting code still loops
+- "callback returned" is NOT success if the owning state machine does not advance
+- "data structure written" is NOT success if the consumer still rejects it
+- "handler installed" is NOT success if the VBlank counter never increments
+- "chain head populated" is NOT success if pixels don't reach the screen
+
+Required method:
+1. Define full end-to-end success criteria FIRST — what does the USER see when it works?
+2. Trace producer AND consumer — follow the data from origin to final consumer
+3. Find the first semantic divergence — where does the consumer's view differ from expected?
+4. Fix subsystem behaviour — correct the root cause in the producing subsystem
+5. Rerun the full flow — verify the entire loop closes, not just the intermediate step
+6. Reject any patch that only changes intermediate state without closing the loop
+
+## Stale Hypothesis Rule
+
+If a handoff contains stale claims contradicted by new logs, **update the problem
+statement before changing code**. Never continue debugging from an invalidated
+hypothesis. If there are contradictions or stale data, STOP and inform the user
+immediately before proceeding.
+
+(Seen in the 2026-04-13 VBlank stall: the handoff said "handler is never installed"
+but live TCP inspection proved the handler WAS installed at `0x800DFEE0` — the real
+gap was one level up, in the kernel root-counter chain head at `*(0xA000E014)`. The
+entire static-analysis cascade below that stale claim was wasted effort.)
+
+---
+
+# TOOLING RULE (NON-NEGOTIABLE)
+
+If at ANY point during debugging the TCP debug server cannot answer a question
+you need answered — **STOP IMMEDIATELY**. Do not:
+
+- Guess the answer
+- Work around it with printf/fprintf
+- Proceed with partial information
+- Make assumptions based on code reading alone
+
+Instead:
+
+1. **STOP** all current work
+2. **INFORM the user** exactly what data is missing and what command is needed
+3. **ASK the user** how to proceed (build the command? change approach?)
+4. **BUILD the tooling** if approved, then resume
+
+Adding a new command is cheap — the pattern is one handler in `runtime/src/debug_server.c`
+(native) and a matching handler in `duckstation/src/core/psxrecomp_debug_server.cpp`
+(DuckStation oracle, then regenerate `tools/duckstation/psxrecomp_oracle.patch`).
+See `TCP_COMMANDS.md` for the protocol.
+
+A wrong answer from missing tooling costs MORE than the time to build the tool.
+Every workaround becomes technical debt that hides the real bug longer.
+
+---
+
+# TOOL LIFECYCLE RULE (NON-NEGOTIABLE)
+
+Claude builds, launches, and closes ALL tools. The user NEVER gatekeeps this.
+
+- Build native: `(cd runtime && cmake --build build)` (with `PATH=/c/msys64/mingw64/bin:$PATH`)
+- Build DuckStation oracle: `bash tools/duckstation/build.sh`
+- Launch native: `./runtime/build/psx-runtime.exe &`
+- Launch DuckStation oracle: `./duckstation/build/bin/duckstation-qt.exe -bios -nogui -fastboot &`
+- Close / restart: `taskkill //F //IM psx-runtime.exe` or `//IM duckstation-qt.exe`
+
+Do NOT ask the user to launch, close, or restart anything.
+Do NOT tell the user what commands to run — just run them.
+
+### Kill-and-rebuild procedure (Windows)
+
+The exe file lock can persist after process exit (AV, delayed handle release).
+Always use this sequence:
+
+```bash
+# 1. Kill any running instance
+taskkill //F //IM psx-runtime.exe 2>/dev/null
+taskkill //F //IM duckstation-qt.exe 2>/dev/null
+
+# 2. If linker still fails with "Permission denied", delete the exe first
+rm -f runtime/build/psx-runtime.exe
+
+# 3. Rebuild
+PATH=/c/msys64/mingw64/bin:$PATH
+(cd runtime && cmake --build build -j)
+
+# 4. Relaunch (one instance at a time — memory policy)
+./runtime/build/psx-runtime.exe &
+```
+
+Never use `C:/msys64/msys2_shell.cmd` — it spawns orphan processes. Use `bash` with
+`PATH=/c/msys64/mingw64/bin:$PATH` inline.
diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md
new file mode 100644
index 00000000..70db4693
--- /dev/null
+++ b/TCP_COMMANDS.md
@@ -0,0 +1,100 @@
+# TCP Debug Server Commands
+
+Protocol: **JSON over newline**, one object per line, responses on same connection.
+
+- Request shape: `{"id": N, "cmd": "", ...params}`
+- Success: `{"id": N, "ok": true, ...data}`
+- Failure: `{"id": N, "ok": false, "error": ""}`
+
+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.
+
+```bash
+python tools/debug_client.py [args] # native (port 4370)
+python tools/debug_client.py --ds [args] # duckstation (port 4371)
+python tools/debug_client.py compare # run on both, diff results
+```
+
+---
+
+## 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 |
+| `write_ram` | ✓ | ✓ | `addr`, `hex` | Write bytes to PS1 address space |
+| `dump_ram` | ✓ | ✓ | `addr`, `len` | Chunked RAM read (up to 64 KB) |
+| `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, 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 |
+| `watch` | ✓ | ✓ | `addr` | Set byte-level memory watchpoint (fires per-frame on change) |
+| `unwatch` | ✓ | ✓ | `addr` | Remove memory watchpoint |
+| `set_input` | ✓ | ✓ | `buttons` | Override pad1 buttons (PS1 inverted bitmask, 0 = pressed) |
+| `clear_input` | ✓ | ✓ | — | Remove input override |
+| `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` | Save display to PNG |
+| `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` | ✓ | | — | Dump RAM-write trace entries as JSON |
+| `wtrace_clear` | ✓ | | — | Reset the trace ring |
+| `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`):
+
+1. **Sync state via PC + registers, not frame number.** Frames drift after even a single timing glitch. Pause both servers; compare `get_registers` until they match.
+2. **Dump both sides fully.** Compare `get_frame`, `gpu_state`, `irq_state`, `dma_state` (DS), `dump_ram` over the same regions.
+3. **Byte-level comparison.** Tiny mismatches usually point at one subsystem. Use `debug_client.py compare ` for automatic diff.
+4. **Find the earliest mismatch**, not a later symptom. Ring-buffer queries (`frame_range`, `read_frame_ram`) help locate which frame went wrong.
+5. **Trace the write.** Use `watch` to catch the divergent store, or DS's `pc_break` on the suspect function entry. Look at `$ra` in `pc_hit_last` to identify the caller chain.
+6. **Classify.** codegen (recompiler generates wrong instruction), runtime (MMIO or kernel simulation wrong), timing (IRQ cadence), or BIOS (real-hardware quirk we didn't model).
+7. **Minimal fix** in the correct subsystem. Never hand-deliver state to hide the symptom (see CLAUDE.md §0).
+
+---
+
+## 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:
+
+1. Add a handler in `runtime/src/debug_server.c` (native)
+2. Add the matching handler in `duckstation/src/core/psxrecomp_debug_server.cpp` (DS), regenerate the patch via the instructions in `tools/duckstation/README.md`
+3. Keep field names parallel between the two
+4. 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**.
diff --git a/duckstation b/duckstation
new file mode 160000
index 00000000..ffb33c28
--- /dev/null
+++ b/duckstation
@@ -0,0 +1 @@
+Subproject commit ffb33c281d196eb8ee0f559085ca285de7cdd51b
diff --git a/generated/relocation_proofs/vblank_registrar/DUCKSTATION_FINDINGS.md b/generated/relocation_proofs/vblank_registrar/DUCKSTATION_FINDINGS.md
new file mode 100644
index 00000000..3776bc14
--- /dev/null
+++ b/generated/relocation_proofs/vblank_registrar/DUCKSTATION_FINDINGS.md
@@ -0,0 +1,88 @@
+# VBlank registrar — DuckStation dynamic diagnosis (2026-04-13)
+
+## TL;DR — prior diagnosis was wrong
+
+The previous handoff claimed "VBlank handler 0x8005A5BC is never installed in our runtime". **This is FALSE.** Dynamic TCP-probing of our own runtime proved the handler IS installed at 0x800DFEE0. The real gap is one level deeper: the **kernel's root-counter IRQ chain head** at `*(0xA000E014)` is null in our runtime but populated (=`0x000074A8`) in DuckStation.
+
+## Method
+
+Launched DuckStation headlessly (`./duckstation/build/bin/duckstation-qt.exe -bios -nogui -fastboot`) and connected to its TCP debug server on port 4371. Launched our own runtime on port 4370. Then diffed RAM state between the two at matching boot phases.
+
+Tools added this session (all in `tools/`):
+- `duck_query.py`, `duck_query2.py`, `duck_query3.py`, `duck_query4.py`, `duck_query5.py` — focused state-diff scripts
+- `duck_search.py` — 2 MB RAM scan for marker addresses
+- `disasm_ram.py` — live disassembler over TCP (works against either runtime)
+- `scan_imm.py` — find addiu/ori instructions with a specific 16-bit immediate
+
+## State comparison: our runtime vs DuckStation
+
+| address | what | our runtime | DuckStation |
+|---|---|---|---|
+| 0x80079D9C | VSync counter | 0 (never ticks) | 0x254E (~9550, incrementing) |
+| 0x80079D48 | shell init-done flag | **0x1** (SET) | 0x1 |
+| 0x800DFEE0 | shell VBlank chain entry | **0x8005A5BC,0,0,0x8005A70C** (handler installed!) | 0x8005A5BC,0,0,0x8005A70C |
+| 0x800DFF40 | user VSync callback | 0 | 0 |
+| 0x80000100 | kernel PCB/TCB/EvCB table | identical to Duck | identical |
+| 0xA000E004 | DCB chain head 1 | 0x80140014 | 0x80140014 |
+| 0xA000E00C | DCB chain head 2 | 0x00006D88 | 0x00006D88 |
+| **0xA000E014** | **DCB chain head 3 (root counters)** | **0x00000000 — MISSING** | **0x000074A8 — populated** |
+| 0xA000E01C | DCB chain head 4 | 0x00006D98 | 0x00006D98 |
+| 0x8000E028 | EvCB | **all zeros — empty** | populated with 4 `0xF0000003` RCnt events |
+
+**Our shell-level init (0xBFC42160) ran successfully** — 0x80079D48=1 and the 0x800DFEE0 table is populated. But the **kernel-level init that sets `*(0xA000E014)`** never ran.
+
+## The kernel IRQ chain walker (at 0x80000C80, verified via live disasm)
+
+The kernel exception handler walks four chain heads at 0xA000E004, 0xA000E00C, 0xA000E014, 0xA000E01C. For each non-null head, it iterates a linked list and calls each entry's `+0x08` function:
+
+```
+0x80000DE4: addi $s4,$s3,32 ; iterate 4×8-byte slots in DCB
+0x80000DE8: lw $s6,0($s3) ; load chain head
+0x80000DF0: beq $s6,$zr, skip_null
+0x80000DF8: lw $s1,8($s6) ; handler fn
+0x80000DFC: lw $s0,4($s6) ; verify fn
+0x80000E08: jalr $s1 ; call handler
+0x80000E10: beq $v0,$zr, next
+0x80000E18: beq $s0,$zr, next
+0x80000E20: jalr $s0 ; call verify
+0x80000E28: lw $s6,0($s6) ; next link
+0x80000E30: bne $s6,$zr, loop
+0x80000E38: addi $s3,$s3,8 ; next chain head
+0x80000E3C: bne $s4,$s3, outer_loop
+```
+
+With `*(0xA000E014)=0x000074A8` (Duck), the walker reaches a chain entry at `0x000074A8` containing `{next=0, verify=0x000049BC, handler=0x00004A4C}`. The handler is then responsible for walking the **shell-level** chain at 0x800DFEE0 and calling 0x8005A5BC.
+
+Our runtime has `*(0xA000E014)=0` so the walker skips this chain head entirely — VBlank handler is never reached.
+
+## The missing installer
+
+The function that populates `*(0xA000E014)=0x000074A8` is:
+
+- **`func_00004B90`** — ROM `0xBFC14690`, primary-copy alias RAM `0x80004B90`. Already seeded in dispatch table.
+- Calls `SysEnqIntRP(priority=2, chain_entry_ptr=0x800074A8)` via `jal 0xB0001420`.
+- 6 different kernel sites synthesize `0x000074A8`: 0xBFC146A8, 0xBFC146B8, 0xBFC1472C, 0xBFC14788, 0xBFC14798, 0xBFC14810 — all inside `InitRCnt`-family functions in kernel primary-copy region.
+- `func_00004B90` has one static caller: `0xBFC15C94` (inside `func_1FC15B9C` = **B0:0x15 / ChangeClearRCnt** — RAM alias `0x8000609C`, in our B0 table from DuckStation).
+- `func_1FC15B9C` has 0 static callers. One kernel B0:0x15 trampoline exists at `0xBFC0DAC0`, called statically from `0xBFC0C748` (inside `func_1FC0C720` — itself a 0-caller orphan).
+
+## Remaining unknown
+
+Who, in the kernel boot sequence, invokes B0:0x15 (`ChangeClearRCnt`) or directly calls `func_1FC15B9C` / `func_1FC0C720` with appropriate args during boot? In DuckStation this runs; in our runtime it does not. The call path is indirect (function-pointer, probably through an init-function-pointer table).
+
+## No dispatch misses in our runtime
+
+`psx_dispatch_misses.txt` and `psx_crash.txt` are absent (no dispatch misses, no traps). The runtime isn't failing — it's simply not executing the code path that populates the kernel IRQ chain. Some kernel boot function is returning early or not being reached at all.
+
+## Next session
+
+Options in priority order:
+
+1. **Patch DuckStation** to log PC-level trace over a narrow window (first ~10k instructions from reset). Diff against our runtime's function-dispatch log to find the first function DuckStation enters that our runtime doesn't.
+2. **Add a custom `pc_break` command to DuckStation's debug server** (`duckstation/src/core/psxrecomp_debug_server.cpp`) — execute-breakpoint at `0xBFC14690`. When it fires, dump call stack to see who called it. This is a ~30-line patch + rebuild.
+3. **Instrument our runtime** with a dispatch-trace command that logs the first call to every function. Compare against the call tree expected by DuckStation.
+
+Option 2 is cleanest: the existing `psxrecomp_debug_server.cpp` has `pause` / `continue` / `step N frames` — adding `pc_break` requires a per-instruction hook in the CPU core. More work than it sounds. Option 3 is easier and stays within our own codebase.
+
+## Caveat about auto-memory
+
+The handoff memory `phase4_vsync_diagnosis.md` now contains a false statement ("VSync counter 0x80079D9C never written; EvCB 0xF2000003 never opened; B0:0x15 orphaned"). The "never written" half is confirmed; the reason is different from what was guessed. Update on next session.
diff --git a/generated/relocation_proofs/vblank_registrar/proof.json b/generated/relocation_proofs/vblank_registrar/proof.json
new file mode 100644
index 00000000..37ac35d5
--- /dev/null
+++ b/generated/relocation_proofs/vblank_registrar/proof.json
@@ -0,0 +1,126 @@
+{
+ "phase": 4,
+ "session_date": "2026-04-13",
+ "task": "Locate the code path that registers VBlank chain handler 0xBFC425BC",
+ "classification": "PARTIALLY RESOLVED + UNKNOWN REMAINDER",
+
+ "resolved_findings": {
+ "handler_rom_pc": "0xBFC425BC",
+ "handler_ram_alias": "0x8005A5BC",
+ "handler_seeded": true,
+ "handler_dispatch_entry": "func_1FC425BC",
+
+ "registrar_rom_pc": "0xBFC42540",
+ "registrar_ram_alias": "0x8005A540",
+ "registrar_seeded": true,
+ "registrar_dispatch_entry": "func_1FC42540",
+
+ "proof_registrar_synthesizes_handler": {
+ "lui_site": "0xBFC42544 (lui $a1, 0x8006)",
+ "addiu_site": "0xBFC4254C (addiu $a1, $a1, 0xA5BC)",
+ "resulting_constant": "0x8005A5BC",
+ "uniqueness": "only instance in entire 512KB ROM of 'addiu *,*,0xA5BC' is at 0xBFC4254C"
+ },
+
+ "registrar_body_semantics": {
+ "0xBFC42554": "jal 0xB005A090 with $a0=0 (root counter 0), $a1=handler",
+ "note_0xB005A090": "RAM-side kernel routine (shell-copy region). normalize() in SCPH1001_dispatch.c maps phys 0x5A090 -> 0x1FC42090, which IS in dispatch table as func_1FC42090. So call is reachable by dispatcher.",
+ "fallthrough": "on mismatch: calls 0xB005A980 ($a0=0), 0xB005AC00 ($a0=3,$a1=0), clears *0x800DFF40, returns 0x8005A5FC"
+ }
+ },
+
+ "static_callers_audit": {
+ "method": "scanned every 4-byte-aligned J-format (jal/j) in ROM for target-match on both ROM (0xBFCxxxxx) and RAM (0x8005xxxx / 0xB005xxxx) aliases",
+ "results": {
+ "callers_of_0xBFC42540": 0,
+ "callers_of_0x8005A540": 0,
+ "callers_of_0xB005A540": 2,
+ "caller_sites_0xB005A540": ["0xBFC42340", "0xBFC42604"],
+ "callers_of_containing_functions": {
+ "0xBFC42160_enclosing_0xBFC42340": 0,
+ "0xBFC425FC_enclosing_0xBFC42604": 0,
+ "ram_aliases_0x8005A160_and_0x8005A5FC": 0
+ }
+ },
+ "synthesis_audit": {
+ "method": "searched for lui/addiu (or lui/ori) pairs within 16 instructions that reconstruct each candidate address",
+ "synthesizers_of_0x8005A160": 0,
+ "synthesizers_of_0x8005A5FC": "1 (only the registrar's own return-value code at 0xBFC425AC-0xBFC425B0)",
+ "synthesizers_of_0x80030000": 0,
+ "synthesizers_of_0x80059C80_VSync_entry": 0,
+ "synthesizers_of_0x80059FC0": 0,
+ "literal_4byte_occurrences_of_0x8005A160": 1,
+ "literal_0x8005A160_at": "0xBFC61D0C (in ROM data region)"
+ }
+ },
+
+ "irq_descriptor_table_candidate": {
+ "location_rom": "0xBFC61D00..0xBFC61D14",
+ "contents": {
+ "+0x00": "0x80079D18 (state/data ptr)",
+ "+0x04": "0x00000000",
+ "+0x08": "0x8005A380 (func_1FC42380 — in dispatch table)",
+ "+0x0C": "0x8005A160 (func_1FC42160 — in dispatch table, kernel init that calls registrar)",
+ "+0x10": "0x8005A424 (func_1FC42424 — in dispatch table)",
+ "+0x14": "0x00000000"
+ },
+ "nearby_strings": {
+ "0xBFC61CD0": "\"pad.c, v1.1 1995/02/15 04:57:45 shima Ex\"",
+ "0xBFC61D18": "\"intr.c, v1.1 1995/02/15 04:57:35 shima E\""
+ },
+ "interpretation": "callback descriptor for intr.c (root counter IRQ) subsystem. Three entries likely = {dispatch-on-IRQ, init, cleanup}. Walking this table is how the kernel hooks the VBlank chain.",
+ "cross_reference_audit": {
+ "synthesizers_of_0xBFC61D00": 0,
+ "synthesizers_of_0xBFC61D08": 0,
+ "synthesizers_of_0xBFC61CD0_or_0xBFC61D18": 0,
+ "lui_BFC6_sites_in_rom": 8,
+ "lui_BFC6_resolved_constants": ["0xBFC66000 (7 sites)", "0xBFC69D68 (remaining)"],
+ "conclusion": "No statically-resolvable code path references 0xBFC61D00..0xBFC61D20. Table is either accessed via a pointer-to-pointer indirection (gp-relative, register-loaded from an earlier data struct, or sum-of-two-registers), OR the walker function itself is inside a region we have not yet discovered/seeded.",
+
+ "additional_negative_results_2026_04_13_late": {
+ "addiu_or_ori_with_imm_0x1D00": 0,
+ "addiu_or_ori_with_imm_0x1D08": 0,
+ "addiu_or_ori_with_imm_0x1D0C": 0,
+ "carry_form_lui_0xBFC7_sites": 1,
+ "carry_form_lui_0xBFC7_resolves_to": "0xBFC7FF32 (not in target range)",
+ "final_verdict": "No 16-bit immediate in the entire 512KB ROM matches the low-half of any address in the candidate table region. Table is provably unreachable by any standard lui+addiu, lui+ori, or lui-carry pattern. Either the table is dead data, or it is reached via pointer-indirection loaded from elsewhere in memory at runtime (only discoverable via dynamic trace)."
+ }
+ }
+ },
+
+ "remaining_unknown": {
+ "question": "What function (or data-driven init path) walks the callback descriptor table at 0xBFC61D00 and installs 0xBFC42160 / 0xBFC42540 into the active IRQ chain?",
+ "why_static_analysis_stopped": [
+ "zero static callers of 0xBFC42160 or 0xBFC425FC",
+ "zero static synthesizers of 0x8005A160 / 0x8005A5FC",
+ "zero references (lui/ori, lui/addiu, or 4-byte literal) to the candidate table at 0xBFC61D00",
+ "the only 8 'lui *,0xBFC6' sites in ROM all resolve to 0xBFC66000 or 0xBFC69D68, never 0xBFC61xxx",
+ "per Rule 14 (Unknown is acceptable. Guessing is not.) — stopping static search here"
+ ],
+ "next_step_per_handoff_plan_step_1": {
+ "action": "use DuckStation as oracle",
+ "method_a": "execute-breakpoint at 0xBFC425BC; when it fires, dump call stack; outer frame identifies the caller",
+ "method_b": "memory-read watchpoint on 0x80079D9C (VSync counter); when it fires inside handler, step back to find incoming $ra, then find what enqueued the handler onto the chain"
+ }
+ },
+
+ "runtime_state": {
+ "observed": {
+ "shell_running": true,
+ "shell_entry_executed": "func_1FC18000 (via normalize() mapping RAM 0x80030000 -> ROM 0x1FC18000)",
+ "vsync_counter_0x80079D9C": "never written (confirmed by wtrace ring)",
+ "evcb_0x8000E028_to_0x8000E1F0": "zero-filled by InitEvent(16) at seq 42-57; no class-0xF2xxxxxx writes observed across 12s sample",
+ "vsync_spin_at": "ra=0xBFC41D18 inside func_1FC41DA4 (40-100% of samples)"
+ },
+ "implication": "registrar 0xBFC42540 is seeded and callable by the dispatcher, but nothing in the running code actually reaches the `jal 0xB005A540` instruction at either 0xBFC42340 or 0xBFC42604."
+ },
+
+ "tools_added_this_session": [
+ "tools/disasm_helper.py — minimal MIPS disassembler helper",
+ "tools/find_callers.py — find all ROM PCs that JAL/J to a given target",
+ "tools/find_const.py — find all lui/addiu pairs synthesizing a given 32-bit constant",
+ "tools/scan_literal.py — find 4-byte literal occurrences of an address in ROM",
+ "tools/scan_range.py — find pointers within a given address range anywhere in ROM",
+ "tools/scan_lui.py — list all 'lui *,X' instructions in ROM"
+ ]
+}
diff --git a/tools/add_ds_bios_paths.py b/tools/add_ds_bios_paths.py
new file mode 100644
index 00000000..a9f964a1
--- /dev/null
+++ b/tools/add_ds_bios_paths.py
@@ -0,0 +1,16 @@
+"""Ensure DuckStation settings.ini has BIOS paths wired up for BIOS shell boot."""
+import sys
+p = sys.argv[1]
+with open(p, 'r', encoding='utf-8') as f:
+ s = f.read()
+inject = (
+ "[BIOS]\n"
+ "PathNTSC-U = SCPH1001.BIN\n"
+ "PathNTSC-J = SCPH1001.BIN\n"
+ "PathPAL = SCPH1001.BIN\n"
+ "FastForwardBoot"
+)
+s = s.replace("[BIOS]\nFastForwardBoot", inject)
+with open(p, 'w', encoding='utf-8') as f:
+ f.write(s)
+print('OK')
diff --git a/tools/add_ds_setup_bypass.py b/tools/add_ds_setup_bypass.py
new file mode 100644
index 00000000..aada956d
--- /dev/null
+++ b/tools/add_ds_setup_bypass.py
@@ -0,0 +1,11 @@
+"""Mark DuckStation's setup wizard as completed so -nogui -bios can run headless."""
+import sys
+p = sys.argv[1]
+with open(p, 'r', encoding='utf-8') as f:
+ s = f.read()
+# Inject into [Main] section
+if "SettingsVersion" not in s:
+ s = s.replace("[Main]\n", "[Main]\nSettingsVersion = 3\nSetupWizardIncomplete = false\n", 1)
+with open(p, 'w', encoding='utf-8') as f:
+ f.write(s)
+print('OK')
diff --git a/tools/debug_client.py b/tools/debug_client.py
new file mode 100644
index 00000000..fd113ddc
--- /dev/null
+++ b/tools/debug_client.py
@@ -0,0 +1,461 @@
+#!/usr/bin/env python3
+"""
+debug_client.py — Unified TCP debug client for PSX recomp + DuckStation
+
+Usage:
+ python debug_client.py [options] [args...]
+ python debug_client.py # interactive REPL (native)
+ python debug_client.py --ds # interactive REPL (DuckStation)
+
+Options:
+ --port PORT Target port (default: 4370 for native)
+ --ds Shorthand for --port 4371 (DuckStation)
+ --host HOST Target host (default: 127.0.0.1)
+
+Commands (shared — identical on both servers):
+ ping Heartbeat + current frame
+ frame Current frame number
+ regs / get_registers Dump all MIPS registers
+ read [len] Read RAM bytes (hex addr, default 16 bytes)
+ dump [len] Chunked RAM read (up to 64KB)
+ write Write RAM bytes
+ scratch [len] Read scratchpad bytes
+ gpu GPU display state
+ overlay Overlay state
+ watch Set byte watchpoint
+ unwatch Remove watchpoint
+ pause Pause execution
+ continue / c Resume execution
+ step [n] Step N frames (default 1)
+ run_to Run to specific frame, then pause
+ history Ring buffer stats
+ get_frame Get full frame record from ring buffer
+ range Frame range query (max 200)
+ ts Frame timeseries — compact (max 200)
+ set_snapshot Configure RAM snapshot region (slot 0-3)
+ get_snapshots Show snapshot region config
+ input Override controller input
+ clear_input Remove input override
+ quit Quit game
+
+Comparison commands (query both servers):
+ compare [args...] Run command on both ports, diff results
+ ts_compare Compare timeseries across servers
+
+REPL commands:
+ target native Switch to port 4370
+ target ds Switch to port 4371
+ help Show this help
+"""
+
+import json
+import socket
+import sys
+import argparse
+
+DEFAULT_HOST = "127.0.0.1"
+NATIVE_PORT = 4370
+DS_PORT = 4371
+
+REG_NAMES = [
+ "zero", "at", "v0", "v1", "a0", "a1", "a2", "a3",
+ "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7",
+ "s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7",
+ "t8", "t9", "k0", "k1", "gp", "sp", "fp", "ra",
+]
+
+
+# ---------------------------------------------------------------------------
+# Low-level transport
+# ---------------------------------------------------------------------------
+
+def connect(host=DEFAULT_HOST, port=NATIVE_PORT, timeout=10.0):
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(timeout)
+ s.connect((host, port))
+ return s
+
+
+def send_cmd(sock, cmd_dict):
+ line = json.dumps(cmd_dict) + "\n"
+ sock.sendall(line.encode())
+ buf = b""
+ while b"\n" not in buf:
+ chunk = sock.recv(65536)
+ if not chunk:
+ break
+ buf += chunk
+ return json.loads(buf.decode().strip())
+
+
+def query(host, port, cmd_dict):
+ """One-shot: connect, send, receive, close."""
+ s = connect(host, port)
+ try:
+ return send_cmd(s, cmd_dict)
+ finally:
+ s.close()
+
+
+# ---------------------------------------------------------------------------
+# Formatters
+# ---------------------------------------------------------------------------
+
+def pretty_regs(resp):
+ if not resp.get("ok"):
+ return json.dumps(resp, indent=2)
+ lines = [f" PC: {resp.get('pc','?')} HI: {resp.get('hi','?')} LO: {resp.get('lo','?')}"]
+ regs = resp.get("regs", {})
+ for i in range(0, 32, 4):
+ parts = []
+ for j in range(4):
+ name = REG_NAMES[i + j]
+ val = regs.get(name, "?")
+ parts.append(f"{name:>4s}: {val}")
+ lines.append(" " + " ".join(parts))
+ return "\n".join(lines)
+
+
+def pretty_json(resp):
+ return json.dumps(resp, indent=2)
+
+
+# ---------------------------------------------------------------------------
+# Command builder — translates CLI args to JSON wire format
+# ---------------------------------------------------------------------------
+
+def build_cmd(args):
+ """Convert CLI args to (wire_cmd_dict, formatter) tuple."""
+ if not args:
+ return None, None
+ cmd = args[0].lower()
+
+ if cmd == "ping":
+ return {"cmd": "ping"}, pretty_json
+ elif cmd == "frame":
+ return {"cmd": "frame"}, pretty_json
+ elif cmd in ("regs", "get_registers"):
+ return {"cmd": "get_registers"}, pretty_regs
+ elif cmd == "read":
+ addr = args[1] if len(args) > 1 else "0x80010000"
+ length = int(args[2]) if len(args) > 2 else 16
+ return {"cmd": "read_ram", "addr": addr, "len": length}, pretty_json
+ elif cmd == "dump":
+ addr = args[1] if len(args) > 1 else "0x80010000"
+ length = int(args[2]) if len(args) > 2 else 256
+ return {"cmd": "dump_ram", "addr": addr, "len": length}, pretty_json
+ elif cmd == "write":
+ if len(args) < 3:
+ return None, lambda _: "Usage: write "
+ return {"cmd": "write_ram", "addr": args[1], "hex": args[2]}, pretty_json
+ elif cmd == "scratch":
+ addr = args[1] if len(args) > 1 else "0x1F800000"
+ length = int(args[2]) if len(args) > 2 else 16
+ return {"cmd": "read_scratch", "addr": addr, "len": length}, pretty_json
+ elif cmd == "gpu":
+ return {"cmd": "gpu_state"}, pretty_json
+ elif cmd == "overlay":
+ return {"cmd": "overlay_state"}, pretty_json
+ elif cmd == "watch":
+ if len(args) < 2:
+ return None, lambda _: "Usage: watch "
+ return {"cmd": "watch", "addr": args[1]}, pretty_json
+ elif cmd == "unwatch":
+ if len(args) < 2:
+ return None, lambda _: "Usage: unwatch "
+ return {"cmd": "unwatch", "addr": args[1]}, pretty_json
+ elif cmd == "pause":
+ return {"cmd": "pause"}, pretty_json
+ elif cmd in ("continue", "c"):
+ return {"cmd": "continue"}, pretty_json
+ elif cmd == "step":
+ n = int(args[1]) if len(args) > 1 else 1
+ return {"cmd": "step", "count": n}, pretty_json
+ elif cmd == "run_to":
+ if len(args) < 2:
+ return None, lambda _: "Usage: run_to "
+ return {"cmd": "run_to_frame", "frame": int(args[1])}, pretty_json
+ elif cmd == "history":
+ return {"cmd": "history"}, pretty_json
+ elif cmd == "get_frame":
+ if len(args) < 2:
+ return None, lambda _: "Usage: get_frame "
+ return {"cmd": "get_frame", "frame": int(args[1])}, pretty_json
+ elif cmd == "range":
+ if len(args) < 3:
+ return None, lambda _: "Usage: range "
+ return {"cmd": "frame_range", "start": int(args[1]), "end": int(args[2])}, pretty_json
+ elif cmd == "ts":
+ if len(args) < 3:
+ return None, lambda _: "Usage: ts "
+ return {"cmd": "frame_timeseries", "start": int(args[1]), "end": int(args[2])}, pretty_json
+ elif cmd == "set_snapshot":
+ if len(args) < 3:
+ return None, lambda _: "Usage: set_snapshot "
+ return {"cmd": "set_snapshot", "slot": int(args[1]), "addr": args[2]}, pretty_json
+ elif cmd == "get_snapshots":
+ return {"cmd": "get_snapshots"}, pretty_json
+ elif cmd == "input":
+ if len(args) < 2:
+ return None, lambda _: "Usage: input "
+ return {"cmd": "set_input", "buttons": args[1]}, pretty_json
+ elif cmd == "clear_input":
+ return {"cmd": "clear_input"}, pretty_json
+ elif cmd == "quit":
+ return {"cmd": "quit"}, pretty_json
+ elif cmd == "screenshot":
+ d = {"cmd": "screenshot"}
+ if len(args) > 1:
+ d["path"] = args[1]
+ return d, pretty_json
+ elif cmd == "bios_trace":
+ d = {"cmd": "bios_trace"}
+ if len(args) > 1:
+ d["limit"] = int(args[1])
+ return d, pretty_json
+ elif cmd == "sio_state":
+ return {"cmd": "sio_state"}, pretty_json
+ elif cmd == "bios_wait_state":
+ return {"cmd": "bios_wait_state"}, pretty_json
+ elif cmd == "memcard_log":
+ return {"cmd": "memcard_log"}, pretty_json
+ else:
+ # Pass through as raw command
+ return {"cmd": cmd}, pretty_json
+
+
+# ---------------------------------------------------------------------------
+# Comparison utilities
+# ---------------------------------------------------------------------------
+
+def diff_json(label_a, resp_a, label_b, resp_b):
+ """Compare two JSON responses and print differences."""
+ lines = []
+
+ # Strip id field for comparison
+ a = {k: v for k, v in resp_a.items() if k != "id"}
+ b = {k: v for k, v in resp_b.items() if k != "id"}
+
+ all_keys = sorted(set(list(a.keys()) + list(b.keys())))
+ diffs = 0
+ for key in all_keys:
+ va = a.get(key)
+ vb = b.get(key)
+ if va == vb:
+ lines.append(f" {key}: {va}")
+ else:
+ lines.append(f" {key}:")
+ lines.append(f" {label_a}: {va}")
+ lines.append(f" {label_b}: {vb}")
+ diffs += 1
+
+ header = f"=== Compare: {diffs} difference(s) ==="
+ return header + "\n" + "\n".join(lines)
+
+
+def run_compare(host, args):
+ """Run the same command on both native and DS, diff results."""
+ cmd_dict, fmt = build_cmd(args)
+ if cmd_dict is None:
+ return fmt(None) if fmt else "No command"
+
+ try:
+ resp_native = query(host, NATIVE_PORT, cmd_dict)
+ except (ConnectionRefusedError, TimeoutError, OSError) as e:
+ resp_native = {"error": f"Cannot connect to native (port {NATIVE_PORT}): {e}"}
+
+ try:
+ resp_ds = query(host, DS_PORT, cmd_dict)
+ except (ConnectionRefusedError, TimeoutError, OSError) as e:
+ resp_ds = {"error": f"Cannot connect to DuckStation (port {DS_PORT}): {e}"}
+
+ return diff_json(f"native:{NATIVE_PORT}", resp_native,
+ f"ds:{DS_PORT}", resp_ds)
+
+
+def run_ts_compare(host, start, end):
+ """Fetch timeseries from both servers, align by frame, highlight divergences."""
+ cmd = {"cmd": "frame_timeseries", "start": start, "end": end}
+ try:
+ native = query(host, NATIVE_PORT, cmd)
+ except (ConnectionRefusedError, TimeoutError, OSError):
+ return f"Cannot connect to native (port {NATIVE_PORT})"
+ try:
+ ds = query(host, DS_PORT, cmd)
+ except (ConnectionRefusedError, TimeoutError, OSError):
+ return f"Cannot connect to DuckStation (port {DS_PORT})"
+
+ ts_n = native.get("ts", [])
+ ts_d = ds.get("ts", [])
+
+ lines = [f"=== Timeseries Compare: frames {start}-{end} ==="]
+ lines.append(f"{'Frame':>8} {'Field':>8} {'Native':>12} {'DS':>12}")
+ lines.append("-" * 48)
+
+ max_len = max(len(ts_n), len(ts_d))
+ diffs = 0
+ for i in range(max_len):
+ fn = ts_n[i] if i < len(ts_n) else None
+ fd = ts_d[i] if i < len(ts_d) else None
+ if fn is None and fd is None:
+ continue
+ if fn is None:
+ frame = fd.get("f", start + i) if fd else start + i
+ lines.append(f"{frame:>8} {'*':>8} {'(missing)':>12} {'present':>12}")
+ diffs += 1
+ continue
+ if fd is None:
+ frame = fn.get("f", start + i) if fn else start + i
+ lines.append(f"{frame:>8} {'*':>8} {'present':>12} {'(missing)':>12}")
+ diffs += 1
+ continue
+
+ frame = fn.get("f", start + i)
+ # Compare shared fields
+ for key in ("pc", "sp", "ra", "pad", "da_x", "da_y"):
+ vn = fn.get(key)
+ vd = fd.get(key)
+ if vn != vd:
+ lines.append(f"{frame:>8} {key:>8} {str(vn):>12} {str(vd):>12}")
+ diffs += 1
+
+ lines.append(f"\nTotal differences: {diffs}")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# Command execution
+# ---------------------------------------------------------------------------
+
+def run_command(sock, args, host=DEFAULT_HOST):
+ """Execute a command on the given socket. Returns formatted string."""
+ if not args:
+ return None
+
+ cmd = args[0].lower()
+
+ # Meta-commands
+ if cmd == "compare":
+ return run_compare(host, args[1:])
+ if cmd == "ts_compare":
+ if len(args) < 3:
+ return "Usage: ts_compare "
+ return run_ts_compare(host, int(args[1]), int(args[2]))
+
+ cmd_dict, fmt = build_cmd(args)
+ if cmd_dict is None:
+ return fmt(None) if fmt else None
+
+ resp = send_cmd(sock, cmd_dict)
+ return fmt(resp)
+
+
+# ---------------------------------------------------------------------------
+# REPL
+# ---------------------------------------------------------------------------
+
+def repl(host, port):
+ try:
+ sock = connect(host, port)
+ except ConnectionRefusedError:
+ print(f"Cannot connect to {host}:{port} -- is the target running?")
+ sys.exit(1)
+
+ target = "native" if port == NATIVE_PORT else "ds" if port == DS_PORT else f":{port}"
+ print(f"Connected to {host}:{port} ({target})")
+ print("Type 'help' for commands, Ctrl-C to exit\n")
+
+ try:
+ while True:
+ try:
+ line = input(f"psx({target})> ").strip()
+ except EOFError:
+ break
+ if not line:
+ continue
+ if line.lower() == "help":
+ print(__doc__)
+ continue
+
+ args = line.split()
+
+ # Target switching
+ if args[0].lower() == "target":
+ if len(args) < 2:
+ print(f"Current target: {target} ({host}:{port})")
+ continue
+ new_target = args[1].lower()
+ new_port = NATIVE_PORT if new_target == "native" else DS_PORT if new_target == "ds" else int(new_target)
+ try:
+ sock.close()
+ except Exception:
+ pass
+ try:
+ sock = connect(host, new_port)
+ port = new_port
+ target = new_target
+ print(f"Switched to {host}:{port} ({target})")
+ except ConnectionRefusedError:
+ print(f"Cannot connect to {host}:{new_port}")
+ continue
+
+ try:
+ result = run_command(sock, args, host)
+ if result:
+ print(result)
+ except (ConnectionResetError, BrokenPipeError, socket.timeout):
+ print("Connection lost. Reconnecting...")
+ try:
+ sock = connect(host, port)
+ print("Reconnected.")
+ except ConnectionRefusedError:
+ print(f"Cannot reconnect to {host}:{port}")
+ break
+ except KeyboardInterrupt:
+ print()
+ finally:
+ sock.close()
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Unified TCP debug client for PSX recomp + DuckStation",
+ add_help=False,
+ )
+ parser.add_argument("--host", default=DEFAULT_HOST)
+ parser.add_argument("--port", type=int, default=None)
+ parser.add_argument("--ds", action="store_true", help="Target DuckStation (port 4371)")
+ parser.add_argument("args", nargs="*")
+
+ opts = parser.parse_args()
+
+ port = opts.port
+ if port is None:
+ port = DS_PORT if opts.ds else NATIVE_PORT
+
+ if not opts.args:
+ # REPL mode
+ repl(opts.host, port)
+ return
+
+ # Single command mode
+ try:
+ sock = connect(opts.host, port)
+ except ConnectionRefusedError:
+ print(f"Cannot connect to {opts.host}:{port} -- is the target running?")
+ sys.exit(1)
+
+ try:
+ result = run_command(sock, opts.args, opts.host)
+ if result:
+ print(result)
+ finally:
+ sock.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/disasm_helper.py b/tools/disasm_helper.py
new file mode 100644
index 00000000..0c68b1ca
--- /dev/null
+++ b/tools/disasm_helper.py
@@ -0,0 +1,62 @@
+"""Minimal MIPS disassembly helper for PSX BIOS hunts."""
+import struct, sys
+
+BASE = 0xBFC00000
+REGS = ['zr','at','v0','v1','a0','a1','a2','a3','t0','t1','t2','t3','t4','t5','t6','t7',
+ 's0','s1','s2','s3','s4','s5','s6','s7','t8','t9','k0','k1','gp','sp','fp','ra']
+
+def r(n): return REGS[n]
+
+def dis(pc, w):
+ op=(w>>26)&0x3f; rs=(w>>21)&0x1f; rt=(w>>16)&0x1f; rd=(w>>11)&0x1f; sh=(w>>6)&0x1f; fn=w&0x3f
+ imm=w&0xffff; simm=imm-0x10000 if imm&0x8000 else imm
+ tgt=(w&0x3ffffff)<<2
+ if w==0: return 'nop'
+ if op==0:
+ fns={0x00:'sll',0x02:'srl',0x03:'sra',0x04:'sllv',0x06:'srlv',0x07:'srav',
+ 0x08:'jr',0x09:'jalr',0x0c:'syscall',0x0d:'break',0x10:'mfhi',0x11:'mthi',
+ 0x12:'mflo',0x13:'mtlo',0x18:'mult',0x19:'multu',0x1a:'div',0x1b:'divu',
+ 0x20:'add',0x21:'addu',0x22:'sub',0x23:'subu',0x24:'and',0x25:'or',0x26:'xor',
+ 0x27:'nor',0x2a:'slt',0x2b:'sltu'}
+ n=fns.get(fn,f'spec{fn:02x}')
+ if fn==0x08: return f'jr ${r(rs)}'
+ if fn==0x09: return f'jalr ${r(rd)},${r(rs)}'
+ if fn in (0x00,0x02,0x03): return f'{n} ${r(rd)},${r(rt)},{sh}'
+ return f'{n} ${r(rd)},${r(rs)},${r(rt)}'
+ if op==1:
+ lnk='al' if (rt&0x10) else ''
+ n=('bltz' if (rt&1)==0 else 'bgez')+lnk
+ return f'{n} ${r(rs)},0x{pc+4+(simm<<2):08X}'
+ if op==2: return f'j 0x{(pc&0xf0000000)|tgt:08X}'
+ if op==3: return f'jal 0x{(pc&0xf0000000)|tgt:08X}'
+ ops={4:'beq',5:'bne',6:'blez',7:'bgtz',8:'addi',9:'addiu',10:'slti',11:'sltiu',
+ 12:'andi',13:'ori',14:'xori',15:'lui',32:'lb',33:'lh',34:'lwl',35:'lw',
+ 36:'lbu',37:'lhu',38:'lwr',40:'sb',41:'sh',42:'swl',43:'sw',46:'swr',
+ 0x10:'cop0',0x12:'cop2'}
+ n=ops.get(op,f'op{op:02x}')
+ if op==15: return f'lui ${r(rt)},0x{imm:04X}'
+ if op in (4,5): return f'{n} ${r(rs)},${r(rt)},0x{pc+4+(simm<<2):08X}'
+ if op in (6,7): return f'{n} ${r(rs)},0x{pc+4+(simm<<2):08X}'
+ if op in (8,9,10,11,12,13,14): return f'{n} ${r(rt)},${r(rs)},0x{imm:X}'
+ if op>=32: return f'{n} ${r(rt)},{simm}(${r(rs)})'
+ return f'raw=0x{w:08X}'
+
+def load_rom(path='bios/SCPH1001.BIN'):
+ with open(path,'rb') as f: return f.read()
+
+def dump(rom, pc_start, n):
+ for i in range(n):
+ pc = pc_start + i*4
+ off = pc - BASE
+ if off<0 or off+4>len(rom): break
+ w = struct.unpack('1 else 4370
+addr = int(sys.argv[2],16) if len(sys.argv)>2 else 0x00000C80
+length = int(sys.argv[3]) if len(sys.argv)>3 else 512
+
+def send(cmd, timeout=3.0):
+ s=socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.settimeout(timeout); s.connect((HOST,PORT))
+ s.sendall((json.dumps(cmd)+'\n').encode()); buf=b''
+ deadline=time.time()+timeout
+ while time.time()len(data): break
+ w = struct.unpack('>26)&0x3f
+ rs=(w>>21)&0x1f; rt=(w>>16)&0x1f; rd=(w>>11)&0x1f; sh=(w>>6)&0x1f
+ imm=w&0xffff; simm=imm-0x10000 if imm&0x8000 else imm
+ s = f'0x{pc:08X}: {w:08X} '
+ if w==0: s += 'nop'
+ elif op==0:
+ fn=w&0x3f
+ fnames={0x00:'sll',0x02:'srl',0x03:'sra',0x04:'sllv',0x06:'srlv',0x07:'srav',
+ 0x08:'jr',0x09:'jalr',0x0c:'syscall',0x0d:'break',0x10:'mfhi',0x12:'mflo',
+ 0x18:'mult',0x19:'multu',0x1a:'div',0x1b:'divu',0x20:'add',0x21:'addu',
+ 0x22:'sub',0x23:'subu',0x24:'and',0x25:'or',0x26:'xor',0x27:'nor',
+ 0x2a:'slt',0x2b:'sltu'}
+ n=fnames.get(fn, f'spec{fn:02x}')
+ if fn==8: s += f'jr {R(rs)}'
+ elif fn==9: s += f'jalr {R(rd)},{R(rs)}'
+ elif fn in (0x00,0x02,0x03): s += f'{n} {R(rd)},{R(rt)},{sh}'
+ elif fn==0x0c: s += f'syscall 0x{(w>>6)&0xfffff:X}'
+ else: s += f'{n} {R(rd)},{R(rs)},{R(rt)}'
+ elif op==1:
+ t=(rt&1); lnk='al' if (rt&0x10) else ''
+ n=('bltz' if t==0 else 'bgez')+lnk
+ s += f'{n} {R(rs)},0x{pc+4+(simm<<2):08X}'
+ elif op==2: s += f'j 0x{(pc&0xf0000000)|((w&0x3ffffff)<<2):08X}'
+ elif op==3: s += f'jal 0x{(pc&0xf0000000)|((w&0x3ffffff)<<2):08X}'
+ elif op==0x10:
+ sub=(w>>21)&0x1f
+ if sub==0: s += f'mfc0 {R(rt)},$cop0[{rd}]'
+ elif sub==4: s += f'mtc0 {R(rt)},$cop0[{rd}]'
+ elif sub==0x10 and (w&0x3f)==0x10: s += 'rfe'
+ else: s += f'cop0 sub={sub}'
+ elif op==0x0f: s += f'lui {R(rt)},0x{imm:04X}'
+ elif op==0x09: s += f'addiu {R(rt)},{R(rs)},{simm}'
+ elif op==0x08: s += f'addi {R(rt)},{R(rs)},{simm}'
+ elif op==0x0a: s += f'slti {R(rt)},{R(rs)},{simm}'
+ elif op==0x0b: s += f'sltiu {R(rt)},{R(rs)},{simm}'
+ elif op==0x0c: s += f'andi {R(rt)},{R(rs)},0x{imm:X}'
+ elif op==0x0d: s += f'ori {R(rt)},{R(rs)},0x{imm:X}'
+ elif op==4: s += f'beq {R(rs)},{R(rt)},0x{pc+4+(simm<<2):08X}'
+ elif op==5: s += f'bne {R(rs)},{R(rt)},0x{pc+4+(simm<<2):08X}'
+ elif op==6: s += f'blez {R(rs)},0x{pc+4+(simm<<2):08X}'
+ elif op==7: s += f'bgtz {R(rs)},0x{pc+4+(simm<<2):08X}'
+ elif op==0x20: s += f'lb {R(rt)},{simm}({R(rs)})'
+ elif op==0x21: s += f'lh {R(rt)},{simm}({R(rs)})'
+ elif op==0x23: s += f'lw {R(rt)},{simm}({R(rs)})'
+ elif op==0x24: s += f'lbu {R(rt)},{simm}({R(rs)})'
+ elif op==0x25: s += f'lhu {R(rt)},{simm}({R(rs)})'
+ elif op==0x28: s += f'sb {R(rt)},{simm}({R(rs)})'
+ elif op==0x29: s += f'sh {R(rt)},{simm}({R(rs)})'
+ elif op==0x2b: s += f'sw {R(rt)},{simm}({R(rs)})'
+ else: s += f'op{op:02x}'
+ print(s)
diff --git a/tools/duck_query.py b/tools/duck_query.py
new file mode 100644
index 00000000..46198e82
--- /dev/null
+++ b/tools/duck_query.py
@@ -0,0 +1,87 @@
+"""Quick focused queries to DuckStation TCP debug server (port 4371)."""
+import socket, json, sys, time
+
+HOST='127.0.0.1'; PORT=4371
+
+def send(cmd, timeout=3.0):
+ s=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(timeout)
+ s.connect((HOST,PORT))
+ s.sendall((json.dumps(cmd)+'\n').encode())
+ buf=b''
+ deadline = time.time()+timeout
+ while time.time() len(d): break
+ w = int.from_bytes(d[i:i+4],'little')
+ annot = TARGETS.get(w, '')
+ if not annot and w != 0 and (w & 0xFFFFFFF0) in {t & 0xFFFFFFF0 for t in TARGETS}:
+ for t,n in TARGETS.items():
+ if (w & 0xFFFFFFF0) == (t & 0xFFFFFFF0):
+ annot = f'{n}+{w-t:+d}'
+ break
+ print(f' +0x{i:03X}: 0x{w:08X}' + (f' <-- {annot}' if annot else ''))
+
+send({"cmd":"pause"})
+time.sleep(0.3)
+
+# A0 table = {ptr=0x00008648, size=0x2C0 = 704 bytes = 176 entries}
+table_dump('A0 TABLE', 0x00008648, 0x2C0)
+# B0 table = {ptr=0x00006EE0, size=0x320 = 800 bytes = 200 entries}
+table_dump('B0 TABLE', 0x00006EE0, 0x320)
+
+send({"cmd":"continue"})
diff --git a/tools/duck_query5.py b/tools/duck_query5.py
new file mode 100644
index 00000000..3ccc2d6d
--- /dev/null
+++ b/tools/duck_query5.py
@@ -0,0 +1,65 @@
+"""Dump CORRECT A0/B0/C0 tables from DuckStation."""
+import socket, json, time
+HOST='127.0.0.1'; PORT=4371
+
+def send(cmd, timeout=3.0):
+ s=socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.settimeout(timeout); s.connect((HOST,PORT))
+ s.sendall((json.dumps(cmd)+'\n').encode()); buf=b''
+ deadline=time.time()+timeout
+ while time.time()len(d): break
+ w=int.from_bytes(d[i:i+4],'little')
+ if w==0: continue
+ annot = TARGETS.get(w, '')
+ # Try fuzzy match for shell kernel functions
+ if not annot and 0x8005A000 <= w < 0x8005B000:
+ # RAM alias of 0xBFC42xxx
+ rom = w - 0x8005A000 + 0xBFC42000
+ annot = f'shell_kern 0x{rom:08X}'
+ elif not annot and 0x80059000 <= w < 0x8005A000:
+ rom = w - 0x80059000 + 0xBFC41000
+ annot = f'shell_kern 0x{rom:08X}'
+ elif not annot and 0xBFC00000 <= w < 0xBFC80000:
+ annot = f'ROM_direct'
+ nonzero.append((i, w, annot))
+ print(f' {len(nonzero)} non-zero entries:')
+ for i,w,annot in nonzero:
+ idx = i // 4
+ print(f' [{idx:3d}] +0x{i:03X}: 0x{w:08X}' + (f' {annot}' if annot else ''))
+
+send({"cmd":"pause"}); time.sleep(0.3)
+dump('A0 TABLE', 0x00000200, 0x100) # A0:0..63 entries (probably enough)
+dump('B0 TABLE', 0x00000874, 0x100)
+dump('C0 TABLE', 0x00000674, 0x100)
+send({"cmd":"continue"})
diff --git a/tools/duck_search.py b/tools/duck_search.py
new file mode 100644
index 00000000..11dcfdd5
--- /dev/null
+++ b/tools/duck_search.py
@@ -0,0 +1,61 @@
+"""Search DuckStation's RAM for marker addresses via TCP debug server."""
+import socket, json, sys
+
+HOST='127.0.0.1'; PORT=4371
+
+def send(cmd):
+ s=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(5)
+ s.connect((HOST,PORT))
+ s.sendall((json.dumps(cmd)+'\n').encode())
+ buf=b''
+ while True:
+ try: chunk=s.recv(65536)
+ except socket.timeout: break
+ if not chunk: break
+ buf+=chunk
+ if b'\n' in buf: break
+ s.close()
+ line = buf.split(b'\n',1)[0].decode()
+ return json.loads(line)
+
+def search(start, length, markers, label_per_marker):
+ """Read [start, start+length) bytes; find each marker (hex string LE) in the data."""
+ chunk = 8192
+ found = {m:[] for m in markers}
+ off = 0
+ while off < length:
+ n = min(chunk, length-off)
+ r = send({"cmd":"read_ram", "addr": f"0x{start+off:08X}", "len": n})
+ if not r.get('ok'): print('ERR', r); return found
+ hex_str = r['hex']
+ for m in markers:
+ pos=0
+ while True:
+ i = hex_str.find(m, pos)
+ if i<0: break
+ byte_off = i//2
+ ram_addr = start + off + byte_off
+ found[m].append(ram_addr)
+ pos = i+1
+ off += n
+ for m in markers:
+ label = label_per_marker.get(m, m)
+ for a in found[m]:
+ print(f'{label}: RAM phys=0x{a:08X} (KSEG0=0x{0x80000000|a:08X})')
+ if not found[m]:
+ print(f'{label}: NOT FOUND in [0x{start:08X}..0x{start+length:08X})')
+ return found
+
+if __name__=='__main__':
+ # Markers are little-endian hex of target addresses
+ markers = {
+ 'bca50580': '0x8005A5BC handler (vblank chain)',
+ '40a50580': '0x8005A540 registrar',
+ '60a10580': '0x8005A160 init function',
+ '24a40580': '0x8005A424 cleanup',
+ '80a30580': '0x8005A380 dispatch',
+ 'fca50580': '0x8005A5FC SetVSync',
+ }
+ # Search all 2MB of RAM
+ search(0x00000000, 0x00200000, list(markers.keys()), markers)
diff --git a/tools/duckstation/README.md b/tools/duckstation/README.md
new file mode 100644
index 00000000..386a7749
--- /dev/null
+++ b/tools/duckstation/README.md
@@ -0,0 +1,77 @@
+# DuckStation oracle (dynamic comparison harness)
+
+PSXRecomp v4 uses a patched build of [stenzek/duckstation](https://github.com/stenzek/duckstation) as a live oracle for the recompiled BIOS. Our runtime speaks JSON-over-TCP on port **4370**; the patched DuckStation speaks the same protocol on **4371**, so tools like `debug_client.py compare` can diff live state between the two at any moment.
+
+## Layout
+
+- **`duckstation/`** (git submodule) — pristine upstream DuckStation source tree, pinned to commit `ffb33c281d196eb8ee0f559085ca285de7cdd51b` (release-20260328 era). Never edited directly.
+- **`tools/duckstation/psxrecomp_oracle.patch`** — our changes as a unified diff against the pinned upstream base. Touches 7 files (~1200 lines). Adds `src/core/psxrecomp_debug_server.{cpp,h}`, wires `PSXRecompDebug::Initialize(4371)` into `System::Initialize`, exposes three GPU debug accessors, registers a log channel.
+- **`tools/duckstation/setup.sh`** — idempotent. Initializes submodule, fetches + verifies + extracts prebuilt Windows deps (SDL3, Qt6, ffmpeg, …), normalizes the absolute paths baked into the prebuilt CMake metadata, applies the oracle patch.
+- **`tools/duckstation/build.sh`** — runs CMake (Visual Studio 17 2022, x64, Release) and MSBuild on `duckstation-qt`. Requires the Visual Studio 2022 "Desktop development with C++" workload (CMake + MSBuild come with it).
+- **`tools/fix_duckstation_deps_paths.py`** — helper used by `setup.sh` to rewrite stale `_IMPORT_PREFIX` values in the extracted prebuilt deps.
+
+## First-time setup
+
+```bash
+cd /f/Projects/psxrecomp-v4
+bash tools/duckstation/setup.sh # ~5 min: clone submodule + fetch/extract deps + apply patch
+bash tools/duckstation/build.sh # ~15-30 min: compile duckstation-qt Release x64
+```
+
+The resulting binary is at `duckstation/build/bin/duckstation-qt.exe`. Launch for headless oracle use:
+
+```bash
+./duckstation/build/bin/duckstation-qt.exe -bios -nogui -fastboot &
+# Wait ~5s for BIOS boot, then:
+echo '{"cmd":"ping"}' | ncat -w2 localhost 4371
+```
+
+## Regenerating the patch
+
+If our oracle changes need to be updated, edit files in `duckstation/` directly, then regenerate the patch against the pinned upstream base:
+
+```bash
+cd duckstation
+git diff ffb33c281d196eb8ee0f559085ca285de7cdd51b > ../tools/duckstation/psxrecomp_oracle.patch
+```
+
+The pinned base SHA lives in one place only: `UPSTREAM_BASE` at the top of `tools/duckstation/setup.sh`. Update it there if we ever rebase onto a newer upstream commit.
+
+## Why this layout (vs a hosted fork)
+
+- Keeps the upstream source untracked in v4's git history — no 2.1GB of upstream code in our blame.
+- Keeps *our* 1200-line patch reviewable as a single text diff in `tools/duckstation/` — in-tree, versioned, diffable across sessions.
+- Matches the nestopia setup in sibling project `F:\Projects\nesrecomp\runner\nestopia_cmake.cmake` + `runner/nestopia_oracle.patch`. Same mental model: submodule upstream, patch on top, auto-apply at setup time.
+- No private/public GitHub fork to maintain or keep in sync.
+
+## Protocol parity with native runtime
+
+Both servers implement the same JSON-over-newline command set where possible so that `tools/debug_client.py compare ` diffs state between them. See `TCP_COMMANDS.md` at the v4 root for the full command table with "native-only / duckstation-only / both" annotations.
+
+## Known issue: headless BIOS boot after fresh settings.ini (2026-04-14)
+
+A fresh install produces a `settings.ini` that, even after `build.sh` post-processes
+it with BIOS paths + setup-wizard bypass + portable marker + BIOS copy, still
+doesn't trigger BIOS emulation in `-bios -nogui -fastboot` mode. The process runs
+(stable at ~34 MB), but port 4371 never binds because `PSXRecompDebug::Initialize`
+is only called after `System::BootSystem` starts emulation, and emulation isn't
+starting. Working theory: a Qt/QApplication init path under `-nogui` blocks on
+something that only a GUI launch resolves. Fix on the TODO list; meanwhile the
+one-time workaround is:
+
+```bash
+# Launch once with GUI — this fully initialises settings and state
+./duckstation/build/bin/duckstation-qt.exe -bios # not -nogui
+# Close the window after the BIOS shell appears, then subsequent -nogui launches work.
+```
+
+The prior working-binary session (before the rm -rf incident) had a settings.ini
+that had been "warmed up" this way — it survived because the rm -rf deleted the
+build tree, not the settings. Future fix: capture the post-warmup `settings.ini`
+into a template under `tools/duckstation/` so `build.sh` can install it verbatim.
+
+## Troubleshooting
+
+- **"oracle patch does not apply cleanly"** — either upstream commit has been moved past the pinned base (check `git -C duckstation log --oneline -1`), or the patch file was regenerated against a different base. Fix by either resetting the submodule to `$UPSTREAM_BASE` or updating the pin.
+- **"sha256 mismatch" on deps archive** — the prebuilt deps version in `duckstation/dep/PREBUILT-VERSION` has been bumped. Either update `PREBUILT_SHA256` in `setup.sh` to the new hash from `duckstation/dep/PREBUILT-SHA256SUMS`, or pin the submodule to an older commit that expects the cached archive's version.
+- **"UNSUPPORTED CONFIGURATION" warning from DuckStation CMake** — cosmetic. The upstream build system prefers `msbuild` driven via a VS solution (which is what `build.sh` does). The warning fires any time CMake runs on Windows; safe to ignore.
diff --git a/tools/duckstation/build.sh b/tools/duckstation/build.sh
new file mode 100644
index 00000000..c01f15dd
--- /dev/null
+++ b/tools/duckstation/build.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Build duckstation-qt.exe (Release x64). Requires tools/duckstation/setup.sh to
+# have succeeded first (submodule initialized, deps extracted, patch applied).
+#
+# Uses the CMake that ships with Visual Studio 2022 (the system mingw64 cmake
+# does not know the VS generator) and the MSBuild from the same toolchain.
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+DUCK="$REPO_ROOT/duckstation"
+CMAKE="/c/Program Files/Microsoft Visual Studio/2022/Community/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe"
+MSBUILD="/c/Program Files/Microsoft Visual Studio/2022/Community/MSBuild/Current/Bin/MSBuild.exe"
+
+if [ ! -x "$CMAKE" ]; then
+ echo "ERROR: VS-bundled CMake not found at $CMAKE"
+ echo " install Visual Studio 2022 Community with 'Desktop development with C++' workload"
+ exit 1
+fi
+if [ ! -x "$MSBUILD" ]; then
+ echo "ERROR: MSBuild not found at $MSBUILD"
+ exit 1
+fi
+
+cd "$DUCK"
+if [ ! -f build/duckstation.sln ]; then
+ echo "[duckstation-build] configuring CMake (Visual Studio 17 2022, x64, Release)..."
+ "$CMAKE" -S . -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release
+fi
+
+echo "[duckstation-build] compiling duckstation-qt (Release|x64)..."
+"$MSBUILD" build/duckstation.sln \
+ -p:Configuration=Release \
+ -p:Platform=x64 \
+ -t:duckstation-qt \
+ -m \
+ -verbosity:minimal \
+ -nologo
+
+# MSBuild lands the exe in build/bin/Release/ but the DLLs live at build/bin/.
+# Copy the exe up so it can find its Qt / SDL3 / etc. runtime.
+if [ -f "$DUCK/build/bin/Release/duckstation-qt.exe" ]; then
+ cp -f "$DUCK/build/bin/Release/duckstation-qt.exe" "$DUCK/build/bin/duckstation-qt.exe"
+fi
+
+# Headless-launch provisioning: portable marker, BIOS image, minimal settings.ini
+# (idempotent — only writes if missing).
+touch "$DUCK/build/bin/portable.txt"
+
+if [ ! -f "$DUCK/build/bin/bios/SCPH1001.BIN" ] && [ -f "$REPO_ROOT/bios/SCPH1001.BIN" ]; then
+ mkdir -p "$DUCK/build/bin/bios"
+ cp "$REPO_ROOT/bios/SCPH1001.BIN" "$DUCK/build/bin/bios/"
+ echo "[duckstation-build] copied SCPH1001.BIN into $DUCK/build/bin/bios/"
+fi
+
+# settings.ini with BIOS config + setup-wizard bypass. First launch creates a
+# default; we post-process it to set the headless-friendly fields. Idempotent.
+SETTINGS="$DUCK/build/bin/settings.ini"
+if [ ! -f "$SETTINGS" ]; then
+ # Let DS generate a default by running briefly (it writes settings.ini on first
+ # launch and exits if no autoboot target is present). We then patch it.
+ timeout 3 "$DUCK/build/bin/duckstation-qt.exe" -nogui 2>/dev/null || true
+fi
+if [ -f "$SETTINGS" ]; then
+ python3 "$REPO_ROOT/tools/add_ds_bios_paths.py" "$SETTINGS" 2>/dev/null || true
+ python3 "$REPO_ROOT/tools/add_ds_setup_bypass.py" "$SETTINGS" 2>/dev/null || true
+fi
+
+echo "[duckstation-build] done. binary at: $DUCK/build/bin/duckstation-qt.exe"
diff --git a/tools/duckstation/psxrecomp_oracle.patch b/tools/duckstation/psxrecomp_oracle.patch
new file mode 100644
index 00000000..cd0e155d
--- /dev/null
+++ b/tools/duckstation/psxrecomp_oracle.patch
@@ -0,0 +1,1187 @@
+diff --git a/src/common/log_channels.h b/src/common/log_channels.h
+index a1576e7..6dfdb45 100644
+--- a/src/common/log_channels.h
++++ b/src/common/log_channels.h
+@@ -47,6 +47,7 @@
+ X(PerfMon) \
+ X(PlatformMisc) \
+ X(PostProcessing) \
++ X(PSXRecompDebug) \
+ X(PIO) \
+ X(ReShadeFXShader) \
+ X(Recompiler) \
+diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
+index e2d0294..b290bd3 100644
+--- a/src/core/CMakeLists.txt
++++ b/src/core/CMakeLists.txt
+@@ -116,6 +116,8 @@ add_library(core
+ playstation_mouse.h
+ psf_loader.cpp
+ psf_loader.h
++ psxrecomp_debug_server.cpp
++ psxrecomp_debug_server.h
+ save_state_version.h
+ settings.cpp
+ settings.h
+diff --git a/src/core/gpu.h b/src/core/gpu.h
+index 975f8d5..05fa360 100644
+--- a/src/core/gpu.h
++++ b/src/core/gpu.h
+@@ -212,6 +212,19 @@ public:
+ ALWAYS_INLINE u16 GetCRTCActiveStartLine() const { return m_crtc_state.vertical_display_start; }
+ ALWAYS_INLINE u16 GetCRTCActiveEndLine() const { return m_crtc_state.vertical_display_end; }
+
++ // PSXRecomp debug accessors
++ ALWAYS_INLINE u16 GetDisplayVRAMLeft() const { return m_crtc_state.display_vram_left; }
++ ALWAYS_INLINE u16 GetDisplayVRAMTop() const { return m_crtc_state.display_vram_top; }
++ ALWAYS_INLINE u16 GetDisplayVRAMWidth() const { return m_crtc_state.display_vram_width; }
++ ALWAYS_INLINE u16 GetDisplayVRAMHeight() const { return m_crtc_state.display_vram_height; }
++ ALWAYS_INLINE s32 GetDrawingOffsetX() const { return m_drawing_offset.x; }
++ ALWAYS_INLINE s32 GetDrawingOffsetY() const { return m_drawing_offset.y; }
++ ALWAYS_INLINE u32 GetGPUSTATBits() const { return m_GPUSTAT.bits; }
++ ALWAYS_INLINE u32 GetDrawingAreaLeft() const { return m_drawing_area.left; }
++ ALWAYS_INLINE u32 GetDrawingAreaTop() const { return m_drawing_area.top; }
++ ALWAYS_INLINE u32 GetDrawingAreaRight() const { return m_drawing_area.right; }
++ ALWAYS_INLINE u32 GetDrawingAreaBottom() const { return m_drawing_area.bottom; }
++
+ // Returns the video clock frequency.
+ TickCount GetCRTCFrequency() const;
+
+diff --git a/src/core/psxrecomp_debug_server.cpp b/src/core/psxrecomp_debug_server.cpp
+new file mode 100644
+index 0000000..1fcc225
+--- /dev/null
++++ b/src/core/psxrecomp_debug_server.cpp
+@@ -0,0 +1,1053 @@
++// PSXRecomp TCP debug server for DuckStation
++// JSON-over-newline protocol on localhost:4371 (same as runner debug server).
++// Includes 36000-frame ring buffer with CPU, GPU, pad, and RAM snapshot state.
++
++#include "psxrecomp_debug_server.h"
++#include "bus.h"
++#include "controller.h"
++#include "cpu_core.h"
++#include "cpu_types.h"
++#include "digital_controller.h"
++#include "dma.h"
++#include "gpu.h"
++#include "interrupt_controller.h"
++#include "pad.h"
++#include "system.h"
++
++#include "common/assert.h"
++#include "common/log.h"
++#include "common/small_string.h"
++
++#include "util/image.h"
++#include "util/sockets.h"
++
++#include
++#include
++#include
++#include
++#include
++#include
++#include
++
++#include "fmt/format.h"
++
++LOG_CHANNEL(PSXRecompDebug);
++
++// ---------------------------------------------------------------------------
++// Minimal JSON helpers
++// ---------------------------------------------------------------------------
++
++static bool json_get_str(const char* json, const char* key, char* out, size_t out_sz)
++{
++ char pattern[128];
++ std::snprintf(pattern, sizeof(pattern), "\"%s\"", key);
++ const char* p = std::strstr(json, pattern);
++ if (!p) return false;
++ p += std::strlen(pattern);
++ while (*p == ' ' || *p == ':') p++;
++ if (*p != '"') return false;
++ p++;
++ size_t i = 0;
++ while (*p && *p != '"' && i + 1 < out_sz) out[i++] = *p++;
++ out[i] = 0;
++ return true;
++}
++
++static bool json_get_u32(const char* json, const char* key, u32* out)
++{
++ char pattern[128];
++ std::snprintf(pattern, sizeof(pattern), "\"%s\"", key);
++ const char* p = std::strstr(json, pattern);
++ if (!p) return false;
++ p += std::strlen(pattern);
++ while (*p == ' ' || *p == ':') p++;
++ if (*p == '"') p++;
++ *out = static_cast(std::strtoul(p, nullptr, 0));
++ return true;
++}
++
++static s32 json_get_int(const char* json, const char* key, s32 def)
++{
++ u32 v;
++ if (!json_get_u32(json, key, &v)) return def;
++ return static_cast(v);
++}
++
++// ---------------------------------------------------------------------------
++// Frame record (ring buffer element)
++// ---------------------------------------------------------------------------
++
++static constexpr u32 FRAME_HISTORY_CAP = 36000; // ~10 min @ 60fps
++
++// Snapshot of a RAM region stored per frame
++static constexpr int RAM_SNAPSHOT_REGIONS = 4;
++static constexpr int RAM_SNAPSHOT_SIZE = 128; // bytes per region
++
++struct FrameRecord
++{
++ u32 frame_number;
++
++ // CPU
++ u32 gpr[32];
++ u32 pc, hi, lo;
++
++ // GPU
++ u16 display_vram_left, display_vram_top;
++ u16 display_vram_width, display_vram_height;
++ u8 display_disable;
++ u8 interlaced;
++ u8 pal_mode;
++ u8 _gpu_pad;
++
++ // Pad
++ u16 pad1_buttons; // inverted PS1 style: 0=pressed
++
++ u16 _reserved;
++
++ // RAM snapshots — configurable regions captured every frame
++ u32 snapshot_addr[RAM_SNAPSHOT_REGIONS];
++ u8 snapshot_data[RAM_SNAPSHOT_REGIONS][RAM_SNAPSHOT_SIZE];
++};
++
++// ---------------------------------------------------------------------------
++// State
++// ---------------------------------------------------------------------------
++
++namespace PSXRecompDebug {
++
++namespace {
++
++class ClientSocket final : public BufferedStreamSocket
++{
++public:
++ ClientSocket(SocketMultiplexer& multiplexer, SocketDescriptor descriptor);
++ ~ClientSocket() override;
++
++ void SendJSON(const std::string& json);
++
++protected:
++ void OnConnected() override;
++ void OnDisconnected(const Error& error) override;
++ void OnRead() override;
++
++private:
++ void ProcessLine(std::string_view line);
++};
++
++} // namespace
++
++static std::shared_ptr s_listen_socket;
++static std::vector> s_clients;
++
++// Frame counter & control
++static u32 s_frame_counter = 0;
++static int s_step_frames = 0;
++static bool s_paused = false;
++static int s_request_id = 0;
++static u32 s_run_to_frame = 0; // target frame for run_to_frame (0=disabled)
++
++// Ring buffer
++static FrameRecord s_history[FRAME_HISTORY_CAP];
++static u64 s_history_count = 0;
++
++// Watchpoints
++static constexpr int MAX_WATCHPOINTS = 8;
++static struct { u32 addr; u8 prev_val; bool active; } s_watchpoints[MAX_WATCHPOINTS] = {};
++
++// PC breakpoints — execute breakpoint with state capture on hit.
++// When the callback fires we capture all GPRs + PC + $ra into a pending-hit
++// record, pause the system, and broadcast an event. The pending record stays
++// until queried via "pc_hit_last" (or cleared via "pc_hit_clear").
++static constexpr int MAX_PC_BREAKS = 16;
++static struct { u32 addr; bool active; } s_pc_breaks[MAX_PC_BREAKS] = {};
++static struct {
++ bool valid;
++ u32 addr_hit;
++ u32 pc;
++ u32 ra;
++ u32 hi, lo;
++ u32 gpr[32];
++ u32 cop0_sr, cop0_cause, cop0_epc;
++} s_pc_hit_last = {};
++
++// Input override (-1 = no override, otherwise PS1 inverted bitmask)
++static s32 s_input_override = -1;
++
++// RAM snapshot regions (user-configurable via "set_snapshot" command)
++static u32 s_snapshot_addrs[RAM_SNAPSHOT_REGIONS] = {
++ 0x00097C40, // DMA context (default for memcard investigation)
++ 0x00097840, // staging buffer
++ 0, 0
++};
++
++// Helper: broadcast to all clients
++static void BroadcastJSON(const std::string& msg)
++{
++ for (auto& c : s_clients)
++ c->SendJSON(msg);
++}
++
++} // namespace PSXRecompDebug
++
++// ---------------------------------------------------------------------------
++// Client socket
++// ---------------------------------------------------------------------------
++
++PSXRecompDebug::ClientSocket::ClientSocket(SocketMultiplexer& multiplexer, SocketDescriptor descriptor)
++ : BufferedStreamSocket(multiplexer, descriptor, 8192, 65536)
++{
++}
++
++PSXRecompDebug::ClientSocket::~ClientSocket() = default;
++
++void PSXRecompDebug::ClientSocket::OnConnected()
++{
++ INFO_LOG("PSXRecomp debug client connected from {}", GetRemoteAddress().ToString());
++ s_clients.push_back(std::static_pointer_cast(shared_from_this()));
++}
++
++void PSXRecompDebug::ClientSocket::OnDisconnected(const Error& error)
++{
++ INFO_LOG("PSXRecomp debug client disconnected: {}", error.GetDescription());
++ auto it = std::find_if(s_clients.begin(), s_clients.end(),
++ [this](const auto& c) { return c.get() == this; });
++ if (it != s_clients.end()) s_clients.erase(it);
++}
++
++void PSXRecompDebug::ClientSocket::OnRead()
++{
++ const std::span buffer = AcquireReadBuffer();
++ if (buffer.empty()) return;
++
++ size_t consumed = 0;
++ for (size_t i = 0; i < buffer.size(); i++)
++ {
++ if (buffer[i] == '\n')
++ {
++ std::string_view line(reinterpret_cast(buffer.data() + consumed), i - consumed);
++ if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
++ if (!line.empty()) ProcessLine(line);
++ consumed = i + 1;
++ }
++ }
++ ReleaseReadBuffer(consumed);
++}
++
++void PSXRecompDebug::ClientSocket::SendJSON(const std::string& json)
++{
++ std::string msg = json + "\n";
++ Write(msg.data(), msg.size());
++}
++
++// ---------------------------------------------------------------------------
++// Register names
++// ---------------------------------------------------------------------------
++
++static const char* const s_reg_names[32] = {
++ "zero","at","v0","v1","a0","a1","a2","a3",
++ "t0","t1","t2","t3","t4","t5","t6","t7",
++ "s0","s1","s2","s3","s4","s5","s6","s7",
++ "t8","t9","k0","k1","gp","sp","fp","ra",
++};
++
++// ---------------------------------------------------------------------------
++// Hex encoding helper
++// ---------------------------------------------------------------------------
++
++static std::string hex_encode(const u8* data, size_t len)
++{
++ std::string hex;
++ hex.reserve(len * 2);
++ for (size_t i = 0; i < len; i++)
++ fmt::format_to(std::back_inserter(hex), "{:02x}", data[i]);
++ return hex;
++}
++
++// ---------------------------------------------------------------------------
++// Command handlers
++// ---------------------------------------------------------------------------
++
++void PSXRecompDebug::ClientSocket::ProcessLine(std::string_view line)
++{
++ std::string line_str(line);
++ const char* json = line_str.c_str();
++
++ char cmd[64] = {};
++ json_get_str(json, "cmd", cmd, sizeof(cmd));
++ u32 id = 0;
++ json_get_u32(json, "id", &id);
++ s_request_id = static_cast(id);
++
++ // ---- ping / frame ----
++ if (std::strcmp(cmd, "ping") == 0 || std::strcmp(cmd, "frame") == 0)
++ {
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"frame":{}}})", s_request_id, s_frame_counter));
++ }
++ // ---- registers ----
++ else if (std::strcmp(cmd, "get_registers") == 0 || std::strcmp(cmd, "regs") == 0)
++ {
++ std::string resp = fmt::format(
++ R"({{"id":{},"ok":true,"pc":"0x{:08X}","hi":"0x{:08X}","lo":"0x{:08X}","regs":{{)",
++ s_request_id, CPU::g_state.pc, CPU::g_state.regs.hi, CPU::g_state.regs.lo);
++ for (int i = 0; i < 32; i++)
++ {
++ if (i > 0) resp += ",";
++ resp += fmt::format(R"("{}":"0x{:08X}")", s_reg_names[i], CPU::g_state.regs.r[i]);
++ }
++ resp += "}}";
++ SendJSON(resp);
++ }
++ // ---- read_ram ----
++ else if (std::strcmp(cmd, "read_ram") == 0)
++ {
++ u32 addr = 0, len = 1;
++ json_get_u32(json, "addr", &addr);
++ json_get_u32(json, "len", &len);
++ if (len > 65536) len = 65536;
++
++ u32 phys = addr & 0x1FFFFFFFu;
++ std::string hex;
++ hex.reserve(len * 2);
++ for (u32 i = 0; i < len; i++)
++ {
++ u8 byte = (phys + i < Bus::g_ram_size) ? Bus::g_ram[phys + i] : 0;
++ fmt::format_to(std::back_inserter(hex), "{:02x}", byte);
++ }
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"addr":"0x{:08X}","len":{},"hex":"{}"}})",
++ s_request_id, addr, len, hex));
++ }
++ // ---- write_ram ----
++ else if (std::strcmp(cmd, "write_ram") == 0)
++ {
++ u32 addr = 0;
++ json_get_u32(json, "addr", &addr);
++ char hex_data[131072] = {};
++ json_get_str(json, "hex", hex_data, sizeof(hex_data));
++
++ u32 phys = addr & 0x1FFFFFFFu;
++ size_t hex_len = std::strlen(hex_data);
++ u32 byte_count = 0;
++ for (size_t i = 0; i + 1 < hex_len; i += 2)
++ {
++ char bs[3] = {hex_data[i], hex_data[i + 1], 0};
++ u8 byte = static_cast(std::strtoul(bs, nullptr, 16));
++ if (phys + byte_count < Bus::g_ram_size)
++ Bus::g_ram[phys + byte_count] = byte;
++ byte_count++;
++ }
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"bytes_written":{}}})", s_request_id, byte_count));
++ }
++ // ---- gpu_state ----
++ else if (std::strcmp(cmd, "gpu_state") == 0)
++ {
++ // Map display width to h_resolution index (0=256,1=320,2=512,3=640)
++ u16 w = g_gpu.GetDisplayVRAMWidth();
++ u8 h_res = (w >= 640) ? 3 : (w >= 512) ? 2 : (w >= 320) ? 1 : 0;
++ // Map display height to v_resolution index (0=240,1=480i)
++ u16 h = g_gpu.GetDisplayVRAMHeight();
++ u8 v_res = (h > 240) ? 1 : 0;
++ // display_enable: 0=on, 1=off (same as native convention)
++ u8 disp_en = g_gpu.IsDisplayDisabled() ? 1 : 0;
++
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"display_area_x":{},"display_area_y":{},"draw_offset_x":{},"draw_offset_y":{},"h_resolution":{},"v_resolution":{},"display_enable":{},"gpustat":"0x{:08X}","disp_w":{},"disp_h":{},"clip":[{},{},{},{}]}})",
++ s_request_id,
++ g_gpu.GetDisplayVRAMLeft(), g_gpu.GetDisplayVRAMTop(),
++ g_gpu.GetDrawingOffsetX(), g_gpu.GetDrawingOffsetY(),
++ h_res, v_res, disp_en,
++ g_gpu.GetGPUSTATBits(),
++ g_gpu.GetDisplayVRAMWidth(), g_gpu.GetDisplayVRAMHeight(),
++ g_gpu.GetDrawingAreaLeft(), g_gpu.GetDrawingAreaTop(),
++ g_gpu.GetDrawingAreaRight(), g_gpu.GetDrawingAreaBottom()));
++ }
++ // ---- pause ----
++ else if (std::strcmp(cmd, "pause") == 0)
++ {
++ s_paused = true;
++ System::PauseSystem(true);
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"paused":true,"frame":{}}})", s_request_id, s_frame_counter));
++ }
++ // ---- continue ----
++ else if (std::strcmp(cmd, "continue") == 0 || std::strcmp(cmd, "c") == 0)
++ {
++ s_paused = false;
++ System::PauseSystem(false);
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"paused":false,"frame":{}}})", s_request_id, s_frame_counter));
++ }
++ // ---- step ----
++ else if (std::strcmp(cmd, "step") == 0)
++ {
++ u32 n = 1;
++ json_get_u32(json, "count", &n);
++ if (n < 1) n = 1;
++ s_step_frames = static_cast(n);
++ s_paused = false;
++ System::PauseSystem(false);
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"stepping":{},"frame":{}}})", s_request_id, n, s_frame_counter));
++ }
++ // ---- watch ----
++ else if (std::strcmp(cmd, "watch") == 0)
++ {
++ u32 addr = 0;
++ json_get_u32(json, "addr", &addr);
++ bool added = false;
++ for (int i = 0; i < MAX_WATCHPOINTS; i++)
++ {
++ if (!s_watchpoints[i].active)
++ {
++ s_watchpoints[i].addr = addr & 0x1FFFFFFFu;
++ s_watchpoints[i].prev_val = (s_watchpoints[i].addr < Bus::g_ram_size) ?
++ Bus::g_ram[s_watchpoints[i].addr] : 0;
++ s_watchpoints[i].active = true;
++ added = true;
++ break;
++ }
++ }
++ SendJSON(fmt::format(R"({{"id":{},"ok":{},"addr":"0x{:08X}"}})",
++ s_request_id, added ? "true" : "false", addr));
++ }
++ // ---- unwatch ----
++ else if (std::strcmp(cmd, "unwatch") == 0)
++ {
++ u32 addr = 0;
++ json_get_u32(json, "addr", &addr);
++ u32 phys = addr & 0x1FFFFFFFu;
++ for (int i = 0; i < MAX_WATCHPOINTS; i++)
++ if (s_watchpoints[i].active && s_watchpoints[i].addr == phys)
++ s_watchpoints[i].active = false;
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"addr":"0x{:08X}"}})", s_request_id, addr));
++ }
++ // ---- pc_break: set execute breakpoint at given PC (full state capture on hit) ----
++ else if (std::strcmp(cmd, "pc_break") == 0)
++ {
++ u32 addr = 0;
++ json_get_u32(json, "addr", &addr);
++ bool added = false;
++ int slot = -1;
++ for (int i = 0; i < MAX_PC_BREAKS; i++)
++ {
++ if (!s_pc_breaks[i].active) { slot = i; break; }
++ }
++ if (slot >= 0)
++ {
++ bool ok = CPU::AddBreakpointWithCallback(
++ CPU::BreakpointType::Execute, addr,
++ [](CPU::BreakpointType, VirtualMemoryAddress pc_hit, VirtualMemoryAddress) -> bool {
++ // Capture full state at hit.
++ s_pc_hit_last.valid = true;
++ s_pc_hit_last.addr_hit = pc_hit;
++ s_pc_hit_last.pc = CPU::g_state.pc;
++ s_pc_hit_last.ra = CPU::g_state.regs.r[31];
++ s_pc_hit_last.hi = CPU::g_state.regs.hi;
++ s_pc_hit_last.lo = CPU::g_state.regs.lo;
++ for (int i = 0; i < 32; i++) s_pc_hit_last.gpr[i] = CPU::g_state.regs.r[i];
++ s_pc_hit_last.cop0_sr = CPU::g_state.cop0_regs.sr.bits;
++ s_pc_hit_last.cop0_cause = CPU::g_state.cop0_regs.cause.bits;
++ s_pc_hit_last.cop0_epc = CPU::g_state.cop0_regs.EPC;
++ // Pause the system so the client can inspect before anything else runs.
++ s_paused = true;
++ System::PauseSystem(true);
++ // Broadcast event (best-effort; client may reconnect and query pc_hit_last).
++ BroadcastJSON(fmt::format(
++ R"({{"event":"pc_hit","addr":"0x{:08X}","pc":"0x{:08X}","ra":"0x{:08X}","frame":{}}})",
++ pc_hit, CPU::g_state.pc, CPU::g_state.regs.r[31], s_frame_counter));
++ return true; // keep breakpoint
++ });
++ if (ok)
++ {
++ s_pc_breaks[slot].addr = addr;
++ s_pc_breaks[slot].active = true;
++ added = true;
++ }
++ }
++ SendJSON(fmt::format(R"({{"id":{},"ok":{},"addr":"0x{:08X}","slot":{}}})",
++ s_request_id, added ? "true" : "false", addr, slot));
++ }
++ // ---- pc_unbreak: remove execute breakpoint at given PC ----
++ else if (std::strcmp(cmd, "pc_unbreak") == 0)
++ {
++ u32 addr = 0;
++ json_get_u32(json, "addr", &addr);
++ bool removed = CPU::RemoveBreakpoint(CPU::BreakpointType::Execute, addr);
++ for (int i = 0; i < MAX_PC_BREAKS; i++)
++ if (s_pc_breaks[i].active && s_pc_breaks[i].addr == addr)
++ s_pc_breaks[i].active = false;
++ SendJSON(fmt::format(R"({{"id":{},"ok":{},"addr":"0x{:08X}"}})",
++ s_request_id, removed ? "true" : "false", addr));
++ }
++ // ---- pc_break_list ----
++ else if (std::strcmp(cmd, "pc_break_list") == 0)
++ {
++ std::string resp = fmt::format(R"({{"id":{},"ok":true,"breaks":[)", s_request_id);
++ bool first = true;
++ for (int i = 0; i < MAX_PC_BREAKS; i++)
++ {
++ if (!s_pc_breaks[i].active) continue;
++ if (!first) resp += ",";
++ resp += fmt::format(R"("0x{:08X}")", s_pc_breaks[i].addr);
++ first = false;
++ }
++ resp += "]}";
++ SendJSON(resp);
++ }
++ // ---- pc_hit_last: return captured state from most recent PC break hit ----
++ else if (std::strcmp(cmd, "pc_hit_last") == 0)
++ {
++ if (!s_pc_hit_last.valid)
++ {
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"valid":false}})", s_request_id));
++ }
++ else
++ {
++ std::string resp = fmt::format(
++ R"({{"id":{},"ok":true,"valid":true,"addr_hit":"0x{:08X}","pc":"0x{:08X}","ra":"0x{:08X}",)"
++ R"("hi":"0x{:08X}","lo":"0x{:08X}","cop0_sr":"0x{:08X}","cop0_cause":"0x{:08X}","cop0_epc":"0x{:08X}","regs":{{)",
++ s_request_id, s_pc_hit_last.addr_hit, s_pc_hit_last.pc, s_pc_hit_last.ra,
++ s_pc_hit_last.hi, s_pc_hit_last.lo,
++ s_pc_hit_last.cop0_sr, s_pc_hit_last.cop0_cause, s_pc_hit_last.cop0_epc);
++ for (int i = 0; i < 32; i++)
++ {
++ if (i > 0) resp += ",";
++ resp += fmt::format(R"("{}":"0x{:08X}")", s_reg_names[i], s_pc_hit_last.gpr[i]);
++ }
++ resp += "}}";
++ SendJSON(resp);
++ }
++ }
++ // ---- pc_hit_clear ----
++ else if (std::strcmp(cmd, "pc_hit_clear") == 0)
++ {
++ s_pc_hit_last.valid = false;
++ SendJSON(fmt::format(R"({{"id":{},"ok":true}})", s_request_id));
++ }
++ // ---- history (ring buffer stats) ----
++ else if (std::strcmp(cmd, "history") == 0)
++ {
++ u64 oldest = (s_history_count > FRAME_HISTORY_CAP) ? s_history_count - FRAME_HISTORY_CAP : 0;
++ u64 newest = s_history_count > 0 ? s_history_count - 1 : 0;
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"count":{},"oldest":{},"newest":{}}})",
++ s_request_id, s_history_count, oldest, newest));
++ }
++ // ---- get_frame (single frame from ring buffer) ----
++ else if (std::strcmp(cmd, "get_frame") == 0)
++ {
++ s32 f = json_get_int(json, "frame", -1);
++ if (f < 0) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"missing frame"}})", s_request_id)); return; }
++
++ u64 oldest = (s_history_count > FRAME_HISTORY_CAP) ? s_history_count - FRAME_HISTORY_CAP : 0;
++ if (static_cast(f) < oldest || static_cast(f) >= s_history_count)
++ {
++ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"frame not in buffer"}})", s_request_id));
++ return;
++ }
++
++ u32 idx = static_cast(f) % FRAME_HISTORY_CAP;
++ const FrameRecord& r = s_history[idx];
++
++ // Encode snapshot regions as hex
++ std::string snaps = "[";
++ for (int si = 0; si < RAM_SNAPSHOT_REGIONS; si++)
++ {
++ if (si > 0) snaps += ",";
++ if (r.snapshot_addr[si] == 0)
++ {
++ snaps += "null";
++ }
++ else
++ {
++ snaps += fmt::format(R"({{"addr":"0x{:08X}","hex":"{}"}})",
++ r.snapshot_addr[si] | 0x80000000u,
++ hex_encode(r.snapshot_data[si], RAM_SNAPSHOT_SIZE));
++ }
++ }
++ snaps += "]";
++
++ std::string resp = fmt::format(
++ R"({{"id":{},"ok":true,"frame":{},"pc":"0x{:08X}","hi":"0x{:08X}","lo":"0x{:08X}",)"
++ R"("sp":"0x{:08X}","ra":"0x{:08X}","v0":"0x{:08X}","a0":"0x{:08X}",)"
++ R"("gpu":{{"da_x":{},"da_y":{},"do_x":0,"do_y":0}},)"
++ R"("pad":"0x{:04X}",)"
++ R"("overlay_base":"0x00000000",)"
++ R"("dispatch_misses":0,)"
++ R"("game_data":"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",)"
++ R"("last_func":"",)"
++ R"("snapshots":{}}})",
++ s_request_id, r.frame_number, r.pc, r.hi, r.lo,
++ r.gpr[29], r.gpr[31], r.gpr[2], r.gpr[4],
++ r.display_vram_left, r.display_vram_top,
++ r.pad1_buttons,
++ snaps);
++ SendJSON(resp);
++ }
++ // ---- frame_range (batch query, max 200) ----
++ else if (std::strcmp(cmd, "frame_range") == 0)
++ {
++ s32 start = json_get_int(json, "start", -1);
++ s32 end = json_get_int(json, "end", -1);
++ if (start < 0 || end < 0) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"missing start/end"}})", s_request_id)); return; }
++ if (end - start + 1 > 200) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"max 200 frames"}})", s_request_id)); return; }
++
++ u64 oldest = (s_history_count > FRAME_HISTORY_CAP) ? s_history_count - FRAME_HISTORY_CAP : 0;
++
++ std::string resp = fmt::format(R"({{"id":{},"ok":true,"frames":[)", s_request_id);
++ bool first = true;
++ for (s32 f = start; f <= end; f++)
++ {
++ if (!first) resp += ",";
++ first = false;
++
++ if (static_cast(f) < oldest || static_cast(f) >= s_history_count)
++ {
++ resp += fmt::format(R"({{"frame":{},"available":false}})", f);
++ continue;
++ }
++
++ u32 idx = static_cast(f) % FRAME_HISTORY_CAP;
++ const FrameRecord& r = s_history[idx];
++
++ // Include first snapshot region hex for compact view
++ std::string snap0_hex = (r.snapshot_addr[0] != 0) ?
++ hex_encode(r.snapshot_data[0], RAM_SNAPSHOT_SIZE) : "";
++
++ resp += fmt::format(
++ R"({{"frame":{},"pc":"0x{:08X}","pad":"0x{:04X}","game_data":"0000000000000000000000000000000000000000000000000000000000000000","snap0":"{}"}})",
++ r.frame_number, r.pc, r.pad1_buttons, snap0_hex);
++ }
++ resp += "]}";
++ SendJSON(resp);
++ }
++ // ---- frame_timeseries (compact batch, max 200) ----
++ else if (std::strcmp(cmd, "frame_timeseries") == 0)
++ {
++ s32 start = json_get_int(json, "start", -1);
++ s32 end = json_get_int(json, "end", -1);
++ if (start < 0 || end < 0) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"missing start/end"}})", s_request_id)); return; }
++ if (end - start + 1 > 200) { SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"max 200 frames"}})", s_request_id)); return; }
++
++ u64 oldest = (s_history_count > FRAME_HISTORY_CAP) ? s_history_count - FRAME_HISTORY_CAP : 0;
++
++ std::string resp = fmt::format(R"({{"id":{},"ok":true,"ts":[)", s_request_id);
++ bool first = true;
++ for (s32 f = start; f <= end; f++)
++ {
++ if (!first) resp += ",";
++ first = false;
++
++ if (static_cast(f) < oldest || static_cast(f) >= s_history_count)
++ {
++ resp += "null";
++ continue;
++ }
++
++ u32 idx = static_cast(f) % FRAME_HISTORY_CAP;
++ const FrameRecord& r = s_history[idx];
++
++ resp += fmt::format(
++ R"({{"f":{},"pc":"0x{:08X}","sp":"0x{:08X}","ra":"0x{:08X}","pad":{},"da_x":{},"da_y":{},"misses":0,"gd":"0000000000000000000000000000000000000000000000000000000000000000"}})",
++ r.frame_number, r.pc, r.gpr[29], r.gpr[31],
++ r.pad1_buttons, r.display_vram_left, r.display_vram_top);
++ }
++ resp += "]}";
++ SendJSON(resp);
++ }
++ // ---- set_snapshot (configure RAM snapshot regions) ----
++ else if (std::strcmp(cmd, "set_snapshot") == 0)
++ {
++ // set_snapshot: {"cmd":"set_snapshot","slot":0,"addr":"0x80097C40"}
++ u32 slot = 0, addr = 0;
++ json_get_u32(json, "slot", &slot);
++ json_get_u32(json, "addr", &addr);
++ if (slot < RAM_SNAPSHOT_REGIONS)
++ {
++ s_snapshot_addrs[slot] = addr & 0x1FFFFFFFu;
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"slot":{},"addr":"0x{:08X}"}})",
++ s_request_id, slot, addr));
++ }
++ else
++ {
++ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"slot must be 0-{}"}})",
++ s_request_id, RAM_SNAPSHOT_REGIONS - 1));
++ }
++ }
++ // ---- get_snapshots (show current snapshot config) ----
++ else if (std::strcmp(cmd, "get_snapshots") == 0)
++ {
++ std::string resp = fmt::format(R"({{"id":{},"ok":true,"regions":[)", s_request_id);
++ for (int i = 0; i < RAM_SNAPSHOT_REGIONS; i++)
++ {
++ if (i > 0) resp += ",";
++ if (s_snapshot_addrs[i] == 0)
++ resp += "null";
++ else
++ resp += fmt::format(R"({{"slot":{},"addr":"0x{:08X}","size":{}}})",
++ i, s_snapshot_addrs[i] | 0x80000000u, RAM_SNAPSHOT_SIZE);
++ }
++ resp += "]}";
++ SendJSON(resp);
++ }
++ // ---- read_scratch (scratchpad memory) ----
++ else if (std::strcmp(cmd, "read_scratch") == 0)
++ {
++ u32 addr = 0, len = 1;
++ json_get_u32(json, "addr", &addr);
++ json_get_u32(json, "len", &len);
++ if (len > 1024) len = 1024;
++
++ std::string hex;
++ hex.reserve(len * 2);
++ for (u32 i = 0; i < len; i++)
++ {
++ u32 off = (addr + i) & 0x3FFu;
++ fmt::format_to(std::back_inserter(hex), "{:02x}", CPU::g_state.scratchpad[off]);
++ }
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"addr":"0x{:08X}","len":{},"hex":"{}"}})",
++ s_request_id, addr, len, hex));
++ }
++ // ---- overlay_state (not applicable for DS, returns zeros) ----
++ else if (std::strcmp(cmd, "overlay_state") == 0)
++ {
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"overlay_base":"0x00000000","overlay_pc":"0x00000000"}})",
++ s_request_id));
++ }
++ // ---- run_to_frame ----
++ else if (std::strcmp(cmd, "run_to_frame") == 0)
++ {
++ u32 target = 0;
++ json_get_u32(json, "frame", &target);
++ if (target <= s_frame_counter)
++ {
++ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"target frame already passed"}})", s_request_id));
++ }
++ else
++ {
++ s_run_to_frame = target;
++ s_paused = false;
++ System::PauseSystem(false);
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"running_to":{}}})", s_request_id, target));
++ }
++ }
++ // ---- dump_ram (chunked large reads, up to 64KB) ----
++ else if (std::strcmp(cmd, "dump_ram") == 0)
++ {
++ u32 addr = 0, len = 256;
++ json_get_u32(json, "addr", &addr);
++ json_get_u32(json, "len", &len);
++ if (len > 65536) len = 65536;
++
++ u32 phys = addr & 0x1FFFFFFFu;
++ u32 offset = 0;
++ while (offset < len)
++ {
++ u32 chunk = std::min(len - offset, 4096u);
++ std::string hex;
++ hex.reserve(chunk * 2);
++ for (u32 i = 0; i < chunk; i++)
++ {
++ u32 p = phys + offset + i;
++ u8 byte = (p < Bus::g_ram_size) ? Bus::g_ram[p] : 0;
++ fmt::format_to(std::back_inserter(hex), "{:02x}", byte);
++ }
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"addr":"0x{:08X}","offset":{},"len":{},"hex":"{}"}})",
++ s_request_id, addr + offset, offset, chunk, hex));
++ offset += chunk;
++ }
++ }
++ // ---- read_vram (read VRAM pixels, max 128x128) ----
++ else if (std::strcmp(cmd, "read_vram") == 0)
++ {
++ u32 x = 0, y = 0, w = 1, h = 1;
++ json_get_u32(json, "x", &x);
++ json_get_u32(json, "y", &y);
++ json_get_u32(json, "w", &w);
++ json_get_u32(json, "h", &h);
++ if (w > 128) w = 128;
++ if (h > 128) h = 128;
++ if (w == 0) w = 1;
++ if (h == 0) h = 1;
++
++ std::string hex;
++ hex.reserve(w * h * 4);
++ for (u32 row = 0; row < h; row++)
++ {
++ u32 vy = (y + row) & (VRAM_HEIGHT - 1);
++ for (u32 col = 0; col < w; col++)
++ {
++ u32 vx = (x + col) & (VRAM_WIDTH - 1);
++ u16 pixel = g_vram[vy * VRAM_WIDTH + vx];
++ fmt::format_to(std::back_inserter(hex), "{:04x}", pixel);
++ }
++ }
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"x":{},"y":{},"w":{},"h":{},"pixels":"{}"}})",
++ s_request_id, x, y, w, h, hex));
++ }
++ // ---- set_input ----
++ else if (std::strcmp(cmd, "set_input") == 0)
++ {
++ u32 buttons = 0xFFFF;
++ json_get_u32(json, "buttons", &buttons);
++ s_input_override = static_cast(buttons & 0xFFFF);
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"buttons":"0x{:04X}"}})", s_request_id, s_input_override));
++ }
++ // ---- clear_input ----
++ else if (std::strcmp(cmd, "clear_input") == 0)
++ {
++ s_input_override = -1;
++ SendJSON(fmt::format(R"({{"id":{},"ok":true}})", s_request_id));
++ }
++ // ---- dma_state ----
++ else if (std::strcmp(cmd, "dma_state") == 0)
++ {
++ static const char* const dma_names[7] = {"MDECin","MDECout","GPU","CDROM","SPU","PIO","OTC"};
++ u32 dpcr = DMA::ReadRegister(0x70);
++ u32 dicr = DMA::ReadRegister(0x74);
++ std::string channels = "[";
++ for (u32 ch = 0; ch < 7; ch++)
++ {
++ if (ch > 0) channels += ",";
++ u32 base = ch * 16;
++ u32 madr = DMA::ReadRegister(base + 0);
++ u32 bcr = DMA::ReadRegister(base + 4);
++ u32 chcr = DMA::ReadRegister(base + 8);
++ u32 enabled = (dpcr >> (ch * 4 + 3)) & 1;
++ channels += fmt::format(
++ R"({{"ch":{},"name":"{}","madr":"0x{:08X}","bcr":"0x{:08X}","chcr":"0x{:08X}","enabled":{}}})",
++ ch, dma_names[ch], madr, bcr, chcr, enabled);
++ }
++ channels += "]";
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"dpcr":"0x{:08X}","dicr":"0x{:08X}","channels":{}}})",
++ s_request_id, dpcr, dicr, channels));
++ }
++ // ---- irq_state ----
++ else if (std::strcmp(cmd, "irq_state") == 0)
++ {
++ u32 i_stat = InterruptController::ReadRegister(0x00);
++ u32 i_mask = InterruptController::ReadRegister(0x04);
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"i_stat":"0x{:08X}","i_mask":"0x{:08X}","chains":[]}})",
++ s_request_id, i_stat, i_mask));
++ }
++ // ---- event_state (BIOS concept — stub for DS) ----
++ else if (std::strcmp(cmd, "event_state") == 0)
++ {
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"evcb_base":0,"evcb_count":0,"events":[]}})",
++ s_request_id));
++ }
++ // ---- sio_state (read SIO/Pad registers) ----
++ else if (std::strcmp(cmd, "sio_state") == 0)
++ {
++ // Read SIO registers via Pad namespace (offsets from SIO base 0x1F801040)
++ u32 rx_data = Pad::ReadRegister(0x00); // SIO_RX_DATA
++ u32 stat = Pad::ReadRegister(0x04); // SIO_STAT
++ u32 mode = Pad::ReadRegister(0x08); // SIO_MODE
++ u32 ctrl = Pad::ReadRegister(0x0A); // SIO_CTRL
++ u32 baud = Pad::ReadRegister(0x0E); // SIO_BAUD
++ SendJSON(fmt::format(
++ R"({{"id":{},"ok":true,"regs":{{"rx_data":"0x{:02X}","stat":"0x{:04X}","mode":"0x{:04X}","ctrl":"0x{:04X}","baud":"0x{:04X}"}},"note":"DS exposes raw registers only, not protocol state"}})",
++ s_request_id,
++ rx_data & 0xFF, stat & 0xFFFF, mode & 0xFFFF, ctrl & 0xFFFF, baud & 0xFFFF));
++ }
++ // ---- screenshot ----
++ else if (std::strcmp(cmd, "screenshot") == 0)
++ {
++ char path[256] = {};
++ json_get_str(json, "path", path, sizeof(path));
++ if (path[0] == '\0')
++ {
++ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"missing path"}})", s_request_id));
++ }
++ else
++ {
++ u16 dx = g_gpu.GetDisplayVRAMLeft();
++ u16 dy = g_gpu.GetDisplayVRAMTop();
++ u16 dw = g_gpu.GetDisplayVRAMWidth();
++ u16 dh = g_gpu.GetDisplayVRAMHeight();
++ if (dw == 0) dw = 320;
++ if (dh == 0) dh = 240;
++
++ Image img(dw, dh, ImageFormat::RGBA8);
++ for (u16 row = 0; row < dh; row++)
++ {
++ u32* dst = reinterpret_cast(img.GetRowPixels(row));
++ for (u16 col = 0; col < dw; col++)
++ {
++ u16 vx = (dx + col) & (VRAM_WIDTH - 1);
++ u16 vy = (dy + row) & (VRAM_HEIGHT - 1);
++ u16 pixel = g_vram[vy * VRAM_WIDTH + vx];
++ u8 r = static_cast((pixel & 0x1F) << 3);
++ u8 g = static_cast(((pixel >> 5) & 0x1F) << 3);
++ u8 b = static_cast(((pixel >> 10) & 0x1F) << 3);
++ dst[col] = static_cast(r) | (static_cast(g) << 8) | (static_cast(b) << 16) | 0xFF000000u;
++ }
++ }
++
++ Error error;
++ if (img.SaveToFile(path, 85, &error))
++ SendJSON(fmt::format(R"({{"id":{},"ok":true,"path":"{}","w":{},"h":{}}})", s_request_id, path, dw, dh));
++ else
++ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"save failed: {}"}})", s_request_id, error.GetDescription()));
++ }
++ }
++ // ---- unknown ----
++ else
++ {
++ SendJSON(fmt::format(R"({{"id":{},"ok":false,"error":"unknown command: {}"}})", s_request_id, cmd));
++ }
++}
++
++// ---------------------------------------------------------------------------
++// Public API
++// ---------------------------------------------------------------------------
++
++bool PSXRecompDebug::Initialize(u16 port)
++{
++ Error error;
++ Assert(!s_listen_socket);
++
++ const std::optional address =
++ SocketAddress::Parse(SocketAddress::Type::IPv4, "127.0.0.1", port, &error);
++ if (!address.has_value())
++ {
++ ERROR_LOG("Failed to parse address: {}", error.GetDescription());
++ return false;
++ }
++
++ SocketMultiplexer* multiplexer = System::GetSocketMultiplexer();
++ if (!multiplexer) return false;
++
++ s_listen_socket = multiplexer->CreateListenSocket(address.value(), &error);
++ if (!s_listen_socket)
++ {
++ ERROR_LOG("Failed to create listen socket: {}", error.GetDescription());
++ System::ReleaseSocketMultiplexer();
++ return false;
++ }
++
++ s_frame_counter = 0;
++ s_paused = false;
++ s_step_frames = 0;
++ s_run_to_frame = 0;
++ s_history_count = 0;
++ std::memset(s_watchpoints, 0, sizeof(s_watchpoints));
++ std::memset(s_pc_breaks, 0, sizeof(s_pc_breaks));
++ s_pc_hit_last.valid = false;
++ std::memset(s_history, 0, sizeof(s_history));
++
++ INFO_LOG("PSXRecomp debug server listening on 127.0.0.1:{}", port);
++ return true;
++}
++
++bool PSXRecompDebug::HasAnyClients() { return !s_clients.empty(); }
++
++void PSXRecompDebug::Shutdown()
++{
++ if (!s_listen_socket) return;
++ INFO_LOG("Shutting down PSXRecomp debug server ({} clients)", s_clients.size());
++ s_clients.clear();
++ s_listen_socket.reset();
++ System::ReleaseSocketMultiplexer();
++}
++
++void PSXRecompDebug::OnFrameDone()
++{
++ s_frame_counter++;
++
++ // ---- Record frame into ring buffer ----
++ u32 idx = s_frame_counter % FRAME_HISTORY_CAP;
++ FrameRecord& r = s_history[idx];
++ r.frame_number = s_frame_counter;
++
++ // CPU
++ for (int i = 0; i < 32; i++)
++ r.gpr[i] = CPU::g_state.regs.r[i];
++ r.pc = CPU::g_state.pc;
++ r.hi = CPU::g_state.regs.hi;
++ r.lo = CPU::g_state.regs.lo;
++
++ // GPU
++ r.display_vram_left = g_gpu.GetDisplayVRAMLeft();
++ r.display_vram_top = g_gpu.GetDisplayVRAMTop();
++ r.display_vram_width = g_gpu.GetDisplayVRAMWidth();
++ r.display_vram_height = g_gpu.GetDisplayVRAMHeight();
++ r.display_disable = g_gpu.IsDisplayDisabled() ? 1 : 0;
++ r.interlaced = g_gpu.IsInterlacedDisplayEnabled() ? 1 : 0;
++ r.pal_mode = g_gpu.IsInPALMode() ? 1 : 0;
++ r._gpu_pad = 0;
++
++ // Pad
++ Controller* ctrl = System::GetController(0);
++ r.pad1_buttons = ctrl ? static_cast(ctrl->GetButtonStateBits()) : 0xFFFF;
++
++ r._reserved = 0;
++
++ // RAM snapshots
++ for (int si = 0; si < RAM_SNAPSHOT_REGIONS; si++)
++ {
++ r.snapshot_addr[si] = s_snapshot_addrs[si];
++ if (s_snapshot_addrs[si] != 0 && s_snapshot_addrs[si] + RAM_SNAPSHOT_SIZE <= Bus::g_ram_size)
++ std::memcpy(r.snapshot_data[si], Bus::g_ram + s_snapshot_addrs[si], RAM_SNAPSHOT_SIZE);
++ else
++ std::memset(r.snapshot_data[si], 0, RAM_SNAPSHOT_SIZE);
++ }
++
++ s_history_count = static_cast(s_frame_counter) + 1;
++
++ // ---- Input override ----
++ if (s_input_override >= 0)
++ {
++ Controller* pad = System::GetController(0);
++ if (pad)
++ {
++ u16 mask = static_cast(s_input_override);
++ for (u32 bit = 0; bit < 16; bit++)
++ {
++ // PS1 bitmask: 0 = pressed, 1 = released
++ float value = ((mask >> bit) & 1) == 0 ? 1.0f : 0.0f;
++ pad->SetBindState(bit, value);
++ }
++ }
++ }
++
++ // ---- Watchpoints ----
++ for (int i = 0; i < MAX_WATCHPOINTS; i++)
++ {
++ if (!s_watchpoints[i].active || s_watchpoints[i].addr >= Bus::g_ram_size)
++ continue;
++ u8 cur = Bus::g_ram[s_watchpoints[i].addr];
++ if (cur != s_watchpoints[i].prev_val)
++ {
++ BroadcastJSON(fmt::format(
++ R"({{"event":"watchpoint","addr":"0x{:08X}","old":"0x{:02X}","new":"0x{:02X}","frame":{}}})",
++ s_watchpoints[i].addr | 0x80000000u, s_watchpoints[i].prev_val, cur, s_frame_counter));
++ s_watchpoints[i].prev_val = cur;
++ }
++ }
++
++ // ---- Step mode ----
++ if (s_step_frames > 0)
++ {
++ s_step_frames--;
++ if (s_step_frames == 0)
++ {
++ s_paused = true;
++ System::PauseSystem(true);
++ BroadcastJSON(fmt::format(R"({{"event":"step_done","frame":{}}})", s_frame_counter));
++ }
++ }
++
++ // ---- Run-to-frame mode ----
++ if (s_run_to_frame > 0 && s_frame_counter >= s_run_to_frame)
++ {
++ s_run_to_frame = 0;
++ s_paused = true;
++ System::PauseSystem(true);
++ BroadcastJSON(fmt::format(R"({{"event":"run_to_done","frame":{}}})", s_frame_counter));
++ }
++}
+diff --git a/src/core/psxrecomp_debug_server.h b/src/core/psxrecomp_debug_server.h
+new file mode 100644
+index 0000000..8aa466c
+--- /dev/null
++++ b/src/core/psxrecomp_debug_server.h
+@@ -0,0 +1,20 @@
++// PSXRecomp TCP debug server for DuckStation
++// JSON-over-newline protocol on localhost:4371
++// Same protocol as the PSXRecomp runner debug server.
++// Includes 36000-frame ring buffer for retroactive state queries.
++
++#pragma once
++
++#include "common/types.h"
++
++namespace PSXRecompDebug {
++
++bool Initialize(u16 port = 4371);
++void Shutdown();
++bool HasAnyClients();
++
++/// Call once per frame from System::FrameDone(). Records frame state into
++/// the ring buffer, checks watchpoints, handles step-N logic.
++void OnFrameDone();
++
++} // namespace PSXRecompDebug
+diff --git a/src/core/system.cpp b/src/core/system.cpp
+index a36ad75..1d23daf 100644
+--- a/src/core/system.cpp
++++ b/src/core/system.cpp
+@@ -102,6 +102,7 @@ LOG_CHANNEL(System);
+ #define ENABLE_GDB_SERVER 1
+ #define ENABLE_SOCKET_MULTIPLEXER 1
+ #include "gdb_server.h"
++#include "psxrecomp_debug_server.h"
+ #endif
+
+ // #define PROFILE_MEMORY_SAVE_STATES 1
+@@ -2018,6 +2019,9 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error)
+ GDBServer::Initialize(g_settings.gdb_server_port);
+ #endif
+
++ // PSXRecomp debug server — always enabled on port 4371
++ PSXRecompDebug::Initialize(4371);
++
+ Host::OnSystemStarted();
+
+ if (parameters.load_image_to_ram || g_settings.cdrom_load_image_to_ram)
+@@ -2136,6 +2140,8 @@ void System::DestroySystem()
+ GDBServer::Shutdown();
+ #endif
+
++ PSXRecompDebug::Shutdown();
++
+ VideoThread::RunOnThread([]() {
+ VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::SystemPaused, false);
+ Host::ClearOSDMessages();
+@@ -2278,6 +2284,8 @@ void System::FrameDone()
+ Achievements::FrameUpdate();
+ }
+
++ PSXRecompDebug::OnFrameDone();
++
+ #ifdef ENABLE_DISCORD_PRESENCE
+ PollDiscordPresence();
+ #endif
+diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt
+index acb8e40..b7f7b8e 100644
+--- a/src/duckstation-qt/CMakeLists.txt
++++ b/src/duckstation-qt/CMakeLists.txt
+@@ -210,7 +210,7 @@ foreach(src ${SRCS})
+ list(APPEND UI_FILES ${src})
+ endif()
+ endforeach()
+-qt_wrap_ui(UI_SRCS SOURCES ${UI_FILES})
++qt_wrap_ui(UI_SRCS ${UI_FILES})
+ target_sources(duckstation-qt PRIVATE ${UI_SRCS})
+ target_include_directories(duckstation-qt PRIVATE "$")
+
diff --git a/tools/duckstation/setup.sh b/tools/duckstation/setup.sh
new file mode 100644
index 00000000..9ea359df
--- /dev/null
+++ b/tools/duckstation/setup.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# Set up the DuckStation oracle build:
+# 1. Ensure duckstation/ submodule is initialized and at the pinned upstream commit
+# 2. Fetch + extract Windows prebuilt deps if missing
+# 3. Rewrite stale absolute paths inside the extracted deps (artefact of how
+# upstream packages them — the prefix is baked in at pack time)
+# 4. Apply the PSXRecomp oracle patch (psxrecomp_oracle.patch) if not applied
+#
+# After setup, run `tools/duckstation/build.sh` (or the commands in its tail) to
+# compile duckstation-qt.exe into duckstation/build/bin/.
+#
+# Safe to run repeatedly — each step checks for "already done" and skips.
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+DUCK="$REPO_ROOT/duckstation"
+PATCH="$REPO_ROOT/tools/duckstation/psxrecomp_oracle.patch"
+UPSTREAM_BASE="ffb33c281d196eb8ee0f559085ca285de7cdd51b" # stenzek/master at time of daa1808
+
+log() { echo "[duckstation-setup] $*"; }
+
+# ---- Step 1: submodule init + checkout upstream base ----
+if [ ! -f "$DUCK/CMakeLists.txt" ]; then
+ log "initializing duckstation submodule..."
+ cd "$REPO_ROOT"
+ git submodule update --init --recursive duckstation
+fi
+
+cd "$DUCK"
+CUR_SHA="$(git rev-parse HEAD)"
+if [ "$CUR_SHA" != "$UPSTREAM_BASE" ]; then
+ log "checking out upstream base $UPSTREAM_BASE (was $CUR_SHA)..."
+ git fetch origin "$UPSTREAM_BASE" 2>/dev/null || git fetch origin
+ git checkout --detach "$UPSTREAM_BASE"
+fi
+
+# ---- Step 2: prebuilt deps ----
+DEPS_ARCHIVE="$DUCK/dep/prebuilt/deps-windows-x64.7z"
+DEPS_DIR="$DUCK/dep/prebuilt/windows-x64"
+PREBUILT_SHA256="f3473f18504b8a2d3ae32f007a575839288380503584d7867135da894ac8c129"
+PREBUILT_VERSION="$(cat "$DUCK/dep/PREBUILT-VERSION")"
+PREBUILT_URL="https://github.com/duckstation/dependencies/releases/download/${PREBUILT_VERSION}/deps-windows-x64.7z"
+
+if [ ! -f "$DEPS_ARCHIVE" ]; then
+ log "downloading prebuilt deps ($PREBUILT_VERSION) from $PREBUILT_URL..."
+ mkdir -p "$(dirname "$DEPS_ARCHIVE")"
+ if command -v curl >/dev/null 2>&1; then
+ curl -L --fail -o "$DEPS_ARCHIVE" "$PREBUILT_URL"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -O "$DEPS_ARCHIVE" "$PREBUILT_URL"
+ else
+ log "ERROR: neither curl nor wget found — fetch $PREBUILT_URL manually and place at $DEPS_ARCHIVE"
+ exit 1
+ fi
+fi
+
+if [ -f "$DEPS_ARCHIVE" ]; then
+ ACTUAL_SHA="$(sha256sum "$DEPS_ARCHIVE" | awk '{print $1}')"
+ if [ "$ACTUAL_SHA" != "$PREBUILT_SHA256" ]; then
+ log "ERROR: prebuilt archive sha256 mismatch"
+ log " expected: $PREBUILT_SHA256"
+ log " actual: $ACTUAL_SHA"
+ exit 1
+ fi
+fi
+
+if [ ! -d "$DEPS_DIR" ] || [ -z "$(ls -A "$DEPS_DIR" 2>/dev/null || true)" ]; then
+ log "extracting prebuilt deps..."
+ cd "$DUCK/dep/prebuilt"
+ if command -v 7z >/dev/null 2>&1; then
+ 7z x -y deps-windows-x64.7z >/dev/null
+ elif [ -x "/c/Program Files/7-Zip/7z.exe" ]; then
+ "/c/Program Files/7-Zip/7z.exe" x -y deps-windows-x64.7z >/dev/null
+ else
+ log "ERROR: 7z not found — extract $DEPS_ARCHIVE manually"
+ exit 1
+ fi
+fi
+
+# ---- Step 3: rewrite stale absolute paths in extracted CMake/pkg-config metadata ----
+# Upstream bakes in whatever prefix the archive was built at. Replace with this
+# checkout's real path. Idempotent: no-op once rewritten.
+log "normalizing absolute paths in prebuilt deps metadata..."
+python3 "$REPO_ROOT/tools/fix_duckstation_deps_paths.py" "$DEPS_DIR" >/dev/null
+
+# ---- Step 4: apply PSXRecomp oracle patch ----
+cd "$DUCK"
+if git apply --check "$PATCH" >/dev/null 2>&1; then
+ log "applying PSXRecomp oracle patch..."
+ git apply "$PATCH"
+else
+ # Either already applied, or conflicts. Distinguish:
+ if git apply --reverse --check "$PATCH" >/dev/null 2>&1; then
+ log "oracle patch already applied"
+ else
+ log "ERROR: oracle patch does not apply cleanly and is not already applied."
+ log " - upstream commit may have moved past the pinned base"
+ log " - or the patch itself was regenerated against a different base"
+ log " patch: $PATCH"
+ log " duck HEAD: $(git rev-parse HEAD)"
+ exit 1
+ fi
+fi
+
+log "setup complete — run tools/duckstation/build.sh to compile"
diff --git a/tools/dump_data.py b/tools/dump_data.py
new file mode 100644
index 00000000..15ae7805
--- /dev/null
+++ b/tools/dump_data.py
@@ -0,0 +1,13 @@
+import struct, sys
+with open('bios/SCPH1001.BIN','rb') as f: rom=f.read()
+BASE=0xBFC00000
+for a in sys.argv[1:]:
+ s,n = a.split(':') if ':' in a else (a, '16')
+ start=int(s,16); count=int(n)
+ off = start - BASE
+ print(f'--- 0x{start:08X} ---')
+ for i in range(count):
+ o = off + i*4
+ if o+4>len(rom): break
+ w = struct.unpack('>26)&0x3f
+ if op not in (2,3): continue
+ pc = BASE+off
+ real = (pc & 0xF0000000) | ((w & 0x3ffffff) << 2)
+ for t in targets:
+ if (real & 0x1FFFFFFF) == (t & 0x1FFFFFFF):
+ hits[t].append((pc, op, real))
+for t in targets:
+ print(f'--- {t:08X} ({len(hits[t])} callers) ---')
+ for pc, op, real in hits[t]:
+ n = 'jal' if op==3 else 'j'
+ print(f' PC=0x{pc:08X} {n} 0x{real:08X}')
diff --git a/tools/find_const.py b/tools/find_const.py
new file mode 100644
index 00000000..ca5f5ed0
--- /dev/null
+++ b/tools/find_const.py
@@ -0,0 +1,40 @@
+import struct, sys
+with open('bios/SCPH1001.BIN','rb') as f: rom=f.read()
+BASE=0xBFC00000
+# Find lui reg, X followed by addiu reg, reg, Y where X<<16+signext(Y) == target
+def find_const(target):
+ tgt_hi = (target >> 16) & 0xffff
+ tgt_lo_signed = target & 0xffff
+ # If low bit 15 set, lui needs hi+1
+ if tgt_lo_signed & 0x8000:
+ lui_hi = (tgt_hi + 1) & 0xffff
+ else:
+ lui_hi = tgt_hi
+ hits = []
+ for off in range(0, len(rom)-8, 4):
+ w0 = struct.unpack('>26)&0x3f
+ if op0 != 15 or (w0&0xffff) != lui_hi: continue
+ rt0 = (w0>>16)&0x1f
+ for d in range(1, 16):
+ noff = off + d*4
+ if noff+4>len(rom): break
+ w1 = struct.unpack('>26)&0x3f
+ if op1==9 and (w1&0xffff)==tgt_lo_signed:
+ rs1=(w1>>21)&0x1f; rt1=(w1>>16)&0x1f
+ if rs1==rt0 and rt1==rt0:
+ hits.append((BASE+off, BASE+noff, rt0, d))
+ break
+ if op1==13 and (w1&0xffff)==(target&0xffff): # ori variant
+ rs1=(w1>>21)&0x1f; rt1=(w1>>16)&0x1f
+ if rs1==rt0 and rt1==rt0 and (w0&0xffff)==tgt_hi:
+ hits.append((BASE+off, BASE+noff, rt0, d))
+ break
+ return hits
+
+for t in [int(a,16) for a in sys.argv[1:]]:
+ hits = find_const(t)
+ print(f'--- const 0x{t:08X} synthesized by ({len(hits)} sites) ---')
+ for lui_pc, lo_pc, rt, d in hits[:20]:
+ print(f' lui@0x{lui_pc:08X} lo@0x{lo_pc:08X} reg={rt} dist={d}')
diff --git a/tools/fix_ds_settings.py b/tools/fix_ds_settings.py
new file mode 100644
index 00000000..82527d23
--- /dev/null
+++ b/tools/fix_ds_settings.py
@@ -0,0 +1,9 @@
+"""Enable LogToFile in duckstation settings.ini."""
+import re, sys
+p = sys.argv[1]
+with open(p, 'r', encoding='utf-8') as f:
+ s = f.read()
+s = re.sub(r'LogToFile\s*=\s*false', 'LogToFile = true', s)
+with open(p, 'w', encoding='utf-8') as f:
+ f.write(s)
+print('done')
diff --git a/tools/fix_duckstation_deps_paths.py b/tools/fix_duckstation_deps_paths.py
new file mode 100644
index 00000000..146b709d
--- /dev/null
+++ b/tools/fix_duckstation_deps_paths.py
@@ -0,0 +1,38 @@
+"""Rewrite stale absolute paths inside duckstation's prebuilt deps.
+
+The windows-x64 prebuilt archive ships with CMake-generated Targets files that
+have `_IMPORT_PREFIX` baked in as an absolute path from wherever the archive
+was originally extracted. When the project root moves (e.g. psxrecomp-projects
+-> psxrecomp-v4), CMake can't find the referenced .lib/.dll files even though
+they're there.
+
+This script rewrites `OLD_PREFIX` -> `NEW_PREFIX` in every file under
+`duckstation/dep/prebuilt/`. Safe to run repeatedly (no-op after first run).
+"""
+import os, sys
+
+OLD = b"psxrecomp-projects"
+NEW = b"psxrecomp-v4"
+ROOT = sys.argv[1] if len(sys.argv) > 1 else "duckstation/dep/prebuilt"
+
+n_files = 0
+n_rewrites = 0
+for base, dirs, files in os.walk(ROOT):
+ for name in files:
+ p = os.path.join(base, name)
+ try:
+ with open(p, "rb") as f:
+ data = f.read()
+ except (OSError, IOError):
+ continue
+ if OLD not in data:
+ continue
+ count = data.count(OLD)
+ new_data = data.replace(OLD, NEW)
+ with open(p, "wb") as f:
+ f.write(new_data)
+ n_files += 1
+ n_rewrites += count
+ print(f" {p}: {count} replacement(s)")
+
+print(f"total: {n_files} files, {n_rewrites} replacements")
diff --git a/tools/scan_imm.py b/tools/scan_imm.py
new file mode 100644
index 00000000..71646ed3
--- /dev/null
+++ b/tools/scan_imm.py
@@ -0,0 +1,15 @@
+"""Scan for all addiu/ori instructions with a given 16-bit imm."""
+import struct, sys
+with open('bios/SCPH1001.BIN','rb') as f:
+ rom = f.read()
+BASE = 0xBFC00000
+imm_want = int(sys.argv[1], 16)
+for off in range(0, len(rom)-3, 4):
+ w = struct.unpack('> 26) & 0x3f
+ if op in (9, 13) and (w & 0xffff) == imm_want:
+ pc = BASE + off
+ rt = (w >> 16) & 0x1f
+ rs = (w >> 21) & 0x1f
+ name = 'addiu' if op == 9 else 'ori'
+ print(f'0x{pc:08X}: {name} ${rt},${rs},0x{imm_want:X}')
diff --git a/tools/scan_literal.py b/tools/scan_literal.py
new file mode 100644
index 00000000..5fee52e4
--- /dev/null
+++ b/tools/scan_literal.py
@@ -0,0 +1,14 @@
+import struct, sys
+with open('bios/SCPH1001.BIN','rb') as f: rom=f.read()
+BASE=0xBFC00000
+for a in sys.argv[1:]:
+ t=int(a,16)
+ b=struct.pack('1 else 0xBFC6
+for off in range(0, len(rom)-3, 4):
+ w = struct.unpack('>26)&0x3f
+ if op==15 and (w&0xffff)==hi:
+ rt=(w>>16)&0x1f
+ print(f'0x{BASE+off:08X}: lui ${rt},0x{hi:04X}')
diff --git a/tools/scan_range.py b/tools/scan_range.py
new file mode 100644
index 00000000..381ddebb
--- /dev/null
+++ b/tools/scan_range.py
@@ -0,0 +1,16 @@
+import struct
+with open('bios/SCPH1001.BIN','rb') as f: rom=f.read()
+BASE=0xBFC00000
+ranges = [(0xBFC61CFC, 0xBFC61D20), (0x80061D00, 0x80061D20)]
+for lo,hi in ranges:
+ print(f'--- literals in [0x{lo:X}..0x{hi:X}] ---')
+ for off in range(0, len(rom)-3, 4):
+ w = struct.unpack('