mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
77d036bfb6
Make the repo approachable for outside contributors and an IDE integrator:
- New contributor docs:
- docs/EXECUTION_MODEL.md — semi-technical tour of the static / native-overlay
/ interpreter execution model and how coverage gaps get plugged.
- docs/ARCHITECTURE.md — component-level view (recompiler, runtime, LLE+HLE
BIOS, overlay backend static->gcc->tcc, renderers, oracle model).
- docs/BUILDING.md — accurate dependency table, per-platform prereqs, build
steps, and the submodule + local-junction framework-linking setup.
- CONTRIBUTING.md — distilled core rules, verification expectations, PR flow.
- Declutter root: move ~14 internal dev-logs into docs/internal/ (history
preserved via rename) with an index README; repoint CLAUDE.md references.
- README: correct build recipe, LLE+HLE + overlay-backend summary, links to the
new docs.
- code_provider.c: update the stale backend comment to the real static->gcc->tcc
resolution (dead SLJIT.md reference removed).
- config_schema.md: drop the obsolete "once Phase A2/A3 ships --config" note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1533 lines
68 KiB
Markdown
1533 lines
68 KiB
Markdown
# PSXRecomp v4 — Plan for Static Recompilation of the PlayStation BIOS
|
||
|
||
> **Audience for this document:** an outside reviewer (ChatGPT or a
|
||
> human engineer) who has not seen any of the prior versions. The goal
|
||
> is for this document to be self-contained: by the end you should
|
||
> understand what we are building, why three previous attempts failed,
|
||
> what we are deliberately doing differently, what the hard problems
|
||
> are, and where we want outside input on architectural decisions.
|
||
|
||
---
|
||
|
||
## TL;DR
|
||
|
||
We are building **PSXRecomp v4**, a static MIPS-to-C recompiler that
|
||
takes the PlayStation 1 BIOS ROM (`SCPH1001.BIN`, 512 KB of MIPS R3000A
|
||
machine code) and emits a single `SCPH1001_full.c` file containing every
|
||
BIOS function as a native C function. That generated C is then linked
|
||
into a small SDL2-based runtime which simulates the PS1 hardware
|
||
peripherals (GPU, DMA, timers, interrupts, controllers, etc.) over MMIO.
|
||
The recompiled BIOS calls into those simulations the same way real BIOS
|
||
code touches `0x1F801xxx` MMIO on real hardware. There is **no MIPS
|
||
interpreter** in the BIOS execution path. There are **no HLE shims** that
|
||
fake the output of BIOS functions. The BIOS runs as native compiled C.
|
||
|
||
Once the BIOS boots cleanly through the Sony logo and into the BIOS
|
||
shell (Phases 1-4), we add a second recompilation entry point for PS1
|
||
game EXEs (Phase 5), starting with Tomba! as the first end-to-end
|
||
target including save/load via memory card (Phase 6).
|
||
|
||
This is the project's third clean restart. We are doing it because the
|
||
prior three attempts (v1, v2, v3) all collapsed into the same shape:
|
||
**interpreter + HLE emulator pretending to be a recompiler**. v4 forbids
|
||
that shape architecturally so it cannot be reached again under
|
||
schedule pressure.
|
||
|
||
---
|
||
|
||
## Background: three failed attempts
|
||
|
||
### v1 — `F:/Projects/psxrecomp/` — 126 sessions
|
||
|
||
The original attempt. Worked through Tomba! to a playable state with
|
||
working renderer and GTE. **The BIOS was almost entirely stubbed.**
|
||
Save/load was broken because the kernel data structures (PCB, TCB,
|
||
EvCB) the game relied on were never initialized properly — there was no
|
||
real BIOS init pass; the runner just faked the values it thought the
|
||
BIOS would have written. Game ran, save/load fundamentally did not.
|
||
Project archived after 126 sessions of work without resolving the save
|
||
issue.
|
||
|
||
### v2 — `F:/Projects/psxrecomp-v2/` — Severed Chains attempt
|
||
|
||
A pivot to a different game (Severed Chains / Legend of Dragoon
|
||
restoration project) with FFmpeg integration for FMV playback. Same
|
||
underlying recompiler approach, same BIOS-stubbing problem.
|
||
Abandoned. Contains many FMV decode test artifacts.
|
||
|
||
### v3 — `F:/Projects/psxrecomp-projects-v3/`
|
||
|
||
The "this time we'll do BIOS first properly" restart. **It was not.**
|
||
v3's actual architecture, verified by reading the source:
|
||
|
||
1. **The recompiler (`psxrecomp/recompiler/`) only ingests PS1-EXE
|
||
files.** `main_psx.cpp:17` calls `PS1ExeParser::parse_file()` which
|
||
validates a `"PS-X EXE"` magic header. SCPH1001.BIN has no such
|
||
header. Searching the entire `recompiler/` tree for `SCPH`,
|
||
`bios.bin`, `0xBFC00000`, or any BIOS-related identifier returns
|
||
zero matches. **The recompiler in v3 has never seen the BIOS ROM.**
|
||
|
||
2. **`TombaRecomp/generated/` contains only `SCUS_942.36_*.c`** (Tomba's
|
||
game serial). No `SCPH1001*.c` file has ever been emitted.
|
||
|
||
3. **At runtime, the BIOS is executed by `runner/src/interpreter.c`**, a
|
||
919-line hand-written MIPS R3000A interpreter. `main_runner.cpp:575`
|
||
calls `interpreter_run_bios_boot(&g_cpu, bios_only)` which walks the
|
||
bytes of SCPH1001.BIN starting at `0xBFC00000`. This is exactly what
|
||
an emulator does.
|
||
|
||
4. **`runner/src/bios.c` is 1808 lines of HLE shims** that intercept
|
||
the BIOS A0/B0/C0 vectors and replace real BIOS functions with C
|
||
reimplementations. Many shims fake outputs. The most egregious
|
||
example, found while writing this plan: `B0:0x4B (StartCard)` at
|
||
`bios.c:1340` sets an EvCB status byte directly with the comment
|
||
*"no SIO chain handler — deliver completion event manually"*. The
|
||
real SIO chain handler was never installed because the integration
|
||
between the interpreter and the IRQ delivery path is incomplete.
|
||
Hand-delivering the event made the symptom go away while leaving
|
||
the underlying bug untouched.
|
||
|
||
5. **The "run native" path inside `bios.c` does not run native C
|
||
either.** `bios_run_native_b0` (line 284) reads the B0 dispatch
|
||
table from PS1 RAM, finds the function address, and calls
|
||
`interpreter_run` on it. "Native" in v3 means *"interpreted MIPS
|
||
bytes from the real BIOS ROM"* — not *"compiled C function"*. This
|
||
redefinition of "native" is itself a symptom of the architectural
|
||
confusion.
|
||
|
||
6. **555 GB of `boot_trace*.log` and `card_test*.log` files** had
|
||
accumulated in `TombaRecomp/` because someone left a tracing flag
|
||
on for hours/days. Deleted before forking.
|
||
|
||
v3 ran for ~25 commits and reached "Sony logo displays + BIOS shell
|
||
appears + memory card screen renders" — but every milestone past the
|
||
Sony logo depended on increasingly elaborate HLE deception. The fix
|
||
for each new bug was another shim. The architecture made bugs cheaper
|
||
to paper over than to fix.
|
||
|
||
**The v3 failure was not a discipline failure. It was an architectural
|
||
failure.** The CLAUDE.md rules in v3 forbade stubs, forbade printf,
|
||
forbade game-loading, forbade simulating outputs of native systems —
|
||
and every rule was violated because the architecture made compliance
|
||
impossible. You cannot run an interpreter+HLE system without making
|
||
the integration choices that *look* like stubs.
|
||
|
||
---
|
||
|
||
## Mistakes we are not repeating in v4
|
||
|
||
The following are concrete things v3 (and v1, v2) did that v4 will
|
||
architecturally prevent:
|
||
|
||
### M1. The recompiler ignored the BIOS
|
||
|
||
v3's recompiler was hard-coded to PS1-EXE input. It could not have
|
||
ingested SCPH1001.BIN even if a session had tried. The infrastructure
|
||
(basic block analysis, control flow, function discovery, code
|
||
generation) all worked on raw MIPS bytes — but the entry point gated
|
||
everything behind `PS1ExeParser::parse_file`, which rejected the BIOS
|
||
ROM.
|
||
|
||
**v4 fix:** the recompiler ships with a new entry point `main_bios.cpp`
|
||
that ingests a flat 512 KB ROM at `0xBFC00000`. The PS1-EXE entry
|
||
point is preserved as `main_psx.cpp` for the eventual game phase, but
|
||
`main_bios.cpp` is the *primary* tool until Phase 5.
|
||
|
||
### M2. "Native" was redefined to mean "interpreted from ROM bytes"
|
||
|
||
v3's `bios_run_native_b0/c0` helpers read the BIOS dispatch table at
|
||
runtime, found the target function in RAM, and ran it through the
|
||
MIPS interpreter. Sessions started using the word "native" to mean
|
||
this — and over time, "make this function native" came to mean "stop
|
||
faking it in HLE C, instead let the interpreter run the real ROM
|
||
bytes". This is still emulation. It is not recompilation.
|
||
|
||
**v4 fix:** there is no interpreter in v4. The word "native" means
|
||
"a compiled C function emitted by the recompiler". If a function is
|
||
not yet recompiled, the program cannot run that function — it
|
||
fatally errors and you go fix the recompiler. There is no fallback.
|
||
|
||
### M3. HLE BIOS layer with selectively-faked outputs
|
||
|
||
v3's `bios.c` was 1808 lines of `case` statements over BIOS function
|
||
indices, each containing either (a) a C reimplementation of the BIOS
|
||
function from nocash specs, (b) a degenerate `cpu->v0 = 0; return;`
|
||
stub, (c) a delegation to the interpreter, or (d) a "fake the output"
|
||
hand-delivery of the kernel state the real BIOS would have produced.
|
||
Type (d) is the worst because it looks like progress.
|
||
|
||
**v4 fix:** there is no HLE BIOS layer. The recompiled SCPH1001.BIN C
|
||
output IS the BIOS. There is no second source of truth for `OpenEvent`
|
||
or `StartCard` or `alloc_kernel_memory`. The recompiler emits whatever
|
||
the BIOS ROM does, byte for byte semantically.
|
||
|
||
### M4. Kernel state was simulated in C alongside the interpreter's
|
||
copy
|
||
|
||
v3 had `events.c` (273 lines) and `threads.c` (149 lines) which
|
||
maintained C-side copies of the BIOS's EvCB and TCB tables. These
|
||
were supposed to mirror what the BIOS itself stored in RAM at the
|
||
addresses discovered during boot, but in practice the C copies and
|
||
the in-RAM copies drifted constantly because every HLE shim updated
|
||
one or the other but rarely both. v1's save/load failure had the
|
||
same root cause: the kernel data structures the game saw were not
|
||
the kernel data structures the BIOS thought existed.
|
||
|
||
**v4 fix:** the recompiled BIOS allocates and manages its own EvCB and
|
||
TCB tables in PS1 RAM, just like the real BIOS does. There is no
|
||
parallel C-side mirror. The hardware simulation reads/writes RAM at
|
||
those addresses; the recompiled BIOS code does too; they share state
|
||
through the actual RAM array.
|
||
|
||
### M5. Stub creep under schedule pressure
|
||
|
||
Every prior version started with "no stubs ever" in CLAUDE.md and
|
||
ended with hundreds of stubs. The reason: when a new BIOS path
|
||
crashes the interpreter, the easy fix is to add an HLE case that
|
||
returns a plausible value. The hard fix is to debug why the
|
||
interpreter crashed in real BIOS code. The cheap fix wins under
|
||
schedule pressure, every time, because it makes the immediate symptom
|
||
go away.
|
||
|
||
**v4 fix:** the recompiler emits or it doesn't. There is no
|
||
"intermediate" state where a function is partly recompiled. If the
|
||
recompiler can't emit a function, the build fails. There is no place
|
||
to put a stub.
|
||
|
||
### M6. 555 GB of debug exhaust
|
||
|
||
v3 violated its own no-printf rule and accumulated 555 GB of trace
|
||
log files because somebody left a tracing flag on. The rule was
|
||
correct. The rule was being ignored. The architecture made
|
||
fprintf-debugging *useful* (because the interpreter was the only
|
||
inspectable thing), so the rule lost.
|
||
|
||
**v4 fix:** v4 will not have an interpreter for the BIOS. The thing
|
||
to inspect at runtime is the recompiled C, which can be inspected with
|
||
a normal native debugger (gdb, MSVC) at full speed. There is no
|
||
"trace every interpreter step" because there is no interpreter. The
|
||
TCP debug server (Phase 2 onward) is the only structured runtime
|
||
inspection mechanism, and it streams over a socket — it does not write
|
||
files.
|
||
|
||
### M7. Project name was a lie
|
||
|
||
The project was called "PSXRecomp" but the BIOS execution path was a
|
||
runtime emulator. The name implied recompilation; the architecture
|
||
delivered emulation. This created a cognitive dissonance where every
|
||
session reasoned "we're a recomp project, so we should use the
|
||
recompiler" — but there was no path from the recompiler to the BIOS,
|
||
so the BIOS work always happened in the runner's HLE/interpreter
|
||
layer instead. The name pulled toward A; the code pulled toward
|
||
emulation.
|
||
|
||
**v4 fix:** name and architecture aligned. The recompiler is the
|
||
primary tool. The runner is a thin SDL+MMIO+debug-server shell that
|
||
hosts the recompiled output. If you find yourself writing more than
|
||
a few hundred lines of C in the runner, you are probably building the
|
||
wrong thing.
|
||
|
||
---
|
||
|
||
## Architecture A — what v4 actually is
|
||
|
||
### The pipeline
|
||
|
||
```
|
||
+--------------------+ +----------------+ +----------------------+
|
||
| bios/SCPH1001.BIN | ---> | recompiler | ---> | generated/ |
|
||
| (512 KB MIPS code) | | (MIPS -> C) | | SCPH1001_full.c |
|
||
+--------------------+ +----------------+ | SCPH1001_dispatch.c|
|
||
+----------+-----------+
|
||
|
|
||
v
|
||
+-----------------------+ +---------------+
|
||
| runtime/ | | C compiler |
|
||
| - SDL2 main loop +--->| (MSVC / gcc) |
|
||
| - memory map | +-------+-------+
|
||
| - hardware sims | |
|
||
| - debug server | v
|
||
+-----------------------+ +-------+-------+
|
||
| bios_runner |
|
||
| (native exe) |
|
||
+---------------+
|
||
```
|
||
|
||
### The recompiler's job
|
||
|
||
1. Load `SCPH1001.BIN` as a flat 512 KB byte array at base address
|
||
`0xBFC00000`.
|
||
2. Discover function entry points. Initial seed: the reset vector at
|
||
`0xBFC00000`, the exception vectors at `0xBFC00100`/`0xBFC00180`,
|
||
and the A0/B0/C0 dispatch sites at `0xA0`/`0xB0`/`0xC0` in RAM
|
||
(after the BIOS copies code into RAM during early init — see "self-
|
||
modifying boot code" below).
|
||
3. For each discovered function, run the existing v3-salvaged
|
||
`BasicBlockAnalyzer` and `ControlFlowAnalyzer` to find its blocks
|
||
and edges.
|
||
4. Translate each block into C using the existing
|
||
`CodeGenerator::translate_*` methods, **plus extensions** for COP0
|
||
kernel-mode instructions and COP2 (GTE) instructions the BIOS uses.
|
||
5. Emit a `SCPH1001_full.c` file containing one C function per BIOS
|
||
function, named by entry address (e.g., `func_0xBFC00000`).
|
||
6. Emit a `SCPH1001_dispatch.c` file containing a PC-to-function-pointer
|
||
table so indirect calls (`jr $t0`, `jalr $t1`) can resolve at
|
||
runtime.
|
||
|
||
### The runtime's job
|
||
|
||
1. Allocate and initialize PS1 memory (2 MB main RAM, 1 KB scratchpad,
|
||
512 KB BIOS ROM region populated from `bios/SCPH1001.BIN`).
|
||
2. Provide MMIO read/write entry points that the recompiled BIOS C
|
||
code calls into (e.g., `psx_mmio_read_word(0x1F801070)` for I_STAT).
|
||
3. Simulate the hardware peripherals the BIOS touches during boot:
|
||
- Interrupt controller (I_STAT, I_MASK)
|
||
- Timers (root counters 0-2)
|
||
- GPU command processor + software rasterizer (for the Sony logo)
|
||
- DMA channels (BIOS uses GPU DMA for the logo)
|
||
4. Drive an SDL2 window and pump events.
|
||
5. Expose a TCP debug server for inspection (no printf, ever).
|
||
6. **Call into the recompiled BIOS** by jumping to `func_0xBFC00000`
|
||
(the reset vector entry function) in the generated C. The BIOS
|
||
then runs to completion the same way it does on real hardware.
|
||
|
||
### What the runtime does NOT do
|
||
|
||
- It does not interpret MIPS bytes. There is no interpreter.
|
||
- It does not implement BIOS functions in C. There is no HLE.
|
||
- It does not maintain shadow kernel state. EvCB, TCB, kernel
|
||
workspace — all of those live in PS1 RAM, written by the recompiled
|
||
BIOS code, read by the recompiled BIOS code.
|
||
|
||
---
|
||
|
||
## What we salvaged from v3
|
||
|
||
The full file inventory of v3 is documented separately (we have it).
|
||
This section explains specifically what survived the fork.
|
||
|
||
### Salvaged: `recompiler/` (the entire subtree)
|
||
|
||
Copied verbatim from `psxrecomp-projects-v3/psxrecomp/recompiler/`,
|
||
build cache excluded. This is roughly 3,800 lines of C++ implementing:
|
||
|
||
| File | Purpose | Reused as-is? |
|
||
|---|---|---|
|
||
| `src/mips_decoder.cpp` | MIPS field extraction, register names, branch math | Yes |
|
||
| `src/basic_block.cpp` | Leader-finding, basic block construction | Yes |
|
||
| `src/control_flow.cpp` | Branch/jump identification, successor edges | Yes |
|
||
| `src/function_analysis.cpp` | Prologue/epilogue heuristics for function discovery | Yes (with one extension — see below) |
|
||
| `src/code_generator.cpp` | Per-instruction MIPS-to-C string emission | **Reused with extensions** for COP0 |
|
||
| `src/annotations.cpp` | TOML-based recompilation hints | Yes |
|
||
| `src/main_psx.cpp` | PS1-EXE entry point | Kept for Phase 5; **not used in Phase 1** |
|
||
| `src/ps1_exe_parser.cpp` | PS-X EXE header parser | Kept for Phase 5; not used in Phase 1 |
|
||
| `lib/rabbitizer/` | MIPS disassembler dependency | Yes |
|
||
| `lib/fmt/` | C++ string formatting | Yes |
|
||
| `lib/toml11/` | TOML parser for annotations | Yes |
|
||
|
||
### Salvaged: `bios/SCPH1001.BIN`
|
||
|
||
Copied from `psxrecomp-projects-v3/TombaRecomp/isos/SCPH1001.BIN`.
|
||
524288 bytes (512 KB), the standard SCPH1001 BIOS image. This is the
|
||
recompilation target.
|
||
|
||
### Salvaged: `duckstation/`
|
||
|
||
**Moved** (not copied) from `F:/Projects/psxrecomp-projects/duckstation/`.
|
||
~2.1 GB. This is a modified DuckStation build with TCP debug server
|
||
extensions added during v3 (debug protocol on port 4371). It is the
|
||
runtime oracle for verifying that the recompiled BIOS produces the
|
||
same hardware register sequences, the same VRAM contents, and the
|
||
same kernel state as a real PS1 BIOS execution does.
|
||
|
||
### NOT salvaged from v3
|
||
|
||
The `psxrecomp-projects-v3/psxrecomp/runner/` subtree, **23 source files
|
||
and 12,852 lines of C/C++**, was entirely excluded from the fork.
|
||
Specifically:
|
||
|
||
| v3 file | Why excluded |
|
||
|---|---|
|
||
| `runner/src/interpreter.c` | The MIPS interpreter. v4 has no interpreter. Discarded. |
|
||
| `runner/src/bios.c` | 1808 lines of HLE shims. The disease. Discarded. |
|
||
| `runner/src/events.c` | Shadow EvCB management. The recompiled BIOS will manage its own. |
|
||
| `runner/src/threads.c` | Shadow TCB management. Same reason. |
|
||
| `runner/src/bios_trace.c` | Per-call HLE trace logger. Not meaningful in v4. |
|
||
| `runner/src/func_logger.c` | Runtime function discovery from interpreter execution. Useless without interpreter. |
|
||
| `runner/src/main_runner.cpp` | SDL window + interpreter driver. Will be rewritten as a much smaller `main.cpp` in Phase 2. |
|
||
| `runner/src/ps1_exe_parser.cpp` | PS1-EXE loader. Game-EXE thing, defer to Phase 5. |
|
||
|
||
### Eligible for salvage in Phase 2 (one file at a time, audited)
|
||
|
||
These are hardware simulation files — they implement PS1 peripherals,
|
||
not BIOS functions. They will be needed by the runtime to service the
|
||
MMIO calls the recompiled BIOS makes. They will be **copied one at a
|
||
time, audited, and only the parts that are pure hardware sim are
|
||
kept.** Anything that touches `events_get_base()`, `bios_*`,
|
||
`interpreter_*`, kernel state mirrors, or "if HLE then else" branching
|
||
gets stripped out before the file enters v4.
|
||
|
||
| v3 file | What it does | Phase needed |
|
||
|---|---|---|
|
||
| `runner/src/memory.c` | RAM/ROM/scratchpad/MMIO routing | Phase 2 |
|
||
| `runner/src/interrupts.c` | I_STAT/I_MASK + COP0 IRQ delivery | Phase 2 |
|
||
| `runner/src/timers.c` | Root counters 0-2 | Phase 2 |
|
||
| `runner/src/dma.c` | 7 DMA channels | Phase 2 |
|
||
| `runner/src/gpu.c` | GP0/GP1 command processor | Phase 3 (Sony logo) |
|
||
| `runner/src/gpu_sw_renderer.c` | Software rasterizer | Phase 3 |
|
||
| `runner/src/debug_server.c` | TCP debug server | Phase 2 |
|
||
| `runner/src/crc32.c` | Helper | Phase 2 |
|
||
| `runner/src/sio.c` | SIO0 controller (pad + memcard protocol) | Phase 4 (BIOS shell input) |
|
||
| `runner/src/memcard.c` | Memory card image format | Phase 6 (saves) |
|
||
| `runner/src/cdrom.c` | CD-ROM controller | Phase 5 (game loading) |
|
||
| `runner/src/iso_reader.cpp` | ISO9660 reader | Phase 5 |
|
||
| `runner/src/gte.cpp` | GTE math (well-tested) | Phase 5 (3D rendering) |
|
||
| `runner/src/spu.c` | SPU register shadow (no audio) | Phase 5+ |
|
||
|
||
---
|
||
|
||
## Hard engineering problems v4 must solve
|
||
|
||
These are the things we know are non-trivial. We want outside review on
|
||
how to approach them.
|
||
|
||
### HP1. COP0 kernel-mode instructions in `code_generator.cpp`
|
||
|
||
The PS1 BIOS uses COP0 instructions that v3's `code_generator.cpp`
|
||
does not translate (verified by reading the file). Specifically:
|
||
|
||
- `mtc0 rt, rd` / `mfc0 rt, rd` — move to/from COP0 registers
|
||
- `rfe` — return from exception (restores SR's IE/KU stack)
|
||
- COP0 register reads/writes for SR (12), Cause (13), EPC (14),
|
||
BadVaddr (8)
|
||
|
||
These are used by the BIOS exception handler at `0x80000080` and by
|
||
the boot init code (cache isolation, IRQ enable/disable). v3 didn't
|
||
need them in the recompiler because v3 only recompiled game EXEs,
|
||
which run in user mode and never touch COP0.
|
||
|
||
**Question for review:** what's the cleanest way to model COP0 in the
|
||
generated C? Options we see:
|
||
1. A `cop0` struct in `CPUState` with explicit fields (`sr`, `cause`,
|
||
`epc`, etc.) and translation methods that emit
|
||
`cpu->cop0.sr = ...;`
|
||
2. A 32-entry `cop0_regs[32]` array with helpers, mirroring how the
|
||
GPRs work
|
||
3. A function-call-per-instruction approach (`cpu_mtc0(cpu, rt, rd)`)
|
||
so the runtime can intercept SR writes for IRQ delivery
|
||
|
||
Option 3 feels cleanest because IRQ delivery needs a hook on SR
|
||
writes anyway, but it adds a function call per COP0 access.
|
||
|
||
### HP2. The BIOS does self-modifying boot code
|
||
|
||
During the first ~10000 instructions after the reset vector, the PS1
|
||
BIOS does the following:
|
||
|
||
1. Copies a chunk of code from BIOS ROM (`0xBFC0xxxx`) into RAM
|
||
(`0x80000000` + offset) — typically the kernel/exception/vector
|
||
support code.
|
||
2. Patches A0/B0/C0 dispatch addresses at fixed RAM addresses `0xA0`,
|
||
`0xB0`, `0xC0` to point at the in-RAM code.
|
||
3. Builds the A0/B0/C0 jump tables in RAM at addresses determined at
|
||
runtime by where the kernel was loaded.
|
||
|
||
This is a problem for static recompilation because:
|
||
|
||
- The "in-RAM" version of the kernel functions is the same MIPS
|
||
bytes as the "in-ROM" version, but at a different address. A naive
|
||
recompiler would emit two copies under different function names.
|
||
- The A0/B0/C0 jump tables are built at runtime, so the targets of
|
||
`jal A0`, `jal B0`, `jal C0` (the standard BIOS function call
|
||
pattern) are not statically resolvable from the ROM bytes alone.
|
||
You have to either (a) recognize the table-build pattern and
|
||
statically resolve the targets, or (b) emit indirect-call code that
|
||
reads the table at runtime.
|
||
|
||
**Question for review:** how do other BIOS-like static recomp projects
|
||
handle (b)? In particular, is there a clean way to express
|
||
"statically known indirect dispatch through a table built at runtime"
|
||
in generated C? We are inclined to:
|
||
|
||
- Emit the A0/B0/C0 dispatch sites (`0xA0`, `0xB0`, `0xC0`) as
|
||
indirect-call functions that look up a global function pointer
|
||
table, e.g. `dispatch_a0[func_index]`.
|
||
- Recognize the table-build code in the BIOS init and statically
|
||
resolve which table entries point at which recompiled functions.
|
||
- Populate the dispatch tables from generated C startup code, before
|
||
the recompiled BIOS reset vector runs.
|
||
|
||
This avoids running the table-build code at all. The tradeoff is that
|
||
the recompiler needs to *understand* the table-build pattern, which
|
||
makes it BIOS-specific (vs. fully generic MIPS-to-C). Is there a more
|
||
generic option?
|
||
|
||
### HP3. The RAM-vs-ROM duplicate code problem
|
||
|
||
Related to HP2: the BIOS copies code from ROM to RAM and then runs the
|
||
RAM version. The instructions are byte-identical but addresses
|
||
differ. What does the recompiler do?
|
||
|
||
Options:
|
||
1. **Emit only the ROM version**, and statically resolve the RAM-side
|
||
addresses to the same functions. (Requires the recompiler to know
|
||
the ROM->RAM copy mapping.)
|
||
2. **Emit both versions** as separate functions with different names,
|
||
and rely on the BIOS init code to "select" the right version at
|
||
runtime. (Wastes code; runtime selection still needs an indirect
|
||
call.)
|
||
3. **Emit only the RAM version** and recompile from a snapshot of
|
||
RAM after the BIOS init copy. (Requires running the BIOS once to
|
||
capture the RAM state, then recompiling against that — circular.)
|
||
|
||
We are inclined to option 1, with the recompiler understanding the
|
||
specific ROM->RAM copy that the BIOS does early in init (it's a
|
||
well-known pattern and the source/dest/length are constants in the
|
||
BIOS code).
|
||
|
||
### HP4. Exception vector at `0x80000080`
|
||
|
||
The PS1 general exception vector is at `0x80000080` in RAM. After the
|
||
BIOS init copies the exception handler into place, every interrupt
|
||
and every syscall jumps to `0x80000080`. The handler at that address
|
||
saves COP0 state, looks up the cause, dispatches to a chain handler
|
||
list (separate per IRQ source), and then `rfe`s back.
|
||
|
||
For v4 this means:
|
||
|
||
- The recompiler must emit a function for `0x80000080` whose entry is
|
||
callable from runtime IRQ delivery.
|
||
- The runtime must call that function whenever an IRQ fires.
|
||
- The recompiled exception handler will then dispatch to the chain
|
||
handlers (which are themselves recompiled BIOS functions registered
|
||
via the BIOS chain API at boot).
|
||
- Save/restore of CPU state across the exception is handled by the
|
||
generated C (via `mtc0`/`mfc0`/`rfe` translations).
|
||
|
||
**Question for review:** is there an idiomatic way to model
|
||
"interrupt arrives during normal C execution, transfer to exception
|
||
handler function, return when handler is done" in C? We are thinking
|
||
the runtime sets a `cpu->irq_pending` flag, the generated C checks
|
||
that flag at function entry / loop back-edges (similar to how N64Recomp
|
||
handles RSP signals), and on transition it directly calls the exception
|
||
function. This avoids needing real signals or threads.
|
||
|
||
### HP5. Cache isolation for the kernel data area
|
||
|
||
The PS1 doesn't have a real MMU. It has a quirk: when COP0 SR bit 16
|
||
("Isc" — isolate cache) is set, all loads/stores are routed to the
|
||
D-cache instead of memory. The BIOS uses this to repurpose the D-cache
|
||
as a 1KB scratchpad for kernel data (PCB pointer, TCB pointer, etc.).
|
||
The "scratchpad" lives at `0x9F800000` in cached KSEG0 (or
|
||
`0xBF800000` uncached).
|
||
|
||
For v4 this means:
|
||
|
||
- `mtc0` to SR with bit 16 set must change a runtime "cache isolated"
|
||
flag that affects subsequent load/store routing.
|
||
- Loads/stores while isolated go to a 1 KB scratchpad buffer, not to
|
||
main RAM.
|
||
- The kernel data layout in that scratchpad is what `events.c` and
|
||
`threads.c` were trying to mirror in v3 — and getting wrong.
|
||
|
||
In v4, the recompiled BIOS will write to the real scratchpad through
|
||
the normal load/store path, the runtime simulates cache isolation
|
||
correctly, and there is no shadow C-side state to drift.
|
||
|
||
### HP6. GTE (COP2) instructions
|
||
|
||
The BIOS itself does not use the GTE — that's a game-side concern. So
|
||
GTE translation is **not** required for Phases 1-4. It is required
|
||
for Phase 5+ (game EXE recompilation), at which point the
|
||
recompiler's `code_generator.cpp` gains COP2 instruction handling.
|
||
v3 had a working `gte.cpp` (876 lines, well-tested) that can be
|
||
reused at that time.
|
||
|
||
---
|
||
|
||
## Phase plan
|
||
|
||
## Phase 1 — Controlled Recompiler Bring-up
|
||
|
||
Phase 1 is the *only* phase in v4 that owns the recompiler-side proof
|
||
of correctness. It exists to make the recompiler trustworthy on the
|
||
BIOS ROM **before** anything else (runtime, MMIO, hardware sims) is
|
||
allowed to be built. Phase 1 is split into strict sub-phases. Each
|
||
sub-phase has REQUIRED ARTIFACTS, FAILURE CONDITIONS, and ACCEPTANCE
|
||
CRITERIA. A sub-phase is not "done" until every acceptance criterion
|
||
holds and every required artifact exists on disk.
|
||
|
||
### Phase 1 hard constraints (apply to ALL sub-phases below)
|
||
|
||
- **No full BIOS walk before Phase 1e is complete and merged.** Walking
|
||
the entire ROM before relocation is solved will silently emit
|
||
duplicate functions for ROM and RAM aliases, and that is exactly
|
||
the v3 failure mode resurrected at the codegen layer. Bounded walks
|
||
only.
|
||
- **`strict_translator` is the only valid translation path.** It
|
||
fails loudly on unknown opcodes, unknown control flow, or any
|
||
ambiguity. The salvaged v3 `code_generator.cpp` is **not trusted**
|
||
and must not be invoked from any new code path. If a sub-phase
|
||
needs an instruction that the strict translator does not yet
|
||
handle, the fix is to extend the strict translator with a real
|
||
implementation — never to fall back to the salvaged path, never
|
||
to add a default case, never to "absorb" the salvaged path's
|
||
behavior into the strict path opaquely. The strict translator
|
||
absorbs salvaged code only by transcribing each opcode explicitly.
|
||
- **No silent opcode skipping. No fallthrough cases. No "default:
|
||
emit comment".** An unsupported opcode is a build failure with the
|
||
address and the opcode recorded in `generated/unsupported_ops.json`.
|
||
- **No partial instruction implementations.** A handler that
|
||
implements 80% of an instruction's semantics is not 80% done; it
|
||
is 0% done and a stub.
|
||
- **No hand-editing of files under `generated/`.** Generated code is
|
||
a build artifact. Fix the recompiler.
|
||
- **Every sub-phase must produce its manifest before it is considered
|
||
complete.** Code without a manifest is not progress; it is a claim
|
||
without proof.
|
||
|
||
---
|
||
|
||
### Phase 1a — Boot Slice (COMPLETE — 2026-04-06)
|
||
|
||
**Status:** COMPLETE. Do not redo. Do not "improve". Do not extend its
|
||
scope. Phase 1a is frozen as a reference baseline; later sub-phases
|
||
depend on its outputs being stable.
|
||
|
||
**What was delivered:**
|
||
|
||
- `recompiler/src/strict_translator.{h,cpp}` — the only sanctioned
|
||
MIPS-to-C path in v4.
|
||
- `recompiler/src/main_bios.cpp` — BIOS flat-ROM ingestion entry
|
||
point. Loads `bios/SCPH1001.BIN` as 524288 bytes at virtual base
|
||
`0xBFC00000`.
|
||
- `generated/boot_slice.c` — 30 instructions, region
|
||
`0xBFC00000..0xBFC00074`, terminating at `j 0xBFC00150`.
|
||
- `generated/boot_slice_manifest.json` — function entry, instruction
|
||
count, end address, termination reason.
|
||
- `generated/unsupported_ops.json` — empty (no unsupported instructions
|
||
in the slice).
|
||
- `generated/cpu_state.h` — compile-only `CPUState` definition. **It
|
||
must remain compile-only.** No runtime, no MMIO, no peripherals
|
||
may be added to this file. It exists so generated C compiles in
|
||
isolation; that is the entire job.
|
||
|
||
**Decisions locked by 1a (do not relitigate):**
|
||
|
||
- `RFE` substitutes for the nonexistent "ERET" in the strict
|
||
translator's terminator list. R3000A has no ERET. Approved
|
||
2026-04-06.
|
||
- The strict translator owns its own opcode dispatch and will never
|
||
delegate to the salvaged `code_generator.cpp`.
|
||
|
||
**Acceptance (already met):** `boot_slice.c` compiles standalone with
|
||
`-c`, manifest is complete, `unsupported_ops.json` is empty, every
|
||
instruction in the slice is accounted for in the manifest, no TODO
|
||
or FIXME exists in the generated output.
|
||
|
||
---
|
||
|
||
### Phase 1b — Instruction Coverage (NEXT)
|
||
|
||
**Goal:** every MIPS R3000A instruction the BIOS uses inside any region
|
||
the recompiler will ever walk has a real, complete implementation in
|
||
`strict_translator`. No instruction the BIOS executes is allowed to
|
||
exist in the "unknown" set when Phase 1b ends.
|
||
|
||
**Method (bounded — no full walk yet):**
|
||
|
||
1. Use Ghidra (`SCPH1001.BIN` at `0xBFC00000`) as the static oracle
|
||
for the instruction inventory. Enumerate every distinct opcode
|
||
that appears in the disassembly of the ROM. This is a static
|
||
read-only query against the Ghidra MCP — it does not require
|
||
walking the BIOS in v4's recompiler.
|
||
2. For each opcode in the inventory, check whether
|
||
`strict_translator` already implements it. The check is by
|
||
reading the dispatch table in `strict_translator.cpp` — not by
|
||
trial-emission and "looks fine".
|
||
3. For each missing opcode, add a real implementation. COP0
|
||
instructions (`mtc0`, `mfc0`, `rfe`, and the SR/Cause/EPC/BadVaddr
|
||
register accesses) are required because the BIOS exception
|
||
handler uses them. COP2/GTE is **not** required in Phase 1 — it
|
||
is a Phase 5 concern (game side).
|
||
4. After each opcode addition, regenerate `boot_slice.c` and confirm
|
||
it still produces the byte-identical manifest from Phase 1a. Phase
|
||
1a is the regression baseline.
|
||
|
||
**Required artifacts:**
|
||
|
||
- `generated/instruction_inventory.json` — every distinct opcode
|
||
observed in `SCPH1001.BIN` according to Ghidra, with the count of
|
||
occurrences and at least one example address per opcode.
|
||
- `generated/instruction_coverage.json` — for each opcode in the
|
||
inventory: `implemented` (true/false), the source location of the
|
||
handler in `strict_translator.cpp` if implemented, and the
|
||
rationale ("required for BIOS execution path", "GTE — deferred to
|
||
Phase 5", etc.) if intentionally deferred.
|
||
- An updated `strict_translator.cpp` whose dispatch covers every
|
||
opcode marked `implemented: true` in the coverage file.
|
||
- `generated/boot_slice.c` and `generated/boot_slice_manifest.json`
|
||
must regenerate identically to the Phase 1a baseline (regression
|
||
proof).
|
||
|
||
**Failure conditions (any one of these means Phase 1b is NOT done):**
|
||
|
||
- The instruction inventory is missing any opcode that Ghidra
|
||
reports in the ROM.
|
||
- Any opcode marked `implemented: true` in the coverage file lacks a
|
||
real handler in `strict_translator.cpp` (a `default:` branch, a
|
||
fallthrough, a comment-only emit, or a "TODO" all count as no
|
||
handler).
|
||
- Any opcode the BIOS uses is silently deferred to Phase 5 without
|
||
an explicit, justified entry in the coverage file.
|
||
- The Phase 1a regression baseline drifts.
|
||
- The salvaged `code_generator.cpp` is invoked from any new code
|
||
path.
|
||
- `unsupported_ops.json` is not empty after a clean rebuild of the
|
||
Phase 1a slice.
|
||
|
||
**Acceptance criteria (ALL must hold):**
|
||
|
||
- `instruction_inventory.json` exists and matches the Ghidra opcode
|
||
inventory.
|
||
- `instruction_coverage.json` exists and lists every inventory opcode
|
||
with an implemented or explicitly-deferred status.
|
||
- Every BIOS-required opcode is implemented in `strict_translator`.
|
||
- Phase 1a `boot_slice.c` regenerates identically.
|
||
- `unsupported_ops.json` is empty.
|
||
|
||
---
|
||
|
||
### Phase 1c — Function Discovery Pipeline
|
||
|
||
**Goal:** a deterministic, artifact-producing function discovery
|
||
pipeline that, given a *bounded* set of seed addresses, walks each
|
||
function exactly once, records every basic block, and emits a manifest
|
||
of every function it discovered along with how it was discovered and
|
||
how it terminated. This is still a bounded walk — Phase 1c does NOT
|
||
walk the entire ROM. It walks the seeds the user gives it.
|
||
|
||
**Method:**
|
||
|
||
1. Define the seed set as an explicit input file
|
||
(`recompiler/seeds/phase1c_seeds.json`). The initial seeds for
|
||
bring-up are: the reset vector (`0xBFC00000`) and the BEV=1
|
||
general exception vector (`0xBFC00180`). No other seeds are
|
||
added until Phase 1d/1e prove the relocation story.
|
||
|
||
**Empirical correction (2026-04-06):** an earlier draft of this
|
||
plan listed `0xBFC00100` as a third seed under the label "ROM-side
|
||
exception vector". That assertion was wrong and is removed.
|
||
`0xBFC00100` is the R3000 UTLB-miss vector when BEV=1, but PS1's
|
||
R3000A has no TLB, so the address is never executed in normal
|
||
operation. Phase 1b's instruction-inventory walker was run with
|
||
`0xBFC00100` as a synthetic seed to test the assertion; the walker
|
||
immediately bled into the bytes at `0xBFC00108`, which Ghidra's
|
||
`list_data` already classifies as the ASCII string `"Sony Computer
|
||
Entertainment Inc."`. The walker decoded those string bytes as
|
||
garbage opcodes (top-level opcodes `0x18`, `0x19`, `0x1B`, `0x1D`,
|
||
`0x1E` — all unallocated on R3000A). The proof is in
|
||
`generated/instruction_inventory.json` history and in the seed-list
|
||
selection inside `tools/build_instruction_inventory.py`. The
|
||
canonical Phase 1c seed list is therefore exactly two ROM addresses:
|
||
`0xBFC00000` and `0xBFC00180`. `0xBFC00100` must NOT be re-added
|
||
without new evidence that some BIOS path actually executes it.
|
||
2. For each seed, run the strict basic-block / control-flow analysis
|
||
on the ROM bytes. Record every basic block, every edge, every
|
||
terminator, every callee discovered via a direct `jal` (direct
|
||
`jal` only — indirect dispatch is Phase 1d).
|
||
3. Functions discovered via direct `jal` from a seed are themselves
|
||
added to the discovery worklist. Each function is walked once
|
||
and only once, identified by its normalized address (see Phase
|
||
1e for the normalization rule).
|
||
4. The walk terminates a function on the first of: unconditional
|
||
branch out of function, `jr $ra`, `rfe`, or any indirect jump
|
||
(the indirect jump is recorded for Phase 1d but does not get
|
||
resolved here).
|
||
|
||
**Required artifacts:**
|
||
|
||
- `recompiler/seeds/phase1c_seeds.json` — the explicit seed list
|
||
used for the run.
|
||
- `generated/function_manifest.json` — for every discovered function:
|
||
entry address, normalized address, instruction count, end address,
|
||
termination reason (`jr_ra`, `rfe`, `j_out_of_function`,
|
||
`indirect_jump_recorded_for_1d`, ...), the seed lineage that
|
||
reached it (e.g. "reset_vector → direct_jal at 0xBFC00050"), and a
|
||
list of basic-block leader addresses.
|
||
- `generated/function_edges.json` — every direct call edge
|
||
(`caller_addr`, `caller_pc`, `callee_addr`).
|
||
- `generated/discovery_run.log.json` — a structured (NOT free-text)
|
||
log of the discovery run: counters, opcode histogram, time spent
|
||
per function. **Structured JSON only — never `fprintf`.**
|
||
|
||
**Failure conditions:**
|
||
|
||
- The walker visits any function more than once under the same
|
||
normalized address.
|
||
- A function is recorded without a termination reason.
|
||
- Any seed is implicit. Every seed must come from
|
||
`phase1c_seeds.json`.
|
||
- The walker silently drops a function that hits an unsupported
|
||
opcode. (It must hard-fail and the opcode must appear in
|
||
`unsupported_ops.json` — that is what Phase 1b exists to prevent.)
|
||
- The walker continues past an indirect jump under the assumption
|
||
that the next linear instruction is reachable. Indirect jumps
|
||
terminate the walk for this function in Phase 1c.
|
||
|
||
**Acceptance criteria:**
|
||
|
||
- Running the recompiler against the Phase 1c seed file produces
|
||
every required artifact above.
|
||
- `function_manifest.json` contains the reset vector and both ROM
|
||
exception vectors with full metadata.
|
||
- Every function in the manifest has an explicit termination
|
||
reason from the allowed set.
|
||
- No function appears twice under the same normalized address.
|
||
- `unsupported_ops.json` remains empty.
|
||
|
||
---
|
||
|
||
### Phase 1d — Indirect Control Flow Recording
|
||
|
||
**Goal:** every indirect jump (`jr` other than `jr $ra`, every `jalr`)
|
||
encountered during the Phase 1c walk is recorded with full context, so
|
||
that Phase 1e and later phases have the data needed to resolve them.
|
||
Phase 1d does NOT resolve indirect jumps. It only records them. This
|
||
is deliberate — resolving them before relocation is solved leads to
|
||
the v3 failure mode.
|
||
|
||
**Method:**
|
||
|
||
1. Extend the Phase 1c walker to capture, at every indirect jump
|
||
site: the source PC, the source function, the source basic block,
|
||
the register the indirect jump uses, the last ~16 instructions
|
||
leading up to the jump (so a future resolver can run lightweight
|
||
value tracking on the GPR), and the calling-convention guess
|
||
(`computed_call`, `computed_tail_call`, `dispatch_stub`,
|
||
`unknown`).
|
||
2. Cross-reference every recorded site against Ghidra's analysis of
|
||
the same address (`mcp__ghidra__get_function_info`,
|
||
`mcp__ghidra__xrefs`) and store Ghidra's view alongside the
|
||
recompiler's view. Disagreement between Ghidra and the recompiler
|
||
is itself a finding that must be surfaced — it is never silently
|
||
reconciled.
|
||
3. Classify each site by the *kind* of resolution it will eventually
|
||
need: A0/B0/C0 vector dispatch through a runtime-built table,
|
||
`jr $ra` return, computed jump within a jump table, or unknown.
|
||
"Unknown" is a valid and expected value at this stage.
|
||
|
||
**Required artifacts:**
|
||
|
||
- `generated/indirect_jumps.json` — one entry per site with every
|
||
field above.
|
||
- `generated/indirect_jump_classes.json` — counts per classification
|
||
bucket and the list of sites in each bucket.
|
||
- `generated/indirect_jump_ghidra_xref.json` — Ghidra's view of each
|
||
site (function containing it, Ghidra's resolved targets if any,
|
||
Ghidra's note on the dispatch shape).
|
||
|
||
**Failure conditions:**
|
||
|
||
- A `jr` or `jalr` is encountered during the walk and is not
|
||
recorded.
|
||
- A site is recorded without its source function context or its
|
||
preceding-instruction window.
|
||
- A site is "resolved" by Phase 1d. Resolution belongs to a later
|
||
phase. Phase 1d guesses nothing.
|
||
- An indirect jump is silently classified as "return" without a
|
||
proof that the register is `$ra` and the function epilogue
|
||
matches the standard pattern.
|
||
|
||
**Acceptance criteria:**
|
||
|
||
- Every indirect jump in every Phase 1c-discovered function is in
|
||
`indirect_jumps.json`.
|
||
- Every entry has a classification (including `unknown`).
|
||
- Ghidra cross-reference exists for every entry.
|
||
- `unsupported_ops.json` is still empty.
|
||
|
||
---
|
||
|
||
### Phase 1e — Relocation Planning (HARD GATE)
|
||
|
||
**Goal:** the ROM→RAM aliasing problem is solved on paper and proven
|
||
with artifacts before any code is allowed to walk the full BIOS. After
|
||
Phase 1e ends, the recompiler treats ROM and RAM addresses that map
|
||
to the same logical instructions as the same logical function — and
|
||
that fact is provable, not assumed.
|
||
|
||
**Method:**
|
||
|
||
1. Identify, in `SCPH1001.BIN`, every memcpy/move loop that the BIOS
|
||
executes during early init to copy code from `0xBFCxxxxx` into
|
||
`0x800xxxxx`. Use Ghidra for the static side and DuckStation's
|
||
memory trace for the runtime side. **Both sources must agree.**
|
||
2. For each identified copy: record source ROM range, destination
|
||
RAM range, length, the function in the BIOS that performs the
|
||
copy, and the proof artifacts (Ghidra screenshot/JSON of the
|
||
copy loop, DuckStation memory trace excerpt).
|
||
3. Define the address normalization rule (the working draft is
|
||
`normalized = address & 0x1FFFFFFF`, matching the KSEG/KUSEG
|
||
stripping convention, but it must be checked against the actual
|
||
ROM/RAM pairs found above and either confirmed or replaced).
|
||
4. Emit `generated/address_aliases.json` containing every proven
|
||
`BFCxxxxx ↔ 800xxxxx` pair, and only those. No alias may be
|
||
added without an entry in the proof artifact set.
|
||
5. Teach `strict_translator` and the discovery walker to use the
|
||
normalized address as the function identity. Re-run Phase 1c
|
||
discovery and confirm no function is emitted twice.
|
||
|
||
**Required artifacts:**
|
||
|
||
- `generated/relocation_proofs/` — a directory with one
|
||
subdirectory per proven copy operation, each containing:
|
||
- the Ghidra MCP output that identified the copy loop
|
||
- the DuckStation memory-trace excerpt that confirmed it at
|
||
runtime
|
||
- a `proof.json` summarizing the source range, destination range,
|
||
length, and the BIOS function that performs the copy
|
||
- `generated/address_aliases.json` — final alias table, generated
|
||
*only* from proofs in `relocation_proofs/`
|
||
- `generated/normalization_rule.md` — the chosen normalization
|
||
formula and the evidence that it matches every proven alias
|
||
- A regenerated `generated/function_manifest.json` (from a re-run
|
||
of Phase 1c with normalization enabled) showing zero duplicate
|
||
function bodies under normalized addresses
|
||
|
||
**Failure conditions:**
|
||
|
||
- An alias appears in `address_aliases.json` without a corresponding
|
||
proof directory under `relocation_proofs/`.
|
||
- Ghidra and DuckStation disagree on a copy operation and the
|
||
disagreement is reconciled by guessing.
|
||
- The normalization rule is asserted without the evidence file.
|
||
- Any function body is emitted twice under the same normalized
|
||
address.
|
||
- "Looks equivalent" appears in any artifact.
|
||
|
||
**Acceptance criteria (ALL must hold):**
|
||
|
||
- Every copy the BIOS performs from ROM into RAM during init is
|
||
documented in `relocation_proofs/` with both Ghidra and DuckStation
|
||
evidence.
|
||
- `address_aliases.json` is generated mechanically from those proofs
|
||
and contains nothing else.
|
||
- `normalization_rule.md` exists and is consistent with every alias.
|
||
- A re-run of Phase 1c under the new normalization rule produces no
|
||
duplicate functions.
|
||
- `unsupported_ops.json` is still empty.
|
||
|
||
---
|
||
|
||
### Phase 1 exit gate (do not skip)
|
||
|
||
Full BIOS recompilation is **NOT** allowed before *all* of the
|
||
following are true on disk:
|
||
|
||
- Phase 1a baseline regenerates identically.
|
||
- `instruction_coverage.json` shows every BIOS-used opcode
|
||
implemented.
|
||
- `function_manifest.json` exists from a normalized Phase 1c re-run.
|
||
- `indirect_jumps.json` exists and covers every indirect site.
|
||
- `address_aliases.json` exists and is backed by `relocation_proofs/`.
|
||
- `unsupported_ops.json` is empty.
|
||
|
||
If any of these is missing or stale, the next step is to fix it —
|
||
**not** to walk the rest of the ROM "to see what happens".
|
||
|
||
---
|
||
|
||
## BIOS Relocation Is a Hard Gate
|
||
|
||
This is a blocking requirement, not a suggestion. It is restated here
|
||
in its own section because every prior version of this project failed
|
||
to take it seriously.
|
||
|
||
- **ROM↔RAM aliasing must be solved before full BIOS recompilation.**
|
||
The PS1 BIOS copies code from `0xBFCxxxxx` to `0x800xxxxx` during
|
||
init and runs the RAM copy. A recompiler that does not understand
|
||
this will silently emit two C functions for the same logical BIOS
|
||
routine, under different names, with different identities, and
|
||
every dispatch table built later will be wrong in a way that is
|
||
almost impossible to debug.
|
||
- **Duplicate functions are forbidden.** A function is identified by
|
||
its *normalized* address. If the recompiler ever emits two function
|
||
bodies for the same normalized address, the build fails.
|
||
- **Alias mapping must be proven, not assumed.** Every entry in
|
||
`generated/address_aliases.json` must trace back to a proof
|
||
artifact under `generated/relocation_proofs/` containing both a
|
||
Ghidra static identification of the copy loop and a DuckStation
|
||
runtime memory trace confirming the copy at execution time. No
|
||
proof, no alias.
|
||
- **The recompiler may not walk the full BIOS until Phase 1e is
|
||
complete.** Bounded walks are allowed before 1e (single seeds,
|
||
small slices). A full walk before 1e will re-import the v3
|
||
architectural disease at the codegen layer.
|
||
- **"Seems equivalent" is not proof.** The normalization rule must be
|
||
written down in `generated/normalization_rule.md`, must be
|
||
consistent with every proven alias, and must be derived from
|
||
evidence — never asserted because it "looks like" the standard
|
||
KSEG masking.
|
||
|
||
If you find yourself wanting to walk the full BIOS "just to see what
|
||
breaks" before Phase 1e is done, **stop**. That is exactly the move
|
||
that broke v3.
|
||
|
||
---
|
||
|
||
## Proof-Driven Development
|
||
|
||
Phase 1 (and every later phase) operates under one rule: **no claim
|
||
without an artifact.**
|
||
|
||
- **All discoveries produce artifacts.** Every function found, every
|
||
edge recorded, every indirect jump observed, every relocation
|
||
identified produces a JSON file under `generated/`. The artifact
|
||
is the discovery. The artifact is what gets reviewed. A claim made
|
||
in chat that does not have a corresponding file on disk did not
|
||
happen.
|
||
- **No claim is valid without output.** "I implemented `mtc0`" is
|
||
not a claim that can be verified. "`strict_translator.cpp:412`
|
||
implements `mtc0` and `instruction_coverage.json` shows it
|
||
`implemented: true` and the Phase 1a slice still regenerates
|
||
identically" is.
|
||
- **No "probably correct".** The forbidden language is the same as
|
||
in the existing rules: "probably", "should work", "looks correct",
|
||
"seems like", "matches expectation". Replace with proof or replace
|
||
with the explicit string "unknown".
|
||
- **Unknown is acceptable. Guessing is not.** When a fact is not
|
||
yet proven, the artifact records it as `unknown` with the reason
|
||
it is unknown and what would be needed to resolve it. The artifact
|
||
never lies about confidence.
|
||
- **Structured artifacts only.** JSON, not free-text. No log files.
|
||
No `fprintf`. The artifact must be machine-readable so future
|
||
sessions can diff it against current state and detect drift.
|
||
- **Artifacts are versioned with the code.** When a sub-phase is
|
||
complete, its artifacts are part of the deliverable. Re-running
|
||
the recompiler must regenerate them deterministically; if they
|
||
drift from the committed copy without a code change, that itself
|
||
is a finding.
|
||
|
||
The reason this section exists: every prior version of the project
|
||
had sessions in which Claude reported a result, the user believed it,
|
||
and the result turned out to be wishful synthesis with no underlying
|
||
artifact. v4 forbids that pattern by requiring the artifact to exist
|
||
*before* the result can be claimed.
|
||
### Phase 2 — Minimal runtime that hosts the recompiled BIOS
|
||
|
||
**Goal:** a runtime executable that links the Phase 1 output, calls
|
||
into `func_0xBFC00000` (the recompiled reset vector), and runs the
|
||
BIOS until it hits its first MMIO access. The MMIO access fatally
|
||
errors with a clear message ("BIOS read I_STAT @ 0x1F801070, no
|
||
hardware sim available").
|
||
|
||
**Concrete work:**
|
||
- Create `runtime/src/main.cpp` — minimal SDL2 window, no input, no
|
||
rendering yet. Just a CPU state allocation and a single function
|
||
call into the generated C.
|
||
- Create `runtime/src/memory.c` — RAM (2 MB), scratchpad (1 KB),
|
||
BIOS ROM region (512 KB, populated from disk). Read/write entry
|
||
points used by the generated C. **No MMIO routing yet** — any
|
||
MMIO read/write fatally errors with the address.
|
||
- Create `runtime/src/cpu_state.h` — the `CPUState` struct that the
|
||
generated C expects (32 GPRs, HI, LO, PC, COP0 registers).
|
||
- Build with the same compiler the recompiler emits for. Link
|
||
against `SCPH1001_full.o` from Phase 1.
|
||
- Run. Capture which MMIO address the BIOS hits first.
|
||
|
||
**Success criterion:** the runtime starts, executes generated C for
|
||
some number of instructions, and dies with a clear "no hardware sim
|
||
for MMIO @ 0x..." message at a real BIOS MMIO read.
|
||
|
||
### Phase 3 — Hardware sims to reach the Sony logo
|
||
|
||
**Goal:** the BIOS reaches the GPU command sequence that draws the
|
||
Sony logo, and the logo appears on screen.
|
||
|
||
**Concrete work:**
|
||
- Salvage `interrupts.c`, `timers.c`, `dma.c`, `gpu.c`, and
|
||
`gpu_sw_renderer.c` from v3, **one at a time**, audited for HLE
|
||
state leakage. Wire each into `runtime/src/memory.c`'s MMIO
|
||
routing.
|
||
- Wire `gpu_sw_renderer`'s VRAM into the SDL2 window's display buffer.
|
||
- Run. Capture every MMIO access and compare against DuckStation's
|
||
TCP debug server output for the same boot sequence.
|
||
- Iterate until the Sony logo pixels appear in VRAM and on screen.
|
||
|
||
**Success criterion:** SDL2 window shows the Sony Computer
|
||
Entertainment logo. Pixels match DuckStation's VRAM byte-for-byte
|
||
(not just visually).
|
||
|
||
### Phase 4 — Boot to BIOS shell + memory card screen
|
||
|
||
**Goal:** BIOS shell renders, controller input works, memory card
|
||
screen renders correctly with no fake events anywhere.
|
||
|
||
**Concrete work:**
|
||
- Salvage `sio.c` from v3, audited.
|
||
- Salvage `memcard.c` from v3, audited.
|
||
- Wire SDL2 keyboard input through the runtime to the SIO controller
|
||
state machine.
|
||
- Verify the BIOS scans for memory cards via the real SIO chain
|
||
handler running as recompiled C. **Not via hand-delivered events.**
|
||
- Render the memory card management screen.
|
||
|
||
**Success criterion:** user can press buttons to navigate the BIOS
|
||
shell. Memory card screen displays. Cards present/absent correctly.
|
||
Format card works (writes 128 KB image, structure verified by
|
||
DuckStation comparison).
|
||
|
||
**Status (2026-04-23):** Phase 4 functionally complete. Shell renders,
|
||
pad input works, Memory Card screen navigable (CARD 1/2, EXIT/COPY/
|
||
COPY ALL/DELETE). Card present/absent and format card untested but
|
||
infrastructure is in place (SIO memcard protocol + memcard.c). COP2/GTE
|
||
support added (was planned for Phase 5). 1173 emitted functions, zero
|
||
dispatch misses.
|
||
|
||
### Phase 4.5 — Visual polish (deferred)
|
||
|
||
**Goal:** cosmetic improvements to the BIOS shell rendering. Not
|
||
blocking Phase 5 entry.
|
||
|
||
**Known gaps:**
|
||
- Colored background panels behind menu text items (204 shaded quads
|
||
drawn with GTE-computed coordinates — likely zero-area due to
|
||
rotation matrix not persisting between frames)
|
||
- 3D memory card slot graphics (GTE RTPS/RTPT coordinate transforms)
|
||
- Particle/sparkle animation effects
|
||
- Semi-transparent overlays on UI elements
|
||
|
||
**Root cause hypothesis:** GTE rotation matrix (ctrl regs 0-4) reads
|
||
as zero at debug poll time. The matrix may be set and cleared within
|
||
a single frame. The 204 shaded quads (GP0 0x38) produce degenerate
|
||
vertices because RTPS with zero RT matrix maps all inputs to
|
||
(OFX, OFY) = (320, 240). Needs: trace vertex coordinates during
|
||
actual frame rendering to confirm, then fix matrix persistence or
|
||
GTE command accuracy.
|
||
|
||
### Phase 5 — Game EXE recompilation layer
|
||
|
||
**Goal:** the recompiler can ingest a PS1-EXE (re-using the v3-era
|
||
`main_psx.cpp`) and emit `<game>_full.c`. The runtime can load that
|
||
recompiled game on top of the recompiled BIOS, with both linked into
|
||
the same binary.
|
||
|
||
**Concrete work:**
|
||
- Re-enable `recompiler/src/main_psx.cpp` (kept from v3 salvage).
|
||
- Add COP2 / GTE translation to `code_generator.cpp`. Reuse v3's
|
||
`gte.cpp` for the math.
|
||
- Salvage `cdrom.c`, `iso_reader.cpp`, `iso_reader_c.cpp` for game
|
||
disc loading.
|
||
- Salvage `spu.c` (silent SPU is fine for now).
|
||
- Recompile Tomba's game EXE. Link with the recompiled BIOS. Run.
|
||
|
||
**Success criterion:** Tomba boots to its title screen, runs
|
||
through the intro, reaches gameplay.
|
||
|
||
### Phase 6 — Save/load end-to-end (the v1 failure case)
|
||
|
||
**Goal:** the v1 / v2 / v3 failure mode of "save/load broken because
|
||
kernel state isn't real" does not happen. Saves go to a real 128 KB
|
||
memory card image, can be loaded by DuckStation, can be loaded by
|
||
real PS1 hardware, and can be loaded back by v4 itself.
|
||
|
||
**Success criterion:** save in v4, copy `.mcd` to DuckStation, load
|
||
and verify it's the same save. This is the milestone that all three
|
||
prior versions failed.
|
||
|
||
---
|
||
|
||
## Verification strategy
|
||
|
||
### DuckStation as the oracle
|
||
|
||
`duckstation/` (the modified version with TCP debug server, moved from
|
||
v3) serves as the runtime ground truth. For every milestone we run
|
||
the same BIOS boot sequence in v4 and in DuckStation, then diff:
|
||
|
||
- CPU register state at known PC checkpoints
|
||
- RAM contents at known kernel addresses (PCB, TCB, EvCB tables)
|
||
- VRAM contents (byte-for-byte)
|
||
- MMIO read/write sequences
|
||
- Timer counter values
|
||
- Interrupt fire ordering
|
||
|
||
The diff happens via TCP, not via log files. Both servers have a
|
||
ring buffer of state snapshots; the comparison tool walks both rings
|
||
and reports the first divergence.
|
||
|
||
### Ghidra as the static oracle
|
||
|
||
`SCPH1001.BIN` loaded into Ghidra at `0xBFC00000` provides the
|
||
source-of-truth disassembly. When the recompiler emits something
|
||
that looks wrong, the first check is "what does Ghidra say this
|
||
instruction does?" not "let me guess from context".
|
||
|
||
### The TCP debug server
|
||
|
||
In Phase 2 onward, the v4 runtime exposes a TCP debug server (port
|
||
4370 to match v3's convention, separate from DuckStation's 4371). It
|
||
provides commands for register dump, RAM read, VRAM dump, MMIO trace,
|
||
function call trace. **There is no fprintf logging anywhere in v4.**
|
||
If you need to inspect something, build a TCP command for it.
|
||
|
||
---
|
||
|
||
## Open questions for outside review
|
||
|
||
This document exists to be read by an external reviewer (ChatGPT or a
|
||
human) and to surface the architectural decisions where outside input
|
||
would help. Specifically:
|
||
|
||
### Q1. COP0 modeling — how would you do HP1?
|
||
|
||
We want the cleanest mapping of `mtc0`/`mfc0`/`rfe` and the SR/Cause/
|
||
EPC registers into generated C. Current preference: function-call-
|
||
per-access so the runtime can hook SR writes for IRQ delivery. Is
|
||
there a better model? How does N64Recomp handle COP0?
|
||
|
||
### Q2. Self-modifying boot code — how would you do HP2 and HP3?
|
||
|
||
The BIOS copies kernel code from ROM to RAM during init and runs the
|
||
RAM copy. We're inclined to teach the recompiler to recognize the
|
||
specific ROM->RAM copy pattern and statically resolve the RAM
|
||
addresses to the same generated functions. Is there a more general
|
||
solution that doesn't require BIOS-specific pattern recognition?
|
||
|
||
### Q3. A0/B0/C0 vector dispatch — how to express runtime tables?
|
||
|
||
The standard PS1 BIOS call pattern is `jal 0xA0` (or `jal 0xB0`, or
|
||
`jal 0xC0`) with a function index in `$t1`. The targets at `0xA0`,
|
||
`0xB0`, `0xC0` are dispatch stubs that index into a table built at
|
||
runtime. How do we express "indirect call through a runtime-built
|
||
function pointer table" in generated C, in a way that lets the C
|
||
compiler optimize the common case?
|
||
|
||
### Q4. Should the runtime have any C-side BIOS state at all?
|
||
|
||
Our current plan: zero C-side BIOS state. The recompiled BIOS owns
|
||
the kernel data area in PS1 RAM, the runtime only simulates hardware.
|
||
But this means the runtime's debug server can only inspect kernel
|
||
state by reading the PS1 RAM array — it has no native pointers to
|
||
"the EvCB table" or "the current thread". Is that going to make
|
||
debugging miserable, or is it fine?
|
||
|
||
### Q5. How aggressive should we be about salvaging the v3 hardware sims?
|
||
|
||
The v3 hardware sim files (`gpu.c`, `dma.c`, `interrupts.c`, etc.)
|
||
work and are reasonably well-tested against DuckStation. But every
|
||
one of them has at least one branch that handles "if HLE BIOS is
|
||
involved, do X differently". We plan to strip those branches as we
|
||
audit each file for v4. Is that the right call, or should we rewrite
|
||
each from scratch to be sure no HLE assumptions sneak in?
|
||
|
||
### Q6. Build system
|
||
|
||
v3 used CMake + Ninja for the runtime and CMake for the recompiler.
|
||
v4 inherits the recompiler's CMakeLists.txt as-is. Should the
|
||
runtime stay on CMake (for symmetry with the recompiler) or switch
|
||
to something simpler given how small the runtime will be in Phase 2?
|
||
|
||
### Q7. What's the right first compile target?
|
||
|
||
Phase 1's success criterion is "`gcc -c SCPH1001_full.c` succeeds".
|
||
Should we use gcc (MinGW) or MSVC for the eventual full link? v3
|
||
used MinGW for the runner and MSVC for DuckStation. v4 has the
|
||
freedom to pick. Considerations: debugger quality, link-time
|
||
optimization, compile speed for ~hundreds of generated C functions
|
||
in one big file.
|
||
|
||
### Q8. What's the right milestone cadence?
|
||
|
||
The phase plan above has 6 phases. Each is "weeks of work" with no
|
||
intermediate checkpoints described. Should we break this down
|
||
finer? Or is the per-phase verification (DuckStation diff) enough?
|
||
|
||
---
|
||
|
||
## Project layout
|
||
|
||
```
|
||
F:/Projects/psxrecomp-v4/
|
||
├── PLAN.md <-- you are here
|
||
├── CLAUDE.md in-session rules
|
||
├── README.md short summary
|
||
├── bios/
|
||
│ └── SCPH1001.BIN 524288 bytes, the recompilation target
|
||
├── duckstation/ 2.1 GB, modified DuckStation, runtime oracle
|
||
│ (build via cmake; binary at
|
||
│ duckstation/build/bin/duckstation-qt.exe)
|
||
└── recompiler/ 16 MB, salvaged from v3
|
||
├── CMakeLists.txt
|
||
├── src/
|
||
│ ├── main_psx.cpp kept for Phase 5, not used in Phase 1
|
||
│ ├── ps1_exe_parser.cpp kept for Phase 5
|
||
│ ├── mips_decoder.cpp Phase 1
|
||
│ ├── basic_block.cpp Phase 1
|
||
│ ├── control_flow.cpp Phase 1
|
||
│ ├── function_analysis.cpp Phase 1 (needs extension for HP2)
|
||
│ ├── code_generator.cpp Phase 1 (needs extension for HP1)
|
||
│ ├── annotations.cpp Phase 1
|
||
│ └── annotations.hpp
|
||
├── include/
|
||
│ ├── basic_block.h
|
||
│ ├── code_generator.h
|
||
│ ├── control_flow.h
|
||
│ ├── function_analysis.h
|
||
│ ├── gte.h
|
||
│ ├── mips_decoder.h
|
||
│ └── ps1_exe_parser.h
|
||
├── tests/
|
||
│ ├── l1_decoder_test.cpp Decoder conformance (Rabbitizer oracle)
|
||
│ └── l2_structural_test.cpp Codegen pattern verification
|
||
└── lib/
|
||
├── rabbitizer/ MIPS disassembler
|
||
├── fmt/ C++ string formatting
|
||
└── toml11/ TOML parser
|
||
```
|
||
|
||
---
|
||
|
||
## What is NOT in v4
|
||
|
||
- **v3's runner** (`runner/src/`) — 12,852 lines of interpreter + HLE
|
||
+ hardware sims with HLE branches. Not copied. Files will be cherry-
|
||
picked for hardware sims in Phase 2 onward, audited individually.
|
||
- **v3's TombaRecomp/** — game-build directory. Phase 5 concern.
|
||
- **v3's tools/** — ad-hoc Python scripts for v3-specific debugging.
|
||
Most of these are throwaways. The TCP debug client is the only one
|
||
worth porting and that happens in Phase 2.
|
||
- **v3's session handoff files** — forbidden by rules anyway.
|
||
- **v3's test logs and debug screenshots** — 555 GB of `.log` and
|
||
`.png` files in v3's TombaRecomp/. Already deleted before fork.
|
||
- **A MIPS interpreter for the BIOS path.** This is the core
|
||
architectural commitment. There is no interpreter in v4.
|
||
|
||
---
|
||
|
||
## Where to ask for help (the actual asks)
|
||
|
||
If you (the outside reviewer) take only one thing from this document:
|
||
|
||
**Tell us if Architecture A is the right call for the PS1 BIOS
|
||
specifically, or if there's a fundamental reason it can't work and
|
||
we should be doing HLE BIOS reimplementation instead.**
|
||
|
||
Our reasoning for A over HLE:
|
||
|
||
1. The PS1 BIOS is 512 KB of well-understood MIPS code. It is small
|
||
enough to recompile in full.
|
||
2. Other static recomp projects (N64Recomp, SuperMarioWorldRecomp)
|
||
have proven the model on larger ROMs.
|
||
3. HLE BIOS reimplementation has been tried at least three times
|
||
on this project and has failed three times. Empirically, HLE keeps
|
||
collapsing into "stub creep".
|
||
4. A recompiled BIOS, by construction, *is* the BIOS. It cannot
|
||
diverge from the real thing because it is generated from the
|
||
same bytes.
|
||
|
||
Counterarguments we're aware of:
|
||
|
||
1. HLE can be faster than recompilation (no need to faithfully
|
||
simulate every COP0 quirk and self-modifying boot dance).
|
||
2. HLE is what `pcsx-redux` does (OpenBIOS) and it works for them.
|
||
3. Self-modifying boot code is genuinely hard to handle in static
|
||
recompilation.
|
||
|
||
What we want from you:
|
||
|
||
- Validate or reject the architectural call (Architecture A vs HLE).
|
||
- If you validate: tell us the dumbest mistake we're about to make
|
||
that we don't know yet.
|
||
- If you reject: tell us specifically *why*, with reference to the
|
||
hard problems above (HP1-HP6) — not just "stick with HLE because
|
||
it's easier", because we tried that three times.
|
||
|
||
---
|
||
|
||
## Test Infrastructure
|
||
|
||
Modeled after snesrecomp and gbarecomp test harnesses. Two tiers:
|
||
|
||
### L1: Decoder Conformance (Rabbitizer Oracle)
|
||
|
||
Tests that `MipsDecoder::decode()` produces the correct mnemonic for every
|
||
instruction in the BIOS ROM's code regions, using Rabbitizer (already a
|
||
project dependency) as the oracle.
|
||
|
||
**Harness:** `recompiler/tests/l1_decoder_test.cpp`
|
||
**Build target:** `l1_decoder_test`
|
||
**Oracle:** `rabbitizer::InstructionCpu` (standard) / `rabbitizer::InstructionR3000GTE` (GTE commands)
|
||
|
||
For every 4-byte word in the BIOS code regions:
|
||
1. Decode with `MipsDecoder::decode(word, addr)`
|
||
2. Decode with Rabbitizer (InstructionCpu for standard, InstructionR3000GTE for COP2 GTE)
|
||
3. Compare lowercase mnemonics
|
||
4. Bucket failures by `(oracle → ours)` pattern
|
||
|
||
**Code regions tested:**
|
||
- Boot: 0xBFC00000 – 0xBFC0DC60 (ROM 0x00000–0x0DC60)
|
||
- Kernel: 0xBFC10000 – 0xBFC16760 (ROM 0x10000–0x16760)
|
||
- Shell: 0xBFC18000 – 0xBFC42800 (ROM 0x18000–0x42800)
|
||
|
||
**Expected known differences:**
|
||
- Our decoder returns "GTE" for all GTE commands; Rabbitizer returns specific
|
||
names (RTPS, NCLIP, MVMVA, etc.) — this is a coarsening, not a bug
|
||
- Our decoder returns "COP0CO" for unrecognized COP0 CO instructions
|
||
- Data words in code regions may decode differently (both decoders produce
|
||
garbage for non-instruction data — these show up as noise in the buckets)
|
||
|
||
**Run:**
|
||
```bash
|
||
cd /f/Projects/psxrecomp-v4
|
||
PATH=/c/msys64/mingw64/bin:$PATH
|
||
cd recompiler && cmake -B build && cmake --build build --target l1_decoder_test && cd ..
|
||
./recompiler/build/l1_decoder_test bios/SCPH1001.BIN
|
||
```
|
||
|
||
### L2-structural: Codegen Pattern Verification (StrictTranslator)
|
||
|
||
Tests that `StrictTranslator::translate()` produces correct C code patterns
|
||
for each MIPS instruction form (snesrecomp-style structural matching).
|
||
|
||
**Harness:** `recompiler/tests/l2_structural_test.cpp`
|
||
**Build target:** `l2_structural_test`
|
||
**Forms:** 42 instruction forms covering:
|
||
- SPECIAL R-type ALU (SLL, SRL, SRA, shifts, ADDU, SUBU, AND, OR, XOR, NOR, SLT, SLTU, ADD with overflow)
|
||
- Multiply/divide (MULTU, DIV, DIVU, MFHI, MFLO, MTHI, MTLO)
|
||
- Immediate ALU (ADDIU, ADDI, ANDI, ORI, XORI, LUI, SLTI, SLTIU)
|
||
- Load/store (LW, SW, LB, LBU, LH, LHU, SB, SH)
|
||
- COP0 (MFC0, MTC0)
|
||
- Edge cases ($zero writes discarded, NOP)
|
||
|
||
Each form: decode instruction → translate → verify C code contains expected
|
||
register references, operations, and patterns. Catches wrong register
|
||
indices, missing operations, and translation gaps.
|
||
|
||
**Known out-of-scope for strict translator:** SUB (funct 0x22), MULT (funct 0x18),
|
||
COP2/GTE, LWC2/SWC2 — these are handled by the full_function_emitter's code path.
|
||
|
||
**Run:**
|
||
```bash
|
||
./recompiler/build/l2_structural_test # summary
|
||
./recompiler/build/l2_structural_test -v # show generated C for each form
|
||
```
|
||
|
||
### L2-semantic: Full State Comparison — PLANNED
|
||
|
||
Future tier: compile generated C, execute with random register states,
|
||
compare against reference single-instruction interpreter. Modeled after
|
||
gbarecomp's L2 with mGBA oracle. Requires `emit_one_insn` CLI and
|
||
CMake-time code generation.
|
||
|
||
---
|
||
|
||
## Phase 4 Current Status (2026-04-19)
|
||
|
||
Phase 4 goal: boot to BIOS shell. Sony logo renders. Shell entry stalls
|
||
on VSync(0) because VSync counter (0x80079D9C) never increments.
|
||
|
||
### Root cause chain (fully diagnosed)
|
||
|
||
1. VSync counter increments in the kernel exception handler's walk of
|
||
DCB chain #3 (head at `0xA000E014`)
|
||
2. Chain #3 is populated by `func_00004B90` (B_table[0x13]), called from
|
||
`FUN_bfc25e8c` → B0:0x13 trampoline → B-table dispatch at 0x5E0
|
||
3. B-table dispatch at 0x5E0 is a 7-instruction pattern:
|
||
`lui/addiu/sll/add/lw/jr/nop` — reads B_table[$t1] and jumps to it
|
||
4. The trampoline resolver (runtime/src/traps.c) only handled 4-instruction
|
||
patterns. Pattern 4 matched the first two instructions but failed on the
|
||
third (sll instead of jr), causing a silent dispatch miss → B0:0x13
|
||
was no-op'd → chain #3 never populated → VSync counter never ticked
|
||
|
||
### Fix applied
|
||
|
||
Added Pattern 5 to trampoline resolver (runtime/src/traps.c):
|
||
- Matches the 7-instruction BIOS A0/B0/C0 vector dispatch pattern
|
||
- Fixed ADD vs ADDU detection (BIOS uses ADD func=0x20, not ADDU func=0x21)
|
||
- Runtime rebuilt with `-DCMAKE_BUILD_TYPE=Release` (was empty → `-O0`)
|
||
|
||
### Current register state
|
||
|
||
| Register/Address | Value | Meaning |
|
||
|----------------------------|------------|----------------------------------------|
|
||
| SR (COP0.12) | 0x00000401 | IEc=1, IM[2]=1 (VBlank unmasked) |
|
||
| I_STAT | 0x00000001 | VBlank IRQ pending |
|
||
| I_MASK | 0x0000000D | Bits 0,2,3 (VBlank, CDROM, DMA) |
|
||
| DCB chain head #2 (0xE00C) | 0x00006D88 | Populated (InitRCnt) |
|
||
| DCB chain head #3 (0xE014) | 0x00000000 | **EMPTY — awaiting Pattern 5 verify** |
|
||
| VSync counter (0x80079D9C) | 0x00000000 | Never increments (empty chain #3) |
|
||
| Dispatch table entries | 1045 | |
|
||
| Dispatch misses | 0 | |
|
||
|
||
### Active guards/workarounds
|
||
|
||
- RFE longjmp unwind for nested exception paths
|
||
- jr $k0 permitted; IsC + ExitCriticalSection IEc mask fixed
|
||
- Shell-copy normalize() in dispatcher: phys [0x30000..0x5AFFF] → 0x1FC18000+offset
|
||
- Primary-copy aliases (69 entries) for [0xBFC10000..0xBFC18BEF] → [0x80000500..0x800090EF]
|
||
- RAM-write-trace ring on configurable range
|
||
- TCP debug server on port 4370 (native), 4371 (DuckStation)
|
||
- VBLANK_INTERVAL = 50000 dispatches
|
||
|
||
### DuckStation oracle enhancements (in working tree, not yet patched)
|
||
|
||
- `system_reset` — resets console and re-arms all breakpoints
|
||
- `mem_break` — CPU write breakpoint on a memory address
|
||
- `mem_hit_last` / `mem_hit_clear` — query/clear last write breakpoint hit
|
||
- Refactored breakpoint callback into shared `PcBreakCallback` + `RearmAllBreakpoints()`
|
||
- Removed pause-on-hit (was freezing event loop and killing TCP access)
|
||
|
||
### Outstanding issues (priority order)
|
||
|
||
1. **Verify Pattern 5 at runtime.** Release build compiled but not yet run
|
||
long enough to reach the FUN_bfc25e8c call site. Poll `read_ram 0xE014`.
|
||
2. **If chain #3 populates:** VSync counter should tick. VSync(0) stops
|
||
timing out. Shell progresses past logo hold.
|
||
3. **Regenerate DuckStation oracle patch.** Changes in working tree need capture:
|
||
`git -C duckstation diff ffb33c281 > tools/duckstation/psxrecomp_oracle.patch`
|
||
4. **Monitor for new dispatch misses** as shell enters rendering loop.
|
||
|
||
### Build & run commands
|
||
|
||
```bash
|
||
# --- native runtime ---
|
||
cd /f/Projects/psxrecomp-v4
|
||
PATH=/c/msys64/mingw64/bin:$PATH
|
||
|
||
# Regen after seed changes (going-forward — config-driven):
|
||
./recompiler/build/psxrecomp-bios.exe --config bios/SCPH1001.toml
|
||
|
||
# Legacy positional form (still supported for back-compat):
|
||
# ./recompiler/build/psxrecomp-bios.exe bios/SCPH1001.BIN generated/ --emit-full \
|
||
# recompiler/seeds/phase2_ghidra_seeds.json
|
||
|
||
# Rebuild (Debug — see memory/debug_vs_release_optimization.md):
|
||
cd runtime && cmake -B build && cmake --build build && cd ..
|
||
|
||
# Run (one instance at a time):
|
||
taskkill //F //IM psx-runtime.exe 2>/dev/null
|
||
./runtime/build/psx-runtime.exe &
|
||
|
||
# --- duckstation oracle ---
|
||
taskkill //F //IM duckstation-qt.exe 2>/dev/null
|
||
python3 tools/duckstation/launch.py -bios
|
||
|
||
# --- test infrastructure ---
|
||
cd recompiler && cmake -B build && cmake --build build --target l1_decoder_test && cd ..
|
||
./recompiler/build/l1_decoder_test bios/SCPH1001.BIN
|
||
|
||
# Probe (ncat):
|
||
NC='/c/Program Files (x86)/Nmap/ncat'
|
||
(printf '{"cmd":"read_ram","addr":"0x0000E014","len":4}\n'; sleep 1) | "$NC" localhost 4370
|
||
(printf '{"cmd":"read_ram","addr":"0x80079D9C","len":4}\n'; sleep 1) | "$NC" localhost 4370
|
||
|
||
# Regenerate DS patch:
|
||
git -C duckstation diff ffb33c281 > tools/duckstation/psxrecomp_oracle.patch
|
||
```
|
||
|
||
---
|
||
|
||
*End of plan. Last updated 2026-04-19.*
|