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>
68 KiB
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:
-
The recompiler (
psxrecomp/recompiler/) only ingests PS1-EXE files.main_psx.cpp:17callsPS1ExeParser::parse_file()which validates a"PS-X EXE"magic header. SCPH1001.BIN has no such header. Searching the entirerecompiler/tree forSCPH,bios.bin,0xBFC00000, or any BIOS-related identifier returns zero matches. The recompiler in v3 has never seen the BIOS ROM. -
TombaRecomp/generated/contains onlySCUS_942.36_*.c(Tomba's game serial). NoSCPH1001*.cfile has ever been emitted. -
At runtime, the BIOS is executed by
runner/src/interpreter.c, a 919-line hand-written MIPS R3000A interpreter.main_runner.cpp:575callsinterpreter_run_bios_boot(&g_cpu, bios_only)which walks the bytes of SCPH1001.BIN starting at0xBFC00000. This is exactly what an emulator does. -
runner/src/bios.cis 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)atbios.c:1340sets 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. -
The "run native" path inside
bios.cdoes not run native C either.bios_run_native_b0(line 284) reads the B0 dispatch table from PS1 RAM, finds the function address, and callsinterpreter_runon 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. -
555 GB of
boot_trace*.logandcard_test*.logfiles had accumulated inTombaRecomp/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
- Load
SCPH1001.BINas a flat 512 KB byte array at base address0xBFC00000. - Discover function entry points. Initial seed: the reset vector at
0xBFC00000, the exception vectors at0xBFC00100/0xBFC00180, and the A0/B0/C0 dispatch sites at0xA0/0xB0/0xC0in RAM (after the BIOS copies code into RAM during early init — see "self- modifying boot code" below). - For each discovered function, run the existing v3-salvaged
BasicBlockAnalyzerandControlFlowAnalyzerto find its blocks and edges. - 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. - Emit a
SCPH1001_full.cfile containing one C function per BIOS function, named by entry address (e.g.,func_0xBFC00000). - Emit a
SCPH1001_dispatch.cfile containing a PC-to-function-pointer table so indirect calls (jr $t0,jalr $t1) can resolve at runtime.
The runtime's job
- Allocate and initialize PS1 memory (2 MB main RAM, 1 KB scratchpad,
512 KB BIOS ROM region populated from
bios/SCPH1001.BIN). - Provide MMIO read/write entry points that the recompiled BIOS C
code calls into (e.g.,
psx_mmio_read_word(0x1F801070)for I_STAT). - 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)
- Drive an SDL2 window and pump events.
- Expose a TCP debug server for inspection (no printf, ever).
- 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 registersrfe— 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:
- A
cop0struct inCPUStatewith explicit fields (sr,cause,epc, etc.) and translation methods that emitcpu->cop0.sr = ...; - A 32-entry
cop0_regs[32]array with helpers, mirroring how the GPRs work - 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:
- Copies a chunk of code from BIOS ROM (
0xBFC0xxxx) into RAM (0x80000000+ offset) — typically the kernel/exception/vector support code. - Patches A0/B0/C0 dispatch addresses at fixed RAM addresses
0xA0,0xB0,0xC0to point at the in-RAM code. - 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:
- 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.)
- 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.)
- 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 rfes back.
For v4 this means:
- The recompiler must emit a function for
0x80000080whose 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/rfetranslations).
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:
mtc0to 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.candthreads.cwere 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_translatoris the only valid translation path. It fails loudly on unknown opcodes, unknown control flow, or any ambiguity. The salvaged v3code_generator.cppis 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. Loadsbios/SCPH1001.BINas 524288 bytes at virtual base0xBFC00000.generated/boot_slice.c— 30 instructions, region0xBFC00000..0xBFC00074, terminating atj 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-onlyCPUStatedefinition. 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):
RFEsubstitutes 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):
- Use Ghidra (
SCPH1001.BINat0xBFC00000) 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. - For each opcode in the inventory, check whether
strict_translatoralready implements it. The check is by reading the dispatch table instrict_translator.cpp— not by trial-emission and "looks fine". - 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). - After each opcode addition, regenerate
boot_slice.cand 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 inSCPH1001.BINaccording 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 instrict_translator.cppif implemented, and the rationale ("required for BIOS execution path", "GTE — deferred to Phase 5", etc.) if intentionally deferred.- An updated
strict_translator.cppwhose dispatch covers every opcode markedimplemented: truein the coverage file. generated/boot_slice.candgenerated/boot_slice_manifest.jsonmust 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: truein the coverage file lacks a real handler instrict_translator.cpp(adefault: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.cppis invoked from any new code path. unsupported_ops.jsonis not empty after a clean rebuild of the Phase 1a slice.
Acceptance criteria (ALL must hold):
instruction_inventory.jsonexists and matches the Ghidra opcode inventory.instruction_coverage.jsonexists 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.cregenerates identically. unsupported_ops.jsonis 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:
-
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
0xBFC00100as a third seed under the label "ROM-side exception vector". That assertion was wrong and is removed.0xBFC00100is 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 with0xBFC00100as a synthetic seed to test the assertion; the walker immediately bled into the bytes at0xBFC00108, which Ghidra'slist_dataalready classifies as the ASCII string"Sony Computer Entertainment Inc.". The walker decoded those string bytes as garbage opcodes (top-level opcodes0x18,0x19,0x1B,0x1D,0x1E— all unallocated on R3000A). The proof is ingenerated/instruction_inventory.jsonhistory and in the seed-list selection insidetools/build_instruction_inventory.py. The canonical Phase 1c seed list is therefore exactly two ROM addresses:0xBFC00000and0xBFC00180.0xBFC00100must NOT be re-added without new evidence that some BIOS path actually executes it. -
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(directjalonly — indirect dispatch is Phase 1d). -
Functions discovered via direct
jalfrom 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). -
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 — neverfprintf.
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.jsoncontains 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.jsonremains 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:
- 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). - 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. - Classify each site by the kind of resolution it will eventually
need: A0/B0/C0 vector dispatch through a runtime-built table,
jr $rareturn, 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
jrorjalris 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
$raand 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.jsonis 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:
- Identify, in
SCPH1001.BIN, every memcpy/move loop that the BIOS executes during early init to copy code from0xBFCxxxxxinto0x800xxxxx. Use Ghidra for the static side and DuckStation's memory trace for the runtime side. Both sources must agree. - 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).
- 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). - Emit
generated/address_aliases.jsoncontaining every provenBFCxxxxx ↔ 800xxxxxpair, and only those. No alias may be added without an entry in the proof artifact set. - Teach
strict_translatorand 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.jsonsummarizing 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 inrelocation_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.jsonwithout a corresponding proof directory underrelocation_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.jsonis generated mechanically from those proofs and contains nothing else.normalization_rule.mdexists and is consistent with every alias.- A re-run of Phase 1c under the new normalization rule produces no duplicate functions.
unsupported_ops.jsonis 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.jsonshows every BIOS-used opcode implemented.function_manifest.jsonexists from a normalized Phase 1c re-run.indirect_jumps.jsonexists and covers every indirect site.address_aliases.jsonexists and is backed byrelocation_proofs/.unsupported_ops.jsonis 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
0xBFCxxxxxto0x800xxxxxduring 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.jsonmust trace back to a proof artifact undergenerated/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:412implementsmtc0andinstruction_coverage.jsonshows itimplemented: trueand 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
unknownwith 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— theCPUStatestruct 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.ofrom 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, andgpu_sw_renderer.cfrom v3, one at a time, audited for HLE state leakage. Wire each intoruntime/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.cfrom v3, audited. - Salvage
memcard.cfrom 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'sgte.cppfor the math. - Salvage
cdrom.c,iso_reader.cpp,iso_reader_c.cppfor 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
.logand.pngfiles 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:
- The PS1 BIOS is 512 KB of well-understood MIPS code. It is small enough to recompile in full.
- Other static recomp projects (N64Recomp, SuperMarioWorldRecomp) have proven the model on larger ROMs.
- 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".
- 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:
- HLE can be faster than recompilation (no need to faithfully simulate every COP0 quirk and self-modifying boot dance).
- HLE is what
pcsx-reduxdoes (OpenBIOS) and it works for them. - 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:
- Decode with
MipsDecoder::decode(word, addr) - Decode with Rabbitizer (InstructionCpu for standard, InstructionR3000GTE for COP2 GTE)
- Compare lowercase mnemonics
- 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:
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:
./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)
- VSync counter increments in the kernel exception handler's walk of
DCB chain #3 (head at
0xA000E014) - Chain #3 is populated by
func_00004B90(B_table[0x13]), called fromFUN_bfc25e8c→ B0:0x13 trampoline → B-table dispatch at 0x5E0 - 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 - 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 breakpointsmem_break— CPU write breakpoint on a memory addressmem_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)
- 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. - If chain #3 populates: VSync counter should tick. VSync(0) stops timing out. Shell progresses past logo hold.
- Regenerate DuckStation oracle patch. Changes in working tree need capture:
git -C duckstation diff ffb33c281 > tools/duckstation/psxrecomp_oracle.patch - Monitor for new dispatch misses as shell enters rendering loop.
Build & run commands
# --- 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.