Restructure duckstation as submodule + patch; salvage TCP docs + debug_client

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) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-04-14 01:34:27 -07:00
parent e8094affb4
commit 685687f36f
32 changed files with 3174 additions and 5 deletions
+6 -2
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
[submodule "duckstation"]
path = duckstation
url = https://github.com/stenzek/duckstation.git
+32 -3
View File
@@ -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 <base> > 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.
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.
+101
View File
@@ -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.
+100
View File
@@ -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": "<command>", ...params}`
- Success: `{"id": N, "ok": true, ...data}`
- Failure: `{"id": N, "ok": false, "error": "<msg>"}`
There are **two** servers, both implementing this protocol with overlapping command sets:
| Server | Port | Source |
|---|---|---|
| **Native** (our recompiled runtime) | `4370` | `runtime/src/debug_server.c` |
| **DuckStation** (oracle) | `4371` | `duckstation/src/core/psxrecomp_debug_server.cpp` (patched, see `tools/duckstation/psxrecomp_oracle.patch`) |
The `debug_client.py` CLI can target either, or `compare` two at once to diff state live — that's how divergence hunts work.
```bash
python tools/debug_client.py <cmd> [args] # native (port 4370)
python tools/debug_client.py --ds <cmd> [args] # duckstation (port 4371)
python tools/debug_client.py compare <cmd> # 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 <cmd>` 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**.
Submodule
+1
Submodule duckstation added at ffb33c281d
@@ -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.
@@ -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"
]
}
+16
View File
@@ -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')
+11
View File
@@ -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')
+461
View File
@@ -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] <command> [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 <addr> [len] Read RAM bytes (hex addr, default 16 bytes)
dump <addr> [len] Chunked RAM read (up to 64KB)
write <addr> <hex> Write RAM bytes
scratch <addr> [len] Read scratchpad bytes
gpu GPU display state
overlay Overlay state
watch <addr> Set byte watchpoint
unwatch <addr> Remove watchpoint
pause Pause execution
continue / c Resume execution
step [n] Step N frames (default 1)
run_to <frame> Run to specific frame, then pause
history Ring buffer stats
get_frame <n> Get full frame record from ring buffer
range <start> <end> Frame range query (max 200)
ts <start> <end> Frame timeseries — compact (max 200)
set_snapshot <slot> <addr> Configure RAM snapshot region (slot 0-3)
get_snapshots Show snapshot region config
input <buttons_hex> Override controller input
clear_input Remove input override
quit Quit game
Comparison commands (query both servers):
compare <command> [args...] Run command on both ports, diff results
ts_compare <start> <end> 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 <addr> <hex>"
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 <addr>"
return {"cmd": "watch", "addr": args[1]}, pretty_json
elif cmd == "unwatch":
if len(args) < 2:
return None, lambda _: "Usage: unwatch <addr>"
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 <frame>"
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 <n>"
return {"cmd": "get_frame", "frame": int(args[1])}, pretty_json
elif cmd == "range":
if len(args) < 3:
return None, lambda _: "Usage: range <start> <end>"
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 <start> <end>"
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 <slot> <addr>"
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 <buttons_hex>"
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 <start> <end>"
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()
+62
View File
@@ -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('<I', rom[off:off+4])[0]
print(f'0x{pc:08X}: {w:08X} {dis(pc,w)}')
if __name__=='__main__':
rom = load_rom()
for arg in sys.argv[1:]:
if ':' in arg:
s,n = arg.split(':'); dump(rom, int(s,16), int(n))
else:
dump(rom, int(arg,16), 24)
print()
+89
View File
@@ -0,0 +1,89 @@
"""Fetch 4KB at target RAM via TCP debug server and disassemble."""
import sys, socket, json, time, struct
HOST='127.0.0.1'
PORT = int(sys.argv[1]) if len(sys.argv)>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()<deadline:
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()
if not line: return None
try: return json.loads(line)
except: return line
r = send({"cmd":"read_ram","addr":f"0x{addr:08X}","len":length})
if not r or not r.get('ok'):
print('read failed', r); sys.exit(1)
data = bytes.fromhex(r['hex'])
base = 0x80000000 | addr
names=['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 '$'+names[n]
for i in range(0, len(data), 4):
if i+4>len(data): break
w = struct.unpack('<I', data[i:i+4])[0]
pc = base+i
op=(w>>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)
+87
View File
@@ -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()<deadline:
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()
if not line: return None
try: return json.loads(line)
except: return line
def read_ram(addr, length):
r = send({"cmd":"read_ram","addr":f"0x{addr:08X}","len":length})
if not r or not r.get('ok'): return None
return bytes.fromhex(r['hex'])
def search_in(data, base_addr, marker_le, label):
pos=0
while True:
i=data.find(marker_le, pos)
if i<0: break
a = base_addr + i
print(f' {label}: RAM phys=0x{a:08X} (KSEG=0x{0x80000000|a:08X})', flush=True)
pos=i+1
if __name__=='__main__':
print('PING:', send({"cmd":"ping"}), flush=True)
# Pause execution for stable read
print('PAUSE:', send({"cmd":"pause"}), flush=True)
time.sleep(0.5)
# Read kernel state addresses we already know
for addr,n,name in [
(0x00079D9C, 4, 'VSync counter'),
(0x00079D48, 4, 'init-done flag (chk by 0xBFC42160)'),
(0x000DFF40, 4, 'user VSync callback slot'),
(0x00000080, 16, 'exception vector'),
(0x00000100, 64, 'PCB/TCB/EvCB pointer table'),
]:
d = read_ram(addr, n)
if d:
words = ' '.join(f'{int.from_bytes(d[i:i+4],"little"):08X}' for i in range(0,len(d),4))
print(f' 0x{addr:08X} {name}: {words}', flush=True)
# Search EvCB for the handler/registrar/init function pointers
targets = {
bytes.fromhex('bca50580'): '0x8005A5BC handler',
bytes.fromhex('40a50580'): '0x8005A540 registrar',
bytes.fromhex('60a10580'): '0x8005A160 init',
bytes.fromhex('24a40580'): '0x8005A424 cleanup',
bytes.fromhex('80a30580'): '0x8005A380 dispatch',
bytes.fromhex('fca50580'): '0x8005A5FC SetVSync',
}
print('\n--- Searching key RAM regions for handler-related addresses ---', flush=True)
# Search smaller, focused regions: kernel data 0x0000_0000..0x0001_0000, EvCB region 0x0000_E000..0x0000_E200,
# shell stack 0x000F_0000..0x0010_0000, and shell data 0x000DF000..0x000E0000
for base, length in [(0x00000000, 0x00010000), (0x000DF000, 0x00001000), (0x000E0000, 0x00010000), (0x000F0000, 0x00010000)]:
print(f'\nRegion 0x{base:08X}..0x{base+length:08X}:', flush=True)
# read in 4KB chunks
chunks = []
for off in range(0, length, 4096):
d = read_ram(base+off, min(4096, length-off))
if d is None:
print(f' (read failed at 0x{base+off:08X})', flush=True)
break
chunks.append(d)
if not chunks: continue
data = b''.join(chunks)
for marker, label in targets.items():
search_in(data, base, marker, label)
# Resume
print('\nCONTINUE:', send({"cmd":"continue"}), flush=True)
+96
View File
@@ -0,0 +1,96 @@
"""Follow-up probe: read IRQ chain blocks, explore 0x800DFEE0 context, disassemble exception entry."""
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()<deadline:
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()
if not line: return None
try: return json.loads(line)
except: return line
def read_ram(addr, length):
r = send({"cmd":"read_ram","addr":f"0x{addr:08X}","len":length})
if not r or not r.get('ok'): return None
return bytes.fromhex(r['hex'])
def dump_words(data, base, annotate=None):
for i in range(0, len(data), 16):
words = [int.from_bytes(data[i+j:i+j+4],'little') for j in range(0, min(16,len(data)-i), 4)]
hexs = ' '.join(f'{w:08X}' for w in words)
annot = ''
if annotate:
for j,w in enumerate(words):
tag = annotate(base+i+j*4, w)
if tag: annot += f' [{base+i+j*4:08X}:{tag}]'
print(f' 0x{base+i:08X}: {hexs}{annot}', flush=True)
# Targets we care about
TARGETS = {
0x8005A5BC: 'handler',
0x8005A540: 'registrar',
0x8005A160: 'init',
0x8005A424: 'cleanup',
0x8005A380: 'dispatch-entry',
0x8005A5FC: 'SetVSync',
0x8005A600: '0xBFC42600',
0xBFC42160: 'init-rom',
0xBFC425BC: 'handler-rom',
}
def annot(addr, val):
if val in TARGETS: return TARGETS[val]
for t,name in TARGETS.items():
if val == t: return name
# Match uncached alias
if (val | 0x20000000) in TARGETS: return TARGETS[val|0x20000000]+'(uncached)'
if (val & ~0x20000000) in TARGETS: return TARGETS[val&~0x20000000]+'(cached)'
return None
print('PING:', send({"cmd":"ping"}))
print('PAUSE:', send({"cmd":"pause"}))
time.sleep(0.3)
# 1. Dump exception entry at 0x80000C80 (from jr $k0 → 0x00000C80)
print('\n=== Exception handler at 0x80000C80 (32 words) ===', flush=True)
d = read_ram(0x00000C80, 128)
if d: dump_words(d, 0x80000C80, annot)
# 2. Dump IRQ handler chain block at 0xA000E1F4, size 0x300 bytes
print('\n=== Handler chain block 0xA000E1F4..+0x300 ===', flush=True)
d = read_ram(0x0000E1F4, 0x300)
if d: dump_words(d, 0xA000E1F4, annot)
# 3. Dump 0x800DFEE0 context (64 bytes before, 64 after)
print('\n=== Around 0x800DFEE0 (where handler ptr lives) ===', flush=True)
d = read_ram(0x000DFEC0, 256)
if d: dump_words(d, 0x800DFEC0, annot)
# 4. Also check the TCB block at 0xA000E1EC, size 4*TCB (each TCB 0xC0 bytes)
print('\n=== 0xA000E1EC (TCB table, 4×0xC0=0x300 bytes) — overlapping with chain block? ===', flush=True)
d = read_ram(0x0000E1EC, 16)
if d: dump_words(d, 0xA000E1EC, annot)
# 5. Dump 0xA000E004 block, size 0x20
print('\n=== 0xA000E004..+0x20 (DCB?) ===', flush=True)
d = read_ram(0x0000E004, 0x30)
if d: dump_words(d, 0xA000E004, annot)
# 6. Dump 0xA000E028 EvCB
print('\n=== EvCB 0xA000E028..+0x1C0 (first 4 slots only, 28 bytes each) ===', flush=True)
d = read_ram(0x0000E028, 4*28)
if d: dump_words(d, 0xA000E028, annot)
print('\nCONTINUE:', send({"cmd":"continue"}))
+76
View File
@@ -0,0 +1,76 @@
"""Probe A0/B0/C0 kernel vector tables and the RAM trampoline chain."""
import socket, json, time, struct
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()<deadline:
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()
if not line: return None
try: return json.loads(line)
except: return line
def read_ram(addr, length):
r = send({"cmd":"read_ram","addr":f"0x{addr:08X}","len":length})
if not r or not r.get('ok'): return None
return bytes.fromhex(r['hex'])
def dump(data, base, label=''):
print(f'--- {label} 0x{base:08X}..+0x{len(data):X} ---', flush=True)
for i in range(0, len(data), 16):
words = [int.from_bytes(data[i+j:i+j+4],'little') for j in range(0, min(16,len(data)-i), 4)]
print(f' 0x{base+i:08X}: ' + ' '.join(f'{w:08X}' for w in words), flush=True)
send({"cmd":"pause"})
time.sleep(0.3)
# A0/B0/C0 trampolines at RAM 0xA0, 0xB0, 0xC0
for name, addr in [('A0',0xA0),('B0',0xB0),('C0',0xC0)]:
d = read_ram(addr, 16)
if d: dump(d, 0x80000000|addr, f'{name} vector trampoline')
# Now, each A0/B0/C0 dispatcher loads a function pointer from a table. The table
# base is computed internally. Let me scan a wider window around common locations
# where the A0/B0/C0 pointer tables might live.
# Based on standard PS1 layout, tables are often at 0x80000200..0x80000400 or in
# the kernel RAM 0x8000_0500+. Let's dump that.
d = read_ram(0x00000000, 0x200)
if d: dump(d, 0x80000000, 'RAM 0x80000000..0x200 (vectors)')
# Search kernel RAM 0x80000500..0x80009000 for the handler address 0x8005A5BC
# AND the registrar address 0x8005A540 AND SetVSync 0x8005A5FC
print('\n--- Searching kernel RAM 0x80000500..0x80009000 for shell kernel ptrs ---', flush=True)
d = read_ram(0x00000500, 0x8B00)
if d:
targets = {
bytes.fromhex('bca50580'): '0x8005A5BC handler',
bytes.fromhex('40a50580'): '0x8005A540 registrar',
bytes.fromhex('60a10580'): '0x8005A160 init',
bytes.fromhex('fca50580'): '0x8005A5FC SetVSync',
bytes.fromhex('24a40580'): '0x8005A424 cleanup',
bytes.fromhex('80a30580'): '0x8005A380 chain-install',
}
for m,label in targets.items():
pos=0
while True:
i=d.find(m,pos)
if i<0: break
a = 0x80000000 | (0x500 + i)
print(f' {label}: at 0x{a:08X}', flush=True)
pos = i+1
send({"cmd":"continue"})
print('RESUMED', flush=True)
+79
View File
@@ -0,0 +1,79 @@
"""Dump runtime A0/B0/C0 tables from DuckStation and search for VSync/registrar/init in each."""
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()<deadline:
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()
if not line: return None
try: return json.loads(line)
except: return line
def read_ram(addr, length):
# Chunked
out = b''
off = 0
while off < length:
n = min(4096, length-off)
r = send({"cmd":"read_ram","addr":f"0x{addr+off:08X}","len":n})
if not r or not r.get('ok'): return None
out += bytes.fromhex(r['hex'])
off += n
return out
TARGETS = {
0x8005A5BC: 'handler',
0x8005A540: 'registrar',
0x8005A160: 'init_1FC42160',
0x8005A5FC: 'SetVSync/CRCNT',
0x8005A380: 'chain-install',
0x8005A424: 'cleanup',
0x80059C80: 'VSync(1FC41C80)',
0x80059FC0: 'InitRCnt(1FC41FC0)',
0x80059DA4: 'VSync-spin(1FC41DA4)',
0x8005A600: '0xBFC42600',
0x8005A640: '0xBFC42640',
0x8005A70C: '0xBFC4270C',
0x8005A090: 'jal-target(1FC42090)',
0x8005A910: 'jal-target(1FC42910)',
0x8005A980: 'jal-target(1FC42980)',
0x8005AC00: 'jal-target(1FC42C00)',
}
def table_dump(name, base, length):
d = read_ram(base, length)
if not d: print(f'{name}: read failed'); return
print(f'\n=== {name} @ 0x{base|0x80000000:08X} ({length} bytes) ===')
for i in range(0, len(d), 4):
if i+4 > 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"})
+65
View File
@@ -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()<deadline:
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()
if not line: return None
try: return json.loads(line)
except: return line
def read_ram(addr, length):
r = send({"cmd":"read_ram","addr":f"0x{addr:08X}","len":length})
if not r or not r.get('ok'): return None
return bytes.fromhex(r['hex'])
TARGETS = {
0x8005A5BC:'handler', 0x8005A540:'registrar', 0x8005A160:'init_42160',
0x8005A5FC:'SetVSync', 0x8005A380:'chain-install', 0x8005A424:'cleanup',
0x80059C80:'VSync(41C80)', 0x80059FC0:'InitRCnt(41FC0)', 0x80059DA4:'spin(41DA4)',
0x8005A600:'42600', 0x8005A640:'42640', 0x8005A70C:'4270C',
0x8005A090:'42090', 0x8005A910:'42910', 0x8005A980:'42980', 0x8005AC00:'42C00',
0x80059C7C:'41C7C', 0x80059F88:'41F88', 0x8005AE2C:'42E2C',
}
def dump(name, base, length):
d = read_ram(base, length)
if not d: print(f'{name}: read failed'); return
print(f'\n=== {name} @ 0x{0x80000000|base:08X} ({length} bytes, {length//4} entries) ===')
nonzero = []
for i in range(0, len(d), 4):
if i+4>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"})
+61
View File
@@ -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)
+77
View File
@@ -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 <cmd>` 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.
+69
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+106
View File
@@ -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"
+13
View File
@@ -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('<I', rom[o:o+4])[0]
print(f' 0x{start+i*4:08X}: 0x{w:08X}')
+20
View File
@@ -0,0 +1,20 @@
import struct, sys
with open('bios/SCPH1001.BIN','rb') as f: rom=f.read()
BASE=0xBFC00000
# Print addresses that JAL/J to any target in argv
targets = [int(a,16) for a in sys.argv[1:]]
hits = {t: [] for t in targets}
for off in range(0, len(rom), 4):
w = struct.unpack('<I', rom[off:off+4])[0]
op=(w>>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}')
+40
View File
@@ -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('<I', rom[off:off+4])[0]
op0 = (w0>>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('<I', rom[noff:noff+4])[0]
op1 = (w1>>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}')
+9
View File
@@ -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')
+38
View File
@@ -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")
+15
View File
@@ -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('<I', rom[off:off+4])[0]
op = (w >> 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}')
+14
View File
@@ -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('<I', t)
pos=0; hits=[]
while True:
i=rom.find(b,pos)
if i<0: break
hits.append(i); pos=i+1
print(f'0x{t:08X}: {len(hits)} literal occurrences')
for h in hits[:10]:
print(f' ROM+0x{h:X} (addr=0x{BASE+h:08X})')
+10
View File
@@ -0,0 +1,10 @@
import struct, sys
with open('bios/SCPH1001.BIN','rb') as f: rom=f.read()
BASE=0xBFC00000
hi = int(sys.argv[1],16) if len(sys.argv)>1 else 0xBFC6
for off in range(0, len(rom)-3, 4):
w = struct.unpack('<I', rom[off:off+4])[0]
op=(w>>26)&0x3f
if op==15 and (w&0xffff)==hi:
rt=(w>>16)&0x1f
print(f'0x{BASE+off:08X}: lui ${rt},0x{hi:04X}')
+16
View File
@@ -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('<I', rom[off:off+4])[0]
if lo <= w < hi:
print(f' ROM+0x{off:X} (0x{BASE+off:08X}) = 0x{w:08X}')
# Also scan for half-alignment
print('--- unaligned too ---')
for off in range(0, len(rom)-3):
w = struct.unpack('<I', rom[off:off+4])[0]
if 0xBFC61D00 <= w < 0xBFC61D20:
print(f' ROM+0x{off:X} = 0x{w:08X}')