mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
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:
+6
-2
@@ -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/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "duckstation"]
|
||||
path = duckstation
|
||||
url = https://github.com/stenzek/duckstation.git
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"}))
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
@@ -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}')
|
||||
@@ -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}')
|
||||
@@ -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}')
|
||||
@@ -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')
|
||||
@@ -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")
|
||||
@@ -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}')
|
||||
@@ -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})')
|
||||
@@ -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}')
|
||||
@@ -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}')
|
||||
Reference in New Issue
Block a user