From 685687f36fa0fb41bd6b5ad3f804c0a25b5ec1fa Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 14 Apr 2026 01:34:27 -0700 Subject: [PATCH] Restructure duckstation as submodule + patch; salvage TCP docs + debug_client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovery + restructure after rm -rf destroyed duckstation/build/ earlier this session. Adopts the nesrecomp pattern (see F:/Projects/nesrecomp): pristine upstream as submodule, our changes as a patch file that gets auto-applied by a setup script. Submodule: - duckstation/ now pins stenzek/duckstation@ffb33c281 (release-20260328 era) - .gitmodules records the pin; .gitignore stops ignoring the submodule path but still ignores build/ and dep/prebuilt/ (the submodule's own .gitignore also handles these) Patch + setup: - tools/duckstation/psxrecomp_oracle.patch (1187 lines, 7 files) captures our TCP debug-server on port 4371 plus the unfinished pc_break family of commands (with the VirtualMemoryAddress compile fix) - tools/duckstation/setup.sh clones submodule, fetches + verifies + extracts prebuilt Windows deps, rewrites stale absolute paths baked into the prebuilt CMake metadata, applies the oracle patch. Idempotent. - tools/duckstation/build.sh configures CMake (VS 17 2022) + runs MSBuild, then provisions bin/ with portable marker, BIOS copy, and settings.ini post-fixes - tools/duckstation/README.md documents the layout, the regeneration flow, and the known headless-boot configuration issue TCP documentation (salvaged from v3): - TCP_COMMANDS.md: full command table with native (4370) / duckstation (4371) / both columns, reflecting actual v4 implementation including wtrace_* and pc_break/pc_hit_last families - tools/debug_client.py: unified CLI from v3 (ports 4370/4371 match v4) - DEBUG.md: merged v3's END-TO-END COMPLETION RULE, Stale Hypothesis Rule, TOOLING RULE, and TOOL LIFECYCLE RULE sections into v4's existing loop Phase 4 diagnostic artefacts from this session: - generated/relocation_proofs/vblank_registrar/ — corrected VBlank-stall diagnosis. Prior handoff claimed the shell VBlank handler was never installed; live TCP inspection of both runtime and DuckStation oracle proved handler IS installed at 0x800DFEE0. Real gap is one level up in the kernel IRQ chain at *(0xA000E014). - tools/duck_query*.py, duck_search.py, disasm_ram.py, scan_*.py — focused probes used to establish the above; reusable against either server CLAUDE.md §2 and new §15 document the submodule-based oracle setup and the "never edit submodule, regenerate patch instead" rule. Known issues (tracked in tools/duckstation/README.md): - Fresh settings.ini doesn't complete BIOS boot under -nogui; one-time launch with GUI is required to warm up the settings before headless mode works Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 8 +- .gitmodules | 3 + CLAUDE.md | 35 +- DEBUG.md | 101 ++ TCP_COMMANDS.md | 100 ++ duckstation | 1 + .../vblank_registrar/DUCKSTATION_FINDINGS.md | 88 ++ .../vblank_registrar/proof.json | 126 ++ tools/add_ds_bios_paths.py | 16 + tools/add_ds_setup_bypass.py | 11 + tools/debug_client.py | 461 +++++++ tools/disasm_helper.py | 62 + tools/disasm_ram.py | 89 ++ tools/duck_query.py | 87 ++ tools/duck_query2.py | 96 ++ tools/duck_query3.py | 76 ++ tools/duck_query4.py | 79 ++ tools/duck_query5.py | 65 + tools/duck_search.py | 61 + tools/duckstation/README.md | 77 ++ tools/duckstation/build.sh | 69 + tools/duckstation/psxrecomp_oracle.patch | 1187 +++++++++++++++++ tools/duckstation/setup.sh | 106 ++ tools/dump_data.py | 13 + tools/find_callers.py | 20 + tools/find_const.py | 40 + tools/fix_ds_settings.py | 9 + tools/fix_duckstation_deps_paths.py | 38 + tools/scan_imm.py | 15 + tools/scan_literal.py | 14 + tools/scan_lui.py | 10 + tools/scan_range.py | 16 + 32 files changed, 3174 insertions(+), 5 deletions(-) create mode 100644 .gitmodules create mode 100644 TCP_COMMANDS.md create mode 160000 duckstation create mode 100644 generated/relocation_proofs/vblank_registrar/DUCKSTATION_FINDINGS.md create mode 100644 generated/relocation_proofs/vblank_registrar/proof.json create mode 100644 tools/add_ds_bios_paths.py create mode 100644 tools/add_ds_setup_bypass.py create mode 100644 tools/debug_client.py create mode 100644 tools/disasm_helper.py create mode 100644 tools/disasm_ram.py create mode 100644 tools/duck_query.py create mode 100644 tools/duck_query2.py create mode 100644 tools/duck_query3.py create mode 100644 tools/duck_query4.py create mode 100644 tools/duck_query5.py create mode 100644 tools/duck_search.py create mode 100644 tools/duckstation/README.md create mode 100644 tools/duckstation/build.sh create mode 100644 tools/duckstation/psxrecomp_oracle.patch create mode 100644 tools/duckstation/setup.sh create mode 100644 tools/dump_data.py create mode 100644 tools/find_callers.py create mode 100644 tools/find_const.py create mode 100644 tools/fix_ds_settings.py create mode 100644 tools/fix_duckstation_deps_paths.py create mode 100644 tools/scan_imm.py create mode 100644 tools/scan_literal.py create mode 100644 tools/scan_lui.py create mode 100644 tools/scan_range.py 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('