diff --git a/.gitignore b/.gitignore
index 2b778d27..e930ff3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,3 +79,16 @@ __pycache__/
# DuckStation oracle (local-only; private fork at git@github.com:mstan/duckstation.git)
/duckstation/
_wip/
+
+# Game discs/EXEs and recompiler output (copyrighted, not distributable).
+# Only the per-game config (game.toml) is tracked.
+games/*/*
+!games/*/game.toml
+!games/*/seeds.txt
+
+# Per-game memcards created at runtime
+*.mcr
+!dummy.*.mcr
+
+# macOS .app bundle (contains recompiled BIOS-derived binary — not distributable)
+/dist/
diff --git a/README.md b/README.md
index 72e2394f..3a11f5cd 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,58 @@ targets are:
- [TombaRecomp](https://github.com/mstan/TombaRecomp) — *Tomba!*
- [MegaManX6Recomp](https://github.com/mstan/MegaManX6Recomp) — *Mega Man X6*
+### This fork — *Kula World* (SCES-01000) on Linux
+
+This fork brings up *Kula World* (a.k.a. *Roll Away*) as a new end-to-end
+target and ports the framework (recompiler + runtime + Beetle-PSX oracle) to
+Linux. It boots to the title screen and menu, recompiled + interpreted, no
+HLE:
+
+
+
+
+
+The bring-up surfaced and fixed five runtime bugs — most of them
+game-agnostic framework improvements (see `docs/kula-world-findings.md`):
+
+1. **Dirty-RAM interpreter interrupt latency** — a guest spin-wait living in
+ dirty RAM (libcd `CdSync`) never polled interrupts, so a pending, enabled
+ CD IRQ was never taken and the game wedged. Now the interpreter polls at
+ its per-invocation entry on a global counter — general to any dirty-RAM
+ spin-loop.
+2. **Main-RAM 4× mirror** not modeled (crt0 parks `$sp` at `0x807FFFF8`).
+3. **GetID region** hard-coded to `SCEI`; now derived from the disc serial
+ (Tomba/MMX6 get correct `SCEA` too).
+4. **CD status bits** ADPBUSY/BUSYSTS corrected against the oracle.
+5. **CD controller version** returned the PSone `0x97`; the SCPH-1001 sub-CPU
+ reports `0x94 09 19 C0`, which the shell's CD-init requires.
+
+### Now also on macOS (Apple Silicon)
+
+Those **five runtime fixes** are what got *Kula World* actually *playing* — and
+they are game-agnostic, so they benefit every target, not just this one. On top
+of the earlier Windows→Linux POSIX port, bringing the same build up on **macOS
+(Apple Silicon / arm64, AppleClang)** needed only **one** extra build fix plus a
+small POSIX cleanup — the recompiler and runtime are otherwise portable C:
+
+1. `runtime/src/autocompile.c` — added `#include ` for `getenv`
+ (glibc and `windows.h` leak that declaration; the macOS SDK does not).
+2. `recompiler/src/main_bios.cpp` — the compiler-probe's `>NUL` (Windows null
+ device) is now `#ifdef`'d to `/dev/null` on POSIX (it was misdirecting the
+ redirect and dropping a stray `NUL` file).
+
+The ucontext fiber backend already had the `_XOPEN_SOURCE`/`_DARWIN_C_SOURCE`
+guards it needs on macOS, and SDL2 (Homebrew) picks the **Metal** backend
+automatically. Result: BIOS → title → menu → a 3D level that rolls under
+control, running natively on Apple Silicon. Full macOS steps in
+[`docs/MACOS_BUILD_KULA.md`](docs/MACOS_BUILD_KULA.md).
+
+
+
+
+ Kula World, statically recompiled, running natively on macOS (Apple Silicon).
+
+
## Philosophy — toward 100% static recompilation
The goal is simple and absolute: **a PS1 game should run as native code, not be
diff --git a/docs/MACOS_BUILD_KULA.md b/docs/MACOS_BUILD_KULA.md
new file mode 100644
index 00000000..2d57178b
--- /dev/null
+++ b/docs/MACOS_BUILD_KULA.md
@@ -0,0 +1,167 @@
+# Building & running Kula World (PSXRecomp) on macOS — AI/dev handoff
+
+**Audience:** a developer or AI coding agent on macOS (Apple Silicon or Intel).
+Goal: a native macOS build of *Kula World* that boots to its menu and plays in
+3D, exactly as it already does on Linux and Windows.
+
+This is the macOS counterpart to [`WINDOWS_BUILD_KULA.md`](WINDOWS_BUILD_KULA.md).
+The pipeline (clone → user-supplied BIOS+disc → extract the boot EXE → build the
+recompiler → generate BIOS C + game C → build `kula-runtime` → run) is identical;
+only the toolchain and a couple of platform specifics differ. Read `CLAUDE.md`
+first — the no-interpreter / no-HLE / no-stubs rules are load-bearing.
+
+Verified on: **macOS 15.5, Apple Silicon (arm64), AppleClang 17**. The static
+path is portable C (an emulator, not a JIT), so Intel Macs should work too.
+
+---
+
+## 1. Files the USER must supply (copyrighted — NOT in the repo)
+
+Same as the Windows doc. Place them relative to the repo root:
+
+| File | Where it goes | What it is |
+|---|---|---|
+| `SCPH1001.BIN` | `bios/SCPH1001.BIN` | PS1 BIOS dump, 512 KiB, **SCPH1001** (`md5 924e392ed05558ffdb115408c263dccf`). |
+| `Kula World (Europe).bin` | `games/kula/Kula World (Europe).bin` | Your own disc dump (raw 2352-byte/sector, ~255 MB). |
+| `Kula World (Europe).cue` | `games/kula/Kula World (Europe).cue` | The cue sheet pointing at the `.bin`. |
+
+Do **not** download these — the user provides their own legal dump.
+
+---
+
+## 2. Toolchain (Homebrew instead of MSYS2)
+
+```bash
+brew install sdl2 cmake ninja pkg-config
+```
+
+Verify SDL2 is visible to pkg-config (the runtime's CMake uses
+`pkg_check_modules(SDL2)` on non-MSVC, which Homebrew's `sdl2.pc` satisfies):
+
+```bash
+pkg-config --cflags --libs sdl2
+# -I/opt/homebrew/include -I/opt/homebrew/include/SDL2 ... -lSDL2
+```
+
+If that prints nothing, add Homebrew's pkgconfig dir to `PKG_CONFIG_PATH`
+(`export PKG_CONFIG_PATH="$(brew --prefix)/lib/pkgconfig"`). AppleClang builds
+the very large generated C fine; `brew install gcc` is optional and not needed.
+
+---
+
+## 3. Clone, check out the branch, drop in the files
+
+```bash
+git clone https://github.com/chrisking1981/psxrecomp_kula_world.git
+cd psxrecomp_kula_world
+git checkout claude/repo-commit-history-1oayou
+```
+
+Submodules are not needed. Now place the three user-supplied files from §1.
+
+---
+
+## 4. Extract the game's boot EXE from the disc
+
+`game.toml` expects `games/kula/SCES_010.00` (604160 bytes, ASCII magic
+`PS-X EXE`). It is the `BOOT = cdrom:\SCES_010.00;1` binary at the disc root.
+Use `dumpsxiso` (from mkpsxiso), or any tool that reads an ISO9660 filesystem
+from a raw Mode-2/2352 PS1 image, or a tiny extractor that walks the ISO9660
+root directory and copies the extent (each 2352-byte sector carries 2048 bytes
+of payload at a 24-byte offset — Mode 2 Form 1). LBA 24 on this disc.
+
+```bash
+# sanity check
+head -c 8 games/kula/SCES_010.00 # -> PS-X EXE
+```
+
+---
+
+## 5. Build the recompiler
+
+```bash
+cmake -S recompiler -B recompiler/build -G Ninja -DCMAKE_BUILD_TYPE=Release
+cmake --build recompiler/build -j8
+```
+
+Produces `recompiler/build/psxrecomp-bios` and `recompiler/build/psxrecomp-game`.
+
+---
+
+## 6. Generate the C (BIOS once, game once)
+
+```bash
+# 6a. Full BIOS -> generated/SCPH1001_full.c + SCPH1001_dispatch.c
+# NOTE: use --emit-full with the phase-2 seeds. The bare positional form
+# (psxrecomp-bios ) runs the Phase-1a boot-slice validator, which
+# does NOT emit the full BIOS the runtime links against.
+./recompiler/build/psxrecomp-bios bios/SCPH1001.BIN generated \
+ --emit-full recompiler/seeds/phase2_ghidra_seeds.json
+
+# 6b. Game -> games/kula/generated/SCES_010.00_full.c + _dispatch.c
+./recompiler/build/psxrecomp-game --config games/kula/game.toml
+```
+
+Sanity check — both must exist and be multi-MB:
+
+```bash
+ls -la generated/SCPH1001_full.c games/kula/generated/SCES_010.00_full.c
+```
+
+(One skipped BIOS function, a COP1/FPU opcode `0x11`, is expected — the PS1 has
+no FPU. `games/kula/generated/SCES_010.00_full.c` is what makes the
+`kula-runtime` target appear in the runtime build.)
+
+---
+
+## 7. Build the runtime (the playable binary)
+
+```bash
+cmake -S runtime -B runtime/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
+ -DPSX_LAUNCHER=OFF -DPSX_ENABLE_VULKAN=OFF -DPSX_DEBUG_TOOLS=OFF
+cmake --build runtime/build --target kula-runtime -j8
+```
+
+Output: `runtime/build/kula-runtime` (native Mach-O, links SDL2; SDL picks the
+Metal backend on Apple Silicon). Set `-DPSX_DEBUG_TOOLS=ON` for the TCP debug
+server on port 4370 (`set_input`, `screenshot`, `pad_status`, …).
+
+---
+
+## 8. Run and play
+
+From the repo root (so the relative disc path resolves):
+
+```bash
+./runtime/build/kula-runtime
+```
+
+Console shows `psxrecomp: disc region PAL (serial SCES-01000)`, then a window:
+Sony boot → **KULA WORLD** title + menu (1 Player / 2 Player / Scores /
+Options). At the title, **Start** exits the attract demo; select **1 Player**
+with **X** to reach a level and roll the ball with the arrow keys.
+
+Keyboard: arrows = D-pad, **X** = Cross/confirm, **S** = Circle, **Enter** =
+Start, **Right Shift** = Select. An SDL game controller works too.
+
+---
+
+## 9. macOS platform notes
+
+- **The runtime is POSIX** thanks to the Linux port (BSD sockets, `dlopen`,
+ `__atomic`, ucontext fiber backend). Only two small platform gaps needed
+ fixing for macOS, both guarded so Windows/Linux stay intact:
+ 1. `runtime/src/autocompile.c` — added `#include ` for `getenv`.
+ glibc and `windows.h` leak that declaration transitively; the macOS SDK
+ does not, so AppleClang errored on the implicit declaration.
+ 2. `recompiler/src/main_bios.cpp` — the compiler-probe used `>NUL`, which is
+ the Windows null device. On POSIX that both misdirects the redirect and
+ drops a stray `NUL` file in the cwd; now `#ifdef`'d to `/dev/null`.
+- The **ucontext fiber backend** (`runtime/src/psx_fiber.c`) already carried the
+ `_XOPEN_SOURCE`/`_DARWIN_C_SOURCE` guards needed for `makecontext`/
+ `swapcontext` on macOS — it compiled and ran with no change.
+- **No Xvfb needed** — SDL opens a real window.
+- **`~/Downloads` is TCC-protected.** If you keep the repo under `~/Downloads`
+ and launch via a `.app` bundle or Finder, macOS prompts once for Downloads
+ access (needed to read the BIOS/disc). Launching from a terminal that already
+ has file access does not prompt.
diff --git a/docs/WINDOWS_BUILD_KULA.md b/docs/WINDOWS_BUILD_KULA.md
new file mode 100644
index 00000000..f34efaf0
--- /dev/null
+++ b/docs/WINDOWS_BUILD_KULA.md
@@ -0,0 +1,238 @@
+# Building & running Kula World (PSXRecomp) on Windows 11 — AI handoff
+
+**Audience:** an AI coding agent (e.g. Claude Code) running on the user's
+Windows 11 machine. Follow this top to bottom. The goal: produce a native
+Windows build of Kula World that boots to its menu and plays in 3D, exactly
+as it already does on Linux.
+
+---
+
+## 0. Context — what already works and what you are doing
+
+This repo is a fork of Matthew Stanley's **PSXRecomp v4**: a *static
+recompiler* that translates the PS1 BIOS (`SCPH1001.BIN`) and a game's MIPS
+executable into native **C**, which links into an SDL2 runtime that simulates
+the PS1 hardware (CPU state, GPU rasterizer, GTE 3D coprocessor, SPU, CD-ROM).
+There is **no interpreter for static code, no HLE, no stubs** — read
+`CLAUDE.md` before touching anything; those rules are strict and load-bearing.
+
+**What was done in a prior Linux session (already on this branch):**
+
+1. Ported the runtime's platform layer from Windows-only to cross-platform
+ (sockets, `dlopen`, atomics, misc WIN32) — all behind `#ifdef` guards, so
+ **Windows still builds unchanged**.
+2. Taught the recompiler to accept Kula World's KUSEG-addressed EXE header.
+3. Fixed five real bugs that were wedging Kula at boot/first-level-load:
+ RAM-mirror folding, per-disc GetID region, CD status bits, the SCPH-1001
+ CD-controller version, and an interrupt-latency bug in the dirty-RAM
+ interpreter.
+
+Result on Linux: BIOS → title → menu → **select Arcade → 3D level plays, ball
+rolls under control.** Your job is to reproduce that on Windows. None of the
+above needs redoing — it is all committed. You are just **building** here.
+
+**Branch to use:** `claude/repo-commit-history-1oayou`
+(it contains the Linux port + the five fixes + the Kula config + the
+`kula-runtime` build target).
+
+---
+
+## 1. Files the USER must supply (copyrighted — NOT in the repo)
+
+These are `.gitignore`d and will not be in a fresh clone. Ask the user to
+place them exactly here (paths are relative to the repo root):
+
+| File | Where it goes | What it is |
+|---|---|---|
+| `SCPH1001.BIN` | `bios/SCPH1001.BIN` | The PS1 BIOS dump (512 KiB). Must be **SCPH1001** (NTSC-U). |
+| `Kula World (Europe).bin` | `games/kula/Kula World (Europe).bin` | The user's own disc dump (raw 2352-byte/sector image, ~255 MB). |
+| `Kula World (Europe).cue` | `games/kula/Kula World (Europe).cue` | The cue sheet pointing at the `.bin`. |
+
+Do **not** download these. The user has their own legal dump — they provide
+them. If they are missing, stop and ask the user for them; do not proceed.
+
+You will additionally **extract one file from the disc** in step 4.
+
+---
+
+## 2. Prerequisites — MSYS2 / mingw-w64 toolchain
+
+PSXRecomp builds with the **MSYS2 mingw64** GCC toolchain (not MSVC — the
+generated C and the runtime assume GCC). If MSYS2 is not installed, install it
+from https://www.msys2.org/, then open the **"MSYS2 MinGW 64-bit"** shell and:
+
+```bash
+pacman -Syu # update; may ask you to reopen the shell, then run again
+pacman -S --needed \
+ mingw-w64-x86_64-gcc \
+ mingw-w64-x86_64-cmake \
+ mingw-w64-x86_64-ninja \
+ mingw-w64-x86_64-SDL2 \
+ mingw-w64-x86_64-pkgconf \
+ git
+```
+
+Always work from the **MinGW 64-bit** shell so the toolchain is on PATH. If in
+doubt, prefix commands with `PATH=/c/msys64/mingw64/bin:$PATH`.
+
+---
+
+## 3. Clone the fork and check out the branch
+
+```bash
+git clone https://github.com/chrisking1981/psxrecomp_kula_world.git
+cd psxrecomp_kula_world
+git checkout claude/repo-commit-history-1oayou
+```
+
+**Submodules are NOT needed.** `lib/RmlUi` and `lib/freetype` are only used by
+the optional launcher GUI, which we disable. The recompiler's own deps
+(`fmt`, `rabbitizer`, `toml11`, `ELFIO`) and the runtime's `sljit` are vendored
+in-tree and already present after clone. Do not run `git submodule update`.
+
+Now drop in the three user-supplied files from step 1.
+
+---
+
+## 4. Extract the game's boot executable from the disc
+
+The recompiler translates the game's **EXE**, read from a file. `game.toml`
+expects it at `games/kula/SCES_010.00`. It is the boot binary named in the
+disc's `SYSTEM.CNF` (`BOOT = cdrom:\SCES_010.00;1`), living at the disc root.
+
+Extract it to `games/kula/SCES_010.00` (should be **604160 bytes**). Options:
+
+- **Recommended:** `dumpsxiso` from **mkpsxiso**
+ (https://github.com/Lameguy64/mkpsxiso). Run it on the `.cue`/`.bin`, then
+ copy the extracted `SCES_010.00` into `games/kula/`.
+- **Or** any tool that reads an ISO9660 filesystem from a raw Mode-2/2352
+ PS1 image (the `.bin` is 2352 bytes/sector, not a plain 2048 ISO).
+- **Or** write a small extractor: parse the ISO9660 primary volume descriptor
+ (sector 16), walk the root directory to find `SCES_010.00`, and copy its
+ extent — remembering each 2352-byte sector carries a 2048-byte user payload
+ at a 24-byte offset (Mode 2 Form 1).
+
+Verify: `SCES_010.00` starts with the ASCII magic `PS-X EXE`.
+
+---
+
+## 5. Build the recompiler
+
+The recompiler is a standalone CMake project under `recompiler/`:
+
+```bash
+cmake -S recompiler -B recompiler/build -G Ninja -DCMAKE_BUILD_TYPE=Release
+cmake --build recompiler/build -j8
+```
+
+This produces `recompiler/build/psxrecomp-bios.exe` and
+`recompiler/build/psxrecomp-game.exe`.
+
+---
+
+## 6. Generate the C (BIOS once, game once)
+
+Both outputs are `.gitignore`d, so you must generate them on this machine.
+
+```bash
+# 6a. BIOS -> generated/SCPH1001_full.c + SCPH1001_dispatch.c
+./recompiler/build/psxrecomp-bios.exe bios/SCPH1001.BIN generated
+
+# 6b. Game -> games/kula/generated/SCES_010.00_full.c + _dispatch.c
+# (reads all paths from games/kula/game.toml, incl. out_dir)
+./recompiler/build/psxrecomp-game.exe --config games/kula/game.toml
+```
+
+Sanity check afterwards:
+
+```bash
+ls -la generated/SCPH1001_full.c games/kula/generated/SCES_010.00_full.c
+```
+
+Both must exist and be multi-MB. `games/kula/generated/SCES_010.00_full.c` is
+what makes the `kula-runtime` target appear in the runtime build.
+
+---
+
+## 7. Build the runtime (produces the playable binary)
+
+```bash
+cmake -S runtime -B runtime/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
+ -DPSX_LAUNCHER=OFF -DPSX_ENABLE_VULKAN=OFF -DPSX_DEBUG_TOOLS=OFF
+cmake --build runtime/build --target kula-runtime -j8
+```
+
+Output: `runtime/build/kula-runtime.exe`. The recompiled BIOS **code**, the
+recompiled game **code**, and the whole runtime are compiled *into* this one
+binary. (`PSX_DEBUG_TOOLS=OFF` gives a lean play build; turn it `ON` only if
+you later want the TCP debug server on port 4370.)
+
+---
+
+## 8. Run and play
+
+Run **from the repo root** (so the relative BIOS/disc paths resolve):
+
+```bash
+./runtime/build/kula-runtime.exe
+```
+
+You should see `psxrecomp: disc region PAL (serial SCES-01000)` in the console,
+then a window: Sony logo boot → **KULA WORLD** title → menu. If SDL2.dll is not
+found, copy `/c/msys64/mingw64/bin/SDL2.dll` next to `kula-runtime.exe` (or keep
+the MinGW `bin` on PATH).
+
+**Keyboard controls** (from `pad_from_keyboard` in `runtime/src/main.cpp`):
+
+| Key | PS1 button |
+|---|---|
+| Arrow keys | D-pad (roll the ball / move cursor) |
+| **X** | Cross — **confirm / select** |
+| **S** | Circle — back/cancel |
+| Z | Square |
+| A | Triangle |
+| Enter | Start |
+| Right Shift | Select |
+| Q / W | L1 / R1 |
+| E / R | L2 / R2 |
+
+An SDL game controller also works — just plug it in.
+
+To reach gameplay: at the menu press **Down/Up** to highlight **Arcade**, then
+**X** to start a level. Roll the ball with the arrow keys.
+
+---
+
+## 9. Notes, gotchas, and honest status
+
+- **This is not one self-contained file.** The `.exe` has all the *code* baked
+ in, but at runtime it still needs, next to it / under the repo root:
+ `bios/SCPH1001.BIN`, the disc image (`games/kula/Kula World (Europe).cue` +
+ `.bin`), `games/kula/game.toml`, and `SDL2.dll`. It also creates
+ `card1.mcd` / `card2.mcd` for saves. Game assets (levels, textures, audio,
+ video) live on the disc image and are streamed through the simulated CD-ROM
+ — the recomp translates the *program*, not the *data*.
+- **BIOS must be SCPH1001.** The CD-controller version fix (`94 09 19 C0`) is
+ specific to that BIOS revision. A different BIOS dump may misbehave.
+- **Verified working:** boot → menu → navigation → select Arcade → 3D level →
+ ball control. **Not yet verified:** completing a level and all mechanics,
+ audio output, FMV/MDEC video, in-practice memory-card save/load. Treat those
+ as untested, not as known-good. If something breaks there, that is new work —
+ diagnose it per the `CLAUDE.md` rules (oracle-verify against Beetle PSX; no
+ printf, no stubs), don't paper over it.
+- **If the recompiler or a build step fails**, fix the tool/root cause — do not
+ route around it (CLAUDE.md §15).
+- Building `psx-beetle` (the Beetle PSX oracle) is **optional** and only needed
+ for cross-checking against real-hardware behavior, not for playing. Skip it
+ unless you are debugging a divergence.
+
+---
+
+### One-shot summary for the agent
+
+> Clone the fork, check out `claude/repo-commit-history-1oayou`, install the
+> MSYS2 mingw64 toolchain + SDL2, have the user drop in `SCPH1001.BIN` and their
+> Kula `.bin`/`.cue`, extract `SCES_010.00` from the disc, build the recompiler,
+> generate the BIOS C and game C, build the `kula-runtime` target with the
+> launcher/vulkan/debug options off, then run `kula-runtime.exe` from the repo
+> root and confirm the menu appears and Arcade launches a 3D level.
diff --git a/docs/assets/kula-world-macos.png b/docs/assets/kula-world-macos.png
new file mode 100644
index 00000000..85a6eee6
Binary files /dev/null and b/docs/assets/kula-world-macos.png differ
diff --git a/docs/assets/kula-world-titlescreen.png b/docs/assets/kula-world-titlescreen.png
new file mode 100644
index 00000000..7a42726b
Binary files /dev/null and b/docs/assets/kula-world-titlescreen.png differ
diff --git a/docs/beetle-linux.md b/docs/beetle-linux.md
new file mode 100644
index 00000000..3134315e
--- /dev/null
+++ b/docs/beetle-linux.md
@@ -0,0 +1,46 @@
+# Building psx-beetle on Linux
+
+The psx-beetle oracle builds and runs on Linux with the same wire protocol
+as on Windows. Verified 2026-07-02 (Kula World boots to demo gameplay;
+ping/screenshot served over the TCP debug protocol).
+
+## beetle-psx checkout
+
+`runtime/src/beetle_libretro.cpp` targets the C++ beetle-psx tree. Upstream
+master has since been converted to plain C (`libretro.cpp` -> `libretro.c`,
+`PS_CPU` class dropped), which no longer compiles against our integration.
+Pin the checkout to the last compatible commit — the same base
+`docs/beetle_wtrace_hook.patch` was generated from:
+
+```bash
+git clone https://github.com/libretro/beetle-psx-libretro.git beetle-psx
+cd beetle-psx
+git checkout 5759277b # "audit pass" — last C++-tree base we target
+patch -p1 < ../docs/beetle_wtrace_hook.patch
+patch -p1 < ../docs/beetle_sio_trace_hook.patch
+patch -p1 < ../docs/beetle_cdcmd_trace_hook.patch
+```
+
+`beetle_cdcmd_trace_hook.patch` adds a CD-command trace callback (fires per
+command dispatch in cdc.cpp) exposed as the `cdrom_cmd_dump` / `cdrom_cmd_reset`
+debug commands — used to oracle-diff the Kula World CD read sequence.
+
+`beetle_sio_trace_hook.patch` adds `FrontIO::SetSIOTraceCallback` (fires per
+completed SIO byte exchange) — an integration hook beetle_libretro.cpp needs
+that predated the committed wtrace patch.
+
+## Build
+
+```bash
+# Static lib. On unix the artifact is named mednafen_psx_libretro.so but is
+# an ar archive (STATIC_LINKING=1); stage it under the name cmake expects.
+make platform=unix STATIC_LINKING=1 HAVE_LIGHTREC=0 -j"$(nproc)"
+cp mednafen_psx_libretro.so libmednafen_psx.a
+
+cd ../runtime
+cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_LAUNCHER=OFF -DPSX_DEBUG_TOOLS=ON
+ninja -C build psx-beetle
+
+# Headless run (picks the next free port from 4380 if taken; watch stderr)
+xvfb-run -a ./runtime/build/psx-beetle ../bios/SCPH1001.BIN --disc
+```
diff --git a/docs/beetle_cdcmd_trace_hook.patch b/docs/beetle_cdcmd_trace_hook.patch
new file mode 100644
index 00000000..58f1da54
--- /dev/null
+++ b/docs/beetle_cdcmd_trace_hook.patch
@@ -0,0 +1,87 @@
+diff --git a/libretro.cpp b/libretro.cpp
+index d2e82bca..e9c867c8 100644
+--- a/libretro.cpp
++++ b/libretro.cpp
+@@ -508,6 +508,11 @@ PS_SPU *PSX_SPU = NULL;
+ PS_CDC *PSX_CDC = NULL;
+ FrontIO *PSX_FIO = NULL;
+
++/* PSXRecomp oracle hooks: NULL when no listener attached. */
++psxrecomp_wtrace_cb_t g_psxrecomp_wtrace_cb = NULL;
++psxrecomp_fntrace_cb_t g_psxrecomp_fntrace_cb = NULL;
++psxrecomp_cdcmd_cb_t g_psxrecomp_cdcmd_cb = NULL;
++
+ MultiAccessSizeMem<512 * 1024, uint32, false> *BIOSROM = NULL;
+ MultiAccessSizeMem<65536, uint32, false> *PIOMem = NULL;
+ MultiAccessSizeMem<2048 * 1024, uint32, false> *MainRAM = NULL;
+diff --git a/mednafen/psx/cdc.cpp b/mednafen/psx/cdc.cpp
+index 1daaa5b7..a6b31b66 100644
+--- a/mednafen/psx/cdc.cpp
++++ b/mednafen/psx/cdc.cpp
+@@ -1318,6 +1318,16 @@ int32_t PS_CDC::Update(const int32_t timestamp)
+ else
+ {
+ const CDC_CTEntry *command = &Commands[PendingCommand];
++ /* PSXRecomp oracle hook: CD command about to execute.
++ * PC of the guest at dispatch time — the command was
++ * WRITTEN earlier (latency), so this is the poll/idle
++ * PC, but it still localizes kernel vs shell vs game. */
++ if(MDFN_UNLIKELY(g_psxrecomp_cdcmd_cb))
++ g_psxrecomp_cdcmd_cb(PendingCommand, ArgsReceiveIn,
++ ArgsReceiveIn > 0 ? ArgsReceiveBuf[0] : 0,
++ ArgsReceiveIn > 1 ? ArgsReceiveBuf[1] : 0,
++ ArgsReceiveIn > 2 ? ArgsReceiveBuf[2] : 0,
++ PSX_CPU ? PSX_CPU->GetRegister(PS_CPU::GSREG_PC, NULL, 0) : 0);
+ next_time = (this->*(command->func))(ArgsReceiveIn, ArgsReceiveBuf);
+ PendingCommandPhase = 2;
+ }
+diff --git a/mednafen/psx/psx.h b/mednafen/psx/psx.h
+index 704c0a19..fc53362d 100644
+--- a/mednafen/psx/psx.h
++++ b/mednafen/psx/psx.h
+@@ -137,4 +137,45 @@ extern unsigned psx_gpu_overclock_shift;
+ extern uint8_t analog_combo[2];
+ extern uint8_t analog_combo_hold;
+
++/* PSXRecomp oracle: SIO byte trace callback.
++ * Fired by FrontIO when a complete byte exchange finishes. */
++class FrontIO;
++extern FrontIO *PSX_FIO;
++typedef void (*psxrecomp_sio_trace_cb_t)(uint8_t tx, uint8_t rx, uint16_t ctrl);
++
++/* PSXRecomp oracle: RAM write trace callback.
++ * Fired by PS_CPU::SB/SH/SW after the write completes.
++ * addr = virtual address (caller masks for physical filter)
++ * value = stored value (sized by `size`)
++ * pc = PC of the executing store instruction
++ * ra = $ra (GPR[31]) at time of store, for one frame of caller context
++ * size = 1, 2, or 4 (byte / halfword / word) */
++typedef void (*psxrecomp_wtrace_cb_t)(uint32_t addr, uint32_t value,
++ uint32_t pc, uint32_t ra, uint8_t size);
++extern psxrecomp_wtrace_cb_t g_psxrecomp_wtrace_cb;
++
++/* PSXRecomp oracle: function-call (JAL/JALR/J/JR) trace callback.
++ * Fired by PS_CPU on every J/JAL/JR/JALR retire, BEFORE the link write.
++ * Caller filters by target_pc to keep volume bounded.
++ * caller_pc = PC of the J/JAL/JR/JALR instruction
++ * target_pc = computed branch target
++ * parent_ra = GPR[31] at time of call (parent's return address)
++ * a0, a1 = argument registers at time of call
++ * kind = 1=J, 2=JAL, 3=JR, 4=JALR */
++typedef void (*psxrecomp_fntrace_cb_t)(uint32_t caller_pc, uint32_t target_pc,
++ uint32_t parent_ra, uint32_t a0,
++ uint32_t a1, uint8_t kind);
++extern psxrecomp_fntrace_cb_t g_psxrecomp_fntrace_cb;
++
++/* PSXRecomp oracle: CD command trace callback.
++ * Fired by PS_CDC just before a command handler executes.
++ * cmd = command byte (0x01 GetStat, 0x02 Setloc, 0x06 ReadN, 0x09 Pause,
++ * 0x0A Init, 0x0E Setmode, 0x15 SeekL, 0x1A GetID, 0x1B ReadS, ...)
++ * nargs = number of argument bytes
++ * arg0..arg2 = first three argument bytes (0 if absent) */
++typedef void (*psxrecomp_cdcmd_cb_t)(uint8_t cmd, uint8_t nargs,
++ uint8_t arg0, uint8_t arg1, uint8_t arg2,
++ uint32_t pc);
++extern psxrecomp_cdcmd_cb_t g_psxrecomp_cdcmd_cb;
++
+ #endif
diff --git a/docs/beetle_sio_trace_hook.patch b/docs/beetle_sio_trace_hook.patch
new file mode 100644
index 00000000..935b5315
--- /dev/null
+++ b/docs/beetle_sio_trace_hook.patch
@@ -0,0 +1,35 @@
+diff --git a/mednafen/psx/frontio.cpp b/mednafen/psx/frontio.cpp
+index a35b022f..02e7ce0b 100644
+--- a/mednafen/psx/frontio.cpp
++++ b/mednafen/psx/frontio.cpp
+@@ -783,6 +783,10 @@ int32_t FrontIO::Update(int32_t timestamp)
+ ReceiveInProgress = false;
+ ReceiveBufferAvail = true;
+
++ /* PSXRecomp oracle hook: byte exchange complete. */
++ if(MDFN_UNLIKELY(sio_trace_cb))
++ sio_trace_cb(TransmitBuffer, ReceiveBuffer, (uint16_t)Control);
++
+ if(Control & 0x800)
+ {
+ istatus = true;
+diff --git a/mednafen/psx/frontio.h b/mednafen/psx/frontio.h
+index 8a67f5af..d9a260ca 100644
+--- a/mednafen/psx/frontio.h
++++ b/mednafen/psx/frontio.h
+@@ -95,8 +95,15 @@ class FrontIO
+
+ int StateAction(StateMem* sm, int load, int data_only);
+
++ /* PSXRecomp oracle hook: fires once per completed SIO byte exchange
++ * (tx byte sent, rx byte received, current Control register). */
++ typedef void (*sio_trace_cb_t)(uint8_t tx, uint8_t rx, uint16_t ctrl);
++ void SetSIOTraceCallback(sio_trace_cb_t cb) { sio_trace_cb = cb; }
++
+ private:
+
++ sio_trace_cb_t sio_trace_cb = nullptr;
++
+ void DoDSRIRQ(void);
+ void CheckStartStopPending(int32_t timestamp, bool skip_event_set = false);
+
diff --git a/docs/kula-world-findings.md b/docs/kula-world-findings.md
new file mode 100644
index 00000000..a56199c7
--- /dev/null
+++ b/docs/kula-world-findings.md
@@ -0,0 +1,331 @@
+# Kula World (SCES-01000) — bring-up findings
+
+Status: SOLVED — Kula World boots to its title screen + menu (Arcade / Time
+Trial / Load Game). Five bugs found and fixed this session; the final one was
+the wedge.
+
+## Setup
+
+- `games/kula/game.toml` — SCES-01000, load 0x80011000, entry 0x80069F60
+ (KUSEG header addresses normalized by the recompiler; see the PS-X EXE
+ parser change).
+- Recompile: 5297 functions, generated C compiles clean.
+- Runtime target `kula-runtime` (runtime/CMakeLists.txt), Linux + Xvfb.
+- Oracle: `psx-beetle` (docs/beetle-linux.md) boots the same dump + BIOS
+ to demo gameplay — ground truth for every comparison below.
+
+## Bug 1 — RAM mirror not modeled (FIXED)
+
+crt0 parks $sp in the 4th RAM mirror (0x807FFFF8). Guest accesses to the
+0x00200000..0x007FFFFF mirrors fell into the open-bus no-op: stack writes
+vanished, $ra read back 0, game jumped to address 0 at frame ~891.
+Fix: `psx_phys_addr()` folds the 4x mirror before the RAM bounds check
+(runtime/src/memory.c). Diagnosed via the null-dispatch capture-freeze on
+the fntrace ring (runtime/src/fntrace.c).
+
+## Bug 2 — GetID region hardcoded (FIXED)
+
+GetID's last four response bytes were hardcoded 'SCEI' (NTSC-J). The
+kernel CD driver revalidates disc region (ReadTOC + GetID) after the
+game's first file load; a PAL disc reporting 'SCEI' threw it into an
+endless GetStat/Init loop — CD froze at sector 307 (LBA 318, just before
+the HIRO level directory at LBA 319), black screen.
+Fix: `cdrom_set_disc_scex()` fed at launch from the disc's SYSTEM.CNF
+serial via the existing disc_identity module (PAL->SCEE etc). Verified:
+"disc region PAL (serial SCES-01000)"; the game advanced from a hard pin
+at frame 890 to 11000+, CD position reached LBA 319.
+
+## Bug 3 — game's CD-init retry loop never completes (OPEN)
+
+After the region fix the game runs its main loop (VBLANK ticks,
+I_MASK=0x0D, funcs cycling) but never renders: GPU draws frozen at 10054,
+no new GPU DMA, no ReadN ever issued, sectors frozen at 307.
+
+The game's OWN CD driver (game code, all commands issued from pc 0x63A14,
+not the BIOS) runs a slow retry state machine, ~150 frames (2.5 s) between
+every command:
+
+ frame 890 : BIOS ReadTOC + GetID (region revalidation, now passes)
+ frame 1040 : GetStat -> stat 0x02
+ frame 1190 : Init -> stat 0x02
+ frame 1438 : GetStat -> stat 0x02
+ frame 1586 : Init -> stat 0x02
+ ... GetStat/Init alternating, then transitions to ...
+ frame 2971+: Setloc(LBA 16 = ISO PVD) -> Pause, repeating 28x
+
+KEY FINDING: every CD response is CORRECT. GetStat/Init/Pause all return
+stat=0x02 (motor on, no error, idle) — exactly what a healthy idle drive
+reports — and the INT3/INT2 ACK/COMPLETE pair fires for each. The game
+reads the right bytes and STILL re-loops. So this is NOT a wrong-response
+-value bug (unlike bug 2).
+
+Because the responses are correct, the game must be waiting on a TIMING or
+EVENT condition, not a status value:
+ - a per-poll counter / elapsed-time threshold it never reaches, or
+ - an interrupt/event delivery (the Init INT2 COMPLETE, or a CD-IRQ-set
+ "command done" flag its ISR latches) that is subtly mis-delivered, so
+ the main loop's "init done" check never trips and it retries forever.
+
+The 2.5 s cadence is the tell: that is a retry-with-timeout, i.e. the game
+issues a command, waits ~2.5 s for a completion signal, times out, retries.
+The signal it waits for is what our controller/IRQ path delivers wrong.
+
+### Oracle-diff result (DONE — divergence localized)
+
+Built a CD-command trace hook into psx-beetle (beetle_cdcmd_trace_hook.patch:
+a callback in cdc.cpp's command dispatch + `cdrom_cmd_dump` / `cdrom_cmd_reset`
+debug commands on the oracle) and captured its boot->demo CD command stream.
+
+The oracle's game reads level data with a clean per-sector pattern:
+ Setloc(LBA) -> SeekL -> Setmode(0x80) -> ReadN -> Pause (x22 ReadN)
+plus GetlocL (0x10) position polling (x89). It issues SeekL 18x and ReadN 22x.
+
+Our runtime's game, in the wedge window, issues **NEITHER SeekL NOR ReadN** —
+only GetStat/Init/Setloc/Pause. So our game took an error/retry branch the
+oracle never enters.
+
+CRUCIAL timeline fact: our runtime DID deliver 307 sectors successfully during
+early boot (so ReadN + sector-data delivery WORK). Reading only stops at
+frame ~890 — the moment the BIOS does its mid-game region revalidation
+(ReadTOC + GetID). After that revalidation the game can never resume reading;
+it drops into the GetStat/Init/Setloc/Pause retry loop and stays there.
+
+So the root cause is narrowed to: the BIOS ReadTOC+GetID revalidation (or the
+CD-controller state it leaves behind — drive repositioned to lead-in / TOC,
+stat/mode reset) prevents the game's CD driver from resuming reads. The region
+fix got the revalidation to PASS, but the post-revalidation CD state still
+breaks the resume. The oracle does not perform this mid-game revalidation in
+the same way, so it reads continuously.
+
+### Sharper localization (DONE) — spurious BIOS disc re-identify
+
+Full oracle command trace confirms: the oracle NEVER issues ReadTOC (0x1E).
+Our runtime issues it at frame 890, from the BIOS (Pause@BFC05FE8 ->
+ReadTOC@BFC0D760 -> GetID@BFC0D7F0). The sector reads right before are clean
+(stat=0x22 motor+read, no error/shell/seek bits), so this is NOT triggered by
+a CD error we injected.
+
+Since BOTH runtimes execute the SAME BIOS bytes, our runtime reaching the
+BFC0Dxxx disc-identify path while the oracle never does means the BIOS/game
+takes a DIFFERENT BRANCH — driven by state, not by the CD hardware sim:
+ - most likely a kernel "disc already identified" flag that our earlier boot
+ left unset/cleared (so the BIOS re-identifies), where the oracle's is set;
+ - or a recompiler-level game-logic divergence that routes our game into a
+ BIOS disc service the oracle's run never calls.
+
+After the (now-passing) re-identify, the game's own CD driver cannot resume:
+it drops into GetStat/Init then Setloc(16)/Pause and never issues SeekL/ReadN
+again — the visible wedge.
+
+Next tool: instruction/branch-level first-divergence hunt between the two
+processes (CLAUDE.md's find_first_divergence) to find where the game/BIOS
+first branches differently around frame 890. Then either set/preserve the
+kernel disc-identify flag correctly, or fix the recompiled branch. The CD
+command hook (cdrom_cmd_dump) and the fntrace/wtrace rings on both sides are
+the instruments; this is a dedicated next-session task.
+
+
+### Round 3 narrowing (self-stop trap) — the boot flows diverge from power-on
+
+New tool: PSX_CD_TRAP_CMD= [PSX_CD_TRAP_NTH=] makes the runtime
+SIGSTOP itself the instant the Nth matching CD command dispatches (gdb
+conditional breakpoints slow the emu ~100x; the self-stop costs nothing).
+Attach gdb to the frozen process at leisure.
+
+Findings from trapping ReadTOC #1 and #2 plus a full command-history dump
+(command_history ring, 8192 entries, survives the data-event flood):
+
+1. ReadTOC #1 (frame ~545) is the SHELL's disc check (pcs 0xBFC3Fxxx /
+ 0xBFC40xxx; RAM at 0x56FF0 still holds shell bytes — EXE not loaded yet).
+ Legitimate.
+2. ReadTOC #2 (frame ~890, the pre-wedge one) is issued from the BIOS BOOT
+ FLOW itself — guest stack at the trap holds ONLY BIOS-ROM return
+ addresses (Main/StartKernel chain -> 0xBFC0D5A4 -> 0xBFC0D72C), zero game
+ frames. It is the boot's post-EXE-load disc re-check, not a game call.
+3. OUR full boot command stream (from power-on):
+ Test[20], GetStat, GetID, ReadTOC, GetStat, GetID, <- shell check
+ license sectors LBA 4-5, GetID,
+ Init, PVD/dir reads, per-sector EXE load ... Pause(f889),
+ ReadTOC+GetID (f890, irq-masked, polled) <- boot re-check
+ ORACLE's full stream (same SCPH1001 bytes!):
+ Init, Init, GetTN, GetID, Setmode(0), PVD/dir reads,
+ Init x3, per-sector EXE load ... -> game reads, NO Test, NO ReadTOC ever.
+4. So the divergence does NOT start at frame 890 — the flows differ from the
+ FIRST command: the oracle's kernel does an early CdInit (Init,Init,GetTN)
+ that our recompiled BIOS never issues, and our shell uses the
+ Test+ReadTOC-based check the oracle never runs. Same ROM bytes, different
+ path => either a recompiled-BIOS branch divergence in early boot, or a
+ hardware-sim input (timer/SPU/CD status read) steering an early branch
+ differently.
+
+Next session: first-divergence hunt anchored at the FIRST CD command
+(oracle: Init; ours: Test at f539). Instrument the early boot branch that
+selects between these kernel CD-init paths. The 0xBFC0Dxxx CD-driver region
+is not covered by docs/psx_bios_disasm.txt — needs Ghidra (source of truth
+#2) or manual decode of those ROM bytes. The game-side wedge (GetStat/Init
+retry loop) is downstream of this boot-flow divergence.
+
+
+### Round 4 — status-register fix + boot-diff instrumentation status
+
+Fixed vs the oracle source (beetle cdc.cpp Read A==0): our 0x1F801800
+status register raised bit 2 (ADPBUSY, "XA-ADPCM playing") permanently —
+it must idle at 0 — and never raised bit 7 (BUSYSTS, command written but
+response not ready; now mapped to our queued-command window). Verified the
+fix does NOT change the boot command stream, so ADPBUSY was not the branch
+input steering the early boot; the fix stands on correctness regardless.
+
+Environment note: the "everything got 40x slower" mystery was real and
+environmental — leftover wedged instances before a container restart. A
+clean baseline runs ~50 fps and reaches the frame-890 re-check in ~13 s.
+Rule-15 lesson: measure the baseline before blaming the tool.
+
+Instrumentation gap found while diffing boots: the oracle's cdcmd hook has
+no guest PC, and reconstructing command writes from wtrace (index-register
+tracking) is unreliable because IRQ handlers interleave index changes.
+Also learned: pc 0x63A14 is simply the game driver's generic
+write-byte-to-CD-register routine (same pc for all registers, on the
+oracle too) — not specific to the retry loop.
+
+NEXT (small, sharp): extend the beetle cdcmd hook with the guest PC
+(PS_CPU exposes it), recapture both boot command streams with PCs, and
+diff the first divergent command's call site. Then disassemble that
+site's branch (capstone is installed) and inspect its inputs on both
+sides. All tooling for this exists after this session: self-stop trap,
+command-history ring parse, oracle cdcmd dump, oracle wtrace/fntrace.
+
+
+### Round 5 — ROOT MECHANISM FOUND: kernel flag [0xDFFC] drives the frame-890 ReadTOC
+
+New tool: PSX_FNTRACE_ALL=1 records every dispatch from power-on (fntrace.c),
+so the ring holds the earliest boot execution for offline diffs.
+
+Full mechanistic chain of the wedge, traced with the self-stop trap +
+fntrace-all + a gdb watchpoint + capstone disassembly of the ROM:
+
+1. The frame-890 ReadTOC is issued by the kernel CD-open function at
+ 0xBFC0D570 (called from the boot EXE-load path at 0xBFC06C78 / 0xBFC06E98,
+ a0=name ptr, a1=1). Its body:
+ lw $t7, -0x2004($t6) ; t7 = [0xA000DFFC]
+ beqz $t7, skip_readtoc ; flag==0 -> SKIP ReadTOC
+ jal 0xBFC0D72C ; flag!=0 -> ReadTOC (0x1E) then GetID
+2. Kernel flag [0xDFFC] (physical 0xDFFC, just below the kernel heap at
+ 0xE000) is the discriminator:
+ ours : [0xDFFC] = 1 at frame 890 -> ReadTOC -> wedge
+ oracle : [0xDFFC] = 0 at the decision -> skip -> reads continue
+ Confirmed: at frame 539 (first CD cmd) [0xDFFC]==0 on ours; it flips to 1
+ at frame 541.
+3. The setter is func_1FC1DF50 (ROM 0xBFC1DF50), called from SHELL code
+ (ra=0x80036024). Its store `sw $t6,-0x2004($t7)` with $t6=1 writes
+ [0xDFFC]=1 on the a2==7 path; a sibling path writes 0 (0xBFC1DF5C).
+4. The shells diverge: our shell runs functions at RAM 0x35000-0x39000
+ (reaching the flag-setter at 0x36024, issuing Test[0x20]/ReadTOC), while
+ the oracle's shell runs 0x30D00-0x33200 (issuing Init/GetTN) and never
+ calls the flag-setter. Same verbatim shell bytes (RAM 0x30D00 == ROM
+ 0x18D00 confirmed) -> a BRANCH divergence, driven by an input, not code.
+
+So bug 3's root is: our shell takes a different early branch, calls the CD
+control func that sets [0xDFFC]=1, and the boot's CD-open then does an extra
+ReadTOC that breaks the game's CD reads. The oracle's shell never sets the
+flag. REMAINING: the single shell branch (in 0x30000-0x35DC8) that sends our
+shell down the 0x36000 path vs the oracle's 0x30000 path, and the hardware/
+memory input it reads. That input is the true first divergence.
+
+
+### Round 6 — version fix removes the spurious ReadTOC (boot now matches oracle)
+
+func_1FC1DF50 (the shell CD-init) sets [0xDFFC]=1 when the CD controller
+version byte (Test 0x20, response[0]) is >= 0x95. Our cdrom.c returned the
+PSone-era 0x97 0x01 0x10 0xC2 (copied from beetle, which hardcodes it for all
+BIOSes) — wrong for the SCPH-1001 BIOS, whose real 1994 sub-CPU reports
+0x94 0x09 0x19 0xC0 (high byte 0x94 < 0x95). Fixed cdrom.c Test 0x20 to the
+SCPH-1001 value.
+
+RESULT: [0xDFFC] no longer becomes 1 (reads 0x6458, a pointer, like the
+oracle's 0x3660); the frame-890 ReadTOC is GONE; the boot command stream now
+matches the oracle's (Setloc->SeekL->Setmode->ReadN->Pause x8, no ReadTOC).
+This is a genuine correctness fix and closes the boot-flow divergence.
+
+BUT the game still wedges: from ~frame 1039 the GAME's own CD driver (pc
+0x63A14, game code) loops GetStat/Init every ~150 frames and never proceeds
+to SeekL/ReadN. So the frame-890 ReadTOC was NOT the wedge cause (round 5's
+conclusion was wrong) — it was a real but separate inaccuracy. The true wedge
+is downstream, in the GAME's CD-init retry, and persists with the boot now
+oracle-matched. funcs still cycle (0xf40/0x2dbc) and frames advance, so it is
+a spin-wait: the game polls a condition its CD-init never satisfies. NEXT:
+diff the game-phase CD command/response sequence (pc 0x63A14) against the
+oracle's game, which does the same GetStat/Init but then proceeds to read.
+
+
+### Round 7 — remaining wedge localized: CD IRQ pending+enabled but never serviced
+
+With the boot fixed, the visible wedge is the game's libcd CD-init retry.
+Live state during the loop (gdb):
+ i_stat = 0x05 (bit0 VBLANK + bit2 CDROM) <- CD IRQ IS raised to the CPU
+ i_mask = 0x0d (bits 0,2,3 enabled) <- CD IRQ IS enabled
+ cdrom: irq_flag=0x03 (INT3 unacked), response_count=1, response_read=0
+ funcs cycle 0xf40 (ReturnFromException) + 0x2dbc -> CPU DOES take exceptions
+
+So a CD command completed (INT3 + response waiting), the CDROM interrupt is
+pending AND unmasked, and the CPU is taking exceptions (VBLANK bit0 gets
+cleared each frame) -- yet the CDROM bit (bit2) is NEVER cleared and the
+response FIFO is never drained. The game's/libcd's CD interrupt handler in the
+BIOS IRQ chain (IntRP) is not servicing the CD IRQ. libcd's CdSync therefore
+never completes -> the GetStat/Init retry loop -> black screen.
+
+This is a distinct, deeper issue from everything fixed today (it is a BIOS-
+IRQ-chain / libcd-handler / possibly dynamic-handler-install matter, CLAUDE.md
+rule 18 territory), not a CD-response-value bug. NEXT: inspect the BIOS IntRP
+handler chain and whether libcd's CD handler is installed + run; check our CD
+IRQ presentation vs the BIOS root handler's expectations (present_cdrom_irq
+generation latch, and whether the handler that should drain the response FIFO
+and ack is being dispatched at all).
+
+## Session tally (fixes shipped)
+- Bug 1 RAM mirror (memory.c) — FIXED
+- Bug 2 GetID region (cdrom.c + main.cpp) — FIXED
+- Bug 3a CD status bits ADPBUSY/BUSYSTS (cdrom.c) — FIXED (correctness)
+- Bug 3b CD controller version 0x94.. for SCPH-1001 (cdrom.c) — FIXED; removes
+ the spurious boot ReadTOC, boot command stream now matches the oracle
+- Bug 3c (OPEN) libcd CD-IRQ not serviced -> game CD-init never completes
+Tooling built: null-dispatch fntrace freeze, PSX_CD_TRAP_CMD self-stop trap
+(runtime + oracle), PSX_FNTRACE_ALL boot trace, oracle cdrom_cmd_dump hook,
+Linux port of runtime+oracle, beetle-linux build docs + patches.
+
+
+### Round 8 — ROOT CAUSE FOUND AND FIXED: dirty-RAM interpreter interrupt latency
+
+The wedge: at ~frame 1039 the game froze in bursts of ~14s, then advanced.
+During each freeze:
+ SR = 0x40000401 (IEc=1, IM2=1) -> interrupts enabled + hw line unmasked
+ Cause = 0x400 (IP2=1) -> hardware interrupt pending
+ i_stat & i_mask = 0x5 -> VBLANK + CDROM pending and enabled
+ in_exception=0, cooldown irrelevant
+ total_checks FROZEN -> psx_check_interrupts is NOT being called
+ g_dirty_ram_insns_run advancing ~15M/s, blocks_run ~2M/s
+
+So the MIPS interrupt-take condition (IEc && IM2 && IP2) was fully satisfied,
+yet no interrupt was taken because psx_check_interrupts was never called. The
+CPU was in the DIRTY-RAM INTERPRETER running libcd's CdSync spin-wait (a guest
+loop that lives entirely in dirty RAM), re-entering the interpreter once per
+short (~7-insn) block. The interpreter's only interrupt poll was on the
+local-dirty-flow path gated by the PER-INVOCATION `(insns_executed & 0xFFF)`
+counter, which for short blocks never reaches 0x1000 and, when the loop exits
+to dispatch each iteration, is never reached at all. So the spin ran millions
+of instructions with no interrupt poll; the CD IRQ that sets libcd's wait-flag
+was never serviced; the loop only broke when a rare timeout re-dispatched.
+
+FIX (runtime/src/dirty_ram_interp.c): poll interrupts at the interpreter's
+per-invocation entry, gated by a GLOBAL invocation counter (every 64 entries),
+with cpu->pc set so the resume is coherent. This is the interpreter analogue
+of a static block-leader poll. psx_check_interrupts runs the handler and
+returns with registers restored, so the loop safely continues and immediately
+sees its wait-flag set.
+
+RESULT: Kula World boots to the title screen and menu (Arcade / Time Trial /
+Load Game / Back). Pixels on screen — bug 3 SOLVED.
+
+Why this only bit Kula and not Tomba/MMX6: Kula uses the BIOS/libcd CD path
+whose CdSync spin-waits in a dirty-RAM loop; the working titles use their own
+CD drivers / loop shapes that hit the interrupt poll differently. This fix is
+general — any dirty-RAM guest spin-loop now yields to interrupts.
diff --git a/games/kula/game.toml b/games/kula/game.toml
new file mode 100644
index 00000000..21d3c8fc
--- /dev/null
+++ b/games/kula/game.toml
@@ -0,0 +1,26 @@
+# Kula World (Europe) — SCES-01000
+# Values read from the PS-X EXE header of SCES_010.00 (extracted from the
+# disc root, LBA 24). Note: the header stores KUSEG addresses (0x00011000);
+# the parser normalizes them to KSEG0.
+
+[game]
+name = "Kula World"
+id = "SCES-01000"
+exe = "games/kula/SCES_010.00"
+load_address = "0x80011000"
+entry_pc = "0x80069F60"
+text_size = "0x93000"
+stack_base = "0x801FFFF0"
+disc = "games/kula/Kula World (Europe).cue"
+
+[recompiler]
+# No Ghidra seeds yet — discovery walks the call graph from entry_pc alone.
+seeds = "games/kula/seeds.txt"
+out_dir = "games/kula/generated"
+strict = true
+
+[runtime]
+debug_port = 4370
+window_title = "Kula World (PSXRecomp)"
+controller = "digital"
+memcard_dir = "."
diff --git a/games/kula/seeds.txt b/games/kula/seeds.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/recompiler/src/main_bios.cpp b/recompiler/src/main_bios.cpp
index b8588bc9..9f410a9f 100644
--- a/recompiler/src/main_bios.cpp
+++ b/recompiler/src/main_bios.cpp
@@ -304,7 +304,15 @@ std::string find_c_compiler(const std::optional& cli_override) {
static const std::array candidates = { "gcc", "clang", "cl" };
for (const char* c : candidates) {
// Probe with --version. We don't parse output; non-zero exit means not present.
- const std::string probe = std::string(c) + " --version >NUL 2>&1";
+ // The null device differs by platform: NUL on Windows, /dev/null on
+ // POSIX. Using ">NUL" on macOS/Linux both misdirects the redirect and
+ // leaves a stray file named "NUL" in the cwd.
+#ifdef _WIN32
+ const std::string null_dev = "NUL";
+#else
+ const std::string null_dev = "/dev/null";
+#endif
+ const std::string probe = std::string(c) + " --version >" + null_dev + " 2>&1";
if (std::system(probe.c_str()) == 0) {
return c;
}
diff --git a/recompiler/src/ps1_exe_parser.cpp b/recompiler/src/ps1_exe_parser.cpp
index d4cdad8b..65b23144 100644
--- a/recompiler/src/ps1_exe_parser.cpp
+++ b/recompiler/src/ps1_exe_parser.cpp
@@ -160,6 +160,22 @@ std::optional PS1ExeParser::parse_buffer(
// Copy header (first 2048 bytes)
std::memcpy(&exe.header, buffer.data(), sizeof(PS1ExeHeader));
+ // Some EXEs (e.g. Kula World SCES-01000) store KUSEG addresses
+ // (0x00011000) instead of KSEG0 (0x80011000). KUSEG 0x0-0x1FFFFFFF
+ // mirrors the same physical RAM, so normalize to KSEG0 before
+ // validation. Only remap addresses that fall inside the 2 MB RAM
+ // mirror; 0 stays 0 (unused fields like initial_gp).
+ auto to_kseg0 = [](uint32_t& addr) {
+ if (addr != 0 && addr < 0x00200000) addr |= 0x80000000;
+ };
+ to_kseg0(exe.header.initial_pc);
+ to_kseg0(exe.header.initial_gp);
+ to_kseg0(exe.header.load_address);
+ to_kseg0(exe.header.memfill_start);
+ to_kseg0(exe.header.initial_sp);
+ to_kseg0(exe.header.initial_fp);
+ to_kseg0(exe.header.stack_base);
+
// Validate header
if (!validate_header(exe.header, error_msg)) {
return std::nullopt;
diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt
index 31a359f3..e09f601b 100644
--- a/runtime/CMakeLists.txt
+++ b/runtime/CMakeLists.txt
@@ -113,3 +113,23 @@ if(NOT MSVC AND EXISTS "${BEETLE_LIB}")
elseif(NOT MSVC)
message(STATUS "psx-beetle: libmednafen_psx.a not found - skipping psx-beetle")
endif()
+
+# ---- Kula World target (SCES-01000) ----
+#
+# Only defined when the locally-recompiled game output exists (the disc dump,
+# EXE, and generated C are untracked — see games/*/* in .gitignore), so a
+# fresh clone without the game still configures cleanly.
+
+set(KULA_GENERATED "${PSXRECOMP_ROOT}/games/kula/generated")
+if(EXISTS "${KULA_GENERATED}/SCES_010.00_full.c")
+ psxrecomp_add_runtime_target(kula-runtime
+ GAME_GENERATED_FULL_C "${KULA_GENERATED}/SCES_010.00_full.c"
+ GAME_GENERATED_DISPATCH_C "${KULA_GENERATED}/SCES_010.00_dispatch.c"
+ DEBUG_PORT 4370
+ WINDOW_TITLE "Kula World (PSXRecomp)"
+ DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN"
+ DEFAULT_GAME_CONFIG_PATH "${PSXRECOMP_ROOT}/games/kula/game.toml"
+ )
+else()
+ message(STATUS "kula-runtime: games/kula/generated/SCES_010.00_full.c not found - skipping")
+endif()
diff --git a/runtime/include/cdrom.h b/runtime/include/cdrom.h
index 265a1d6b..7740bba7 100644
--- a/runtime/include/cdrom.h
+++ b/runtime/include/cdrom.h
@@ -8,6 +8,9 @@ extern "C" {
#endif
void cdrom_init(const char* cue_path);
+/* Disc license region for GetID ("SCEE"/"SCEA"/"SCEI"); derived from the
+ * mounted disc's boot serial at launch. Defaults to "SCEA" (SCPH1001). */
+void cdrom_set_disc_scex(const char scex[4]);
/* Set disc speed multiplier immediately. divisor=0 → instant, 1 → 1x, 2 → 2x. */
void cdrom_set_speed(int divisor);
diff --git a/runtime/src/autocompile.c b/runtime/src/autocompile.c
index b432f3ae..52a804f3 100644
--- a/runtime/src/autocompile.c
+++ b/runtime/src/autocompile.c
@@ -5,6 +5,7 @@
#include
#include
+#include /* getenv — declared here; glibc/windows.h leak it, macOS SDK does not */
#include
#ifdef _WIN32
diff --git a/runtime/src/beetle_debug_server.c b/runtime/src/beetle_debug_server.c
index 82ec2060..d9a0478b 100644
--- a/runtime/src/beetle_debug_server.c
+++ b/runtime/src/beetle_debug_server.c
@@ -67,6 +67,15 @@ extern uint32_t beetle_get_sio_trace(uint32_t *out_seq, uint8_t *out_tx,
extern uint32_t beetle_get_sio_trace_total(void);
extern void beetle_reset_sio_trace(void);
+/* CD command trace */
+extern uint32_t beetle_get_cdcmd_trace(uint32_t *out_seq, uint8_t *out_cmd,
+ uint8_t *out_nargs, uint8_t *out_a0,
+ uint8_t *out_a1, uint8_t *out_a2,
+ uint32_t *out_pc,
+ int max_count);
+extern uint32_t beetle_get_cdcmd_trace_total(void);
+extern void beetle_reset_cdcmd_trace(void);
+
/* wtrace */
extern int beetle_wtrace_arm(uint32_t lo, uint32_t hi);
extern int beetle_wtrace_disarm(int slot);
@@ -471,6 +480,41 @@ static void h_sio_trace(int id, const char *json) {
free(seqs); free(txs); free(rxs); free(ctrl);
}
+static void h_cdrom_cmd_dump(int id, const char *json) {
+ int count = json_get_int(json, "count", 256);
+ if (count < 1) count = 1;
+ if (count > 8192) count = 8192;
+
+ uint32_t *seqs = (uint32_t*)malloc(count * sizeof(uint32_t));
+ uint32_t *pcs = (uint32_t*)malloc(count * sizeof(uint32_t));
+ uint8_t *cmds = (uint8_t*)malloc(count), *nargs = (uint8_t*)malloc(count);
+ uint8_t *a0 = (uint8_t*)malloc(count), *a1 = (uint8_t*)malloc(count), *a2 = (uint8_t*)malloc(count);
+ if (!seqs || !pcs || !cmds || !nargs || !a0 || !a1 || !a2) {
+ free(seqs); free(pcs); free(cmds); free(nargs); free(a0); free(a1); free(a2);
+ send_err(id, "alloc"); return;
+ }
+ uint32_t got = beetle_get_cdcmd_trace(seqs, cmds, nargs, a0, a1, a2, pcs, count);
+ uint32_t total = beetle_get_cdcmd_trace_total();
+
+ send_fmt("{\"id\":%d,\"ok\":true,\"total\":%u,\"count\":%u,\"entries\":[",
+ id, total, got);
+ for (uint32_t i = 0; i < got; i++) {
+ if (i > 0) send_fmt(",");
+ send_fmt("{\"seq\":%u,\"cmd\":\"0x%02X\",\"nargs\":%u,"
+ "\"a0\":\"0x%02X\",\"a1\":\"0x%02X\",\"a2\":\"0x%02X\","
+ "\"pc\":\"0x%08X\"}",
+ seqs[i], cmds[i], nargs[i], a0[i], a1[i], a2[i], pcs[i]);
+ }
+ send_fmt("]}\n");
+ free(seqs); free(pcs); free(cmds); free(nargs); free(a0); free(a1); free(a2);
+}
+
+static void h_cdrom_cmd_reset(int id, const char *json) {
+ (void)json;
+ beetle_reset_cdcmd_trace();
+ send_ok(id);
+}
+
static void h_sio_write_window(int id, const char *json) {
int which = json_get_int(json, "which", 0);
int before = json_get_int(json, "before", 4);
@@ -1490,6 +1534,8 @@ static const CmdEntry CMDS[] = {
{ "vram_peek", h_vram_peek },
{ "sio_trace_reset", h_sio_trace_reset },
{ "sio_trace", h_sio_trace },
+ { "cdrom_cmd_dump", h_cdrom_cmd_dump },
+ { "cdrom_cmd_reset", h_cdrom_cmd_reset },
{ "sio_write_window", h_sio_write_window },
/* wtrace — normalized verb set (parity contract with psx-runtime). */
{ "wtrace_arm", h_wtrace_arm },
diff --git a/runtime/src/beetle_libretro.cpp b/runtime/src/beetle_libretro.cpp
index cc879663..1d68b418 100644
--- a/runtime/src/beetle_libretro.cpp
+++ b/runtime/src/beetle_libretro.cpp
@@ -15,6 +15,7 @@
#include
#include
#include
+#include
#include "libretro.h"
#include "mednafen/psx/psx.h"
@@ -59,6 +60,36 @@ static void sio_trace_callback(uint8_t tx, uint8_t rx, uint16_t ctrl) {
s_sio_trace_idx = (s_sio_trace_idx + 1) % BEETLE_SIO_TRACE_CAP;
}
+/* ---- CD command trace (oracle-diff for the Kula World CD-init wedge) ---- */
+#define BEETLE_CDCMD_TRACE_CAP 8192
+struct BeetleCdcmdEntry {
+ uint32_t seq; uint32_t pc; uint8_t cmd, nargs, a0, a1, a2;
+};
+static BeetleCdcmdEntry s_cdcmd_trace[BEETLE_CDCMD_TRACE_CAP];
+static int s_cdcmd_idx = 0;
+static uint32_t s_cdcmd_seq = 0;
+static void cdcmd_trace_callback(uint8_t cmd, uint8_t nargs,
+ uint8_t a0, uint8_t a1, uint8_t a2,
+ uint32_t pc) {
+ /* Symmetric self-stop trap (mirror of the runtime's PSX_CD_TRAP_CMD):
+ * SIGSTOP on the Nth matching CD command so a debugger can freeze the
+ * oracle at the exact boot point and diff its state against ours. */
+ {
+ static long trap_cmd = -2, trap_nth = 1, hits = 0;
+ if (trap_cmd == -2) {
+ const char *e2 = getenv("PSX_CD_TRAP_CMD");
+ trap_cmd = (e2 && *e2) ? strtol(e2, NULL, 0) : -1;
+ const char *e3 = getenv("PSX_CD_TRAP_NTH");
+ if (e3 && *e3) trap_nth = strtol(e3, NULL, 0);
+ }
+ if ((long)cmd == trap_cmd && ++hits == trap_nth) raise(SIGSTOP);
+ }
+ BeetleCdcmdEntry *e = &s_cdcmd_trace[s_cdcmd_idx];
+ e->seq = s_cdcmd_seq++; e->pc = pc; e->cmd = cmd; e->nargs = nargs;
+ e->a0 = a0; e->a1 = a1; e->a2 = a2;
+ s_cdcmd_idx = (s_cdcmd_idx + 1) % BEETLE_CDCMD_TRACE_CAP;
+}
+
/* ---- wtrace_all ring (ALWAYS-ON; no filter; captures every write) ----
* Per global rule "ring buffers always-on": every write hits this ring
* unconditionally so probes connected late can still query the last ~1s
@@ -527,6 +558,8 @@ extern "C" int beetle_init_with_disc(const char *bios_path, const char *disc_pat
g_psxrecomp_rtrace_cb = rtrace_callback;
g_psxrecomp_irq_cb = irq_event_callback;
std::fprintf(stderr, "[psx-beetle] wtrace + fntrace + rtrace + irq callbacks registered\n");
+ g_psxrecomp_cdcmd_cb = cdcmd_trace_callback;
+ std::fprintf(stderr, "[psx-beetle] wtrace + fntrace + cdcmd callbacks registered\n");
std::fflush(stderr);
return 0;
@@ -693,6 +726,36 @@ extern "C" void beetle_reset_sio_trace(void) {
memset(s_sio_trace, 0, sizeof(s_sio_trace));
}
+/* ---- CD command trace accessors ---- */
+extern "C" uint32_t beetle_get_cdcmd_trace(uint32_t *out_seq, uint8_t *out_cmd,
+ uint8_t *out_nargs, uint8_t *out_a0,
+ uint8_t *out_a1, uint8_t *out_a2,
+ uint32_t *out_pc,
+ int max_count)
+{
+ int avail = (int)(s_cdcmd_seq < (uint32_t)BEETLE_CDCMD_TRACE_CAP
+ ? s_cdcmd_seq : BEETLE_CDCMD_TRACE_CAP);
+ int count = max_count < avail ? max_count : avail;
+ int start = (s_cdcmd_idx - count + BEETLE_CDCMD_TRACE_CAP) % BEETLE_CDCMD_TRACE_CAP;
+ for (int i = 0; i < count; i++) {
+ const BeetleCdcmdEntry *e = &s_cdcmd_trace[(start + i) % BEETLE_CDCMD_TRACE_CAP];
+ out_seq[i] = e->seq;
+ out_cmd[i] = e->cmd;
+ out_nargs[i] = e->nargs;
+ out_a0[i] = e->a0;
+ out_a1[i] = e->a1;
+ out_a2[i] = e->a2;
+ out_pc[i] = e->pc;
+ }
+ return (uint32_t)count;
+}
+extern "C" uint32_t beetle_get_cdcmd_trace_total(void) { return s_cdcmd_seq; }
+extern "C" void beetle_reset_cdcmd_trace(void) {
+ s_cdcmd_idx = 0;
+ s_cdcmd_seq = 0;
+ memset(s_cdcmd_trace, 0, sizeof(s_cdcmd_trace));
+}
+
/* ---- wtrace accessors ---- */
extern "C" int beetle_wtrace_arm(uint32_t lo, uint32_t hi) {
if (s_wtrace_range_count >= BEETLE_WTRACE_MAX_RANGES) return -1;
diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c
index 3ab86e82..0cc422b4 100644
--- a/runtime/src/cdrom.c
+++ b/runtime/src/cdrom.c
@@ -16,6 +16,9 @@
#include "event_ring.h"
#include
#include
+#ifndef _WIN32
+# include
+#endif
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include
@@ -49,6 +52,21 @@ static uint8_t request_reg;
static uint8_t irq_enable;
static uint8_t irq_flag;
+/* Disc license region string returned in GetID's last four response bytes
+ * ("SCEE" PAL / "SCEA" NTSC-U / "SCEI" NTSC-J). Real hardware reports the
+ * region of the INSERTED DISC (mechacon reads it from the license area);
+ * the BIOS CD driver revalidates it when the kernel CD subsystem
+ * reinitializes mid-game, and a mismatch throws it into an endless
+ * GetStat/Init retry loop (Kula World wedged at its first level load this
+ * way). Set from the mounted disc's SYSTEM.CNF serial at launch
+ * (main.cpp); the default matches the console region of the one supported
+ * BIOS (SCPH1001, NTSC-U). */
+static uint8_t disc_scex[4] = { 'S', 'C', 'E', 'A' };
+
+void cdrom_set_disc_scex(const char scex[4]) {
+ memcpy(disc_scex, scex, 4);
+}
+
/* CPS-native CD interrupt single-outstanding latch.
*
* The CD controller serializes responses: it presents one INT to the CPU
@@ -972,6 +990,24 @@ static void queue_or_exec_command(uint8_t cmd) {
}
static void exec_command(uint8_t cmd) {
+#if !defined(PSX_NO_DEBUG_TOOLS) && !defined(_WIN32)
+ /* Self-stop trap: PSX_CD_TRAP_CMD= makes the process SIGSTOP
+ * itself the moment that CD command dispatches, so a debugger can
+ * attach and read the full native+guest call chain at the exact
+ * instant with ZERO run-speed cost. (A gdb conditional breakpoint on
+ * this function slows the emu two orders of magnitude via ptrace —
+ * unusable for wedges that need minutes of healthy boot first.) */
+ {
+ static long trap_cmd = -2, trap_nth = 1, trap_hits = 0;
+ if (trap_cmd == -2) {
+ const char *e = getenv("PSX_CD_TRAP_CMD");
+ trap_cmd = (e && *e) ? strtol(e, NULL, 0) : -1;
+ e = getenv("PSX_CD_TRAP_NTH"); /* stop on the Nth match (default 1st) */
+ if (e && *e) trap_nth = strtol(e, NULL, 0);
+ }
+ if ((long)cmd == trap_cmd && ++trap_hits == trap_nth) raise(SIGSTOP);
+ }
+#endif
trace_cdrom('C', 0, cmd, 0);
/* ENQUEUE: a CD command was issued (aux = command byte). */
event_ring_record_aux(EV_ENQ, (uint8_t)SRC_CD_CMD, (uint32_t)cmd);
@@ -1249,10 +1285,19 @@ static void exec_command(uint8_t cmd) {
case 0x19: /* Test */
if (param_count >= 1 && param_fifo[0] == 0x20) {
- response_push(0x97);
- response_push(0x01);
- response_push(0x10);
- response_push(0xC2);
+ /* CD controller firmware version (BCD date + region). This BIOS is
+ * SCPH-1001, whose sub-CPU reports the 1994 controller: 94/09/19 C0.
+ * The value must be < 0x95 in the high byte — the shell's CD-init
+ * (func at ROM 0x1DF50) sets kernel flag [0xA000DFFC]=1 when the
+ * version byte >= 0x95, which later makes the boot CD-open
+ * (0xBFC0D570) issue a spurious ReadTOC that wedges the game's
+ * streaming reads. Beetle hardcodes the PSone-era 0x97 regardless of
+ * BIOS, which is wrong for SCPH-1001; matching the real 1994
+ * controller keeps the flag clear (Kula World demo-load wedge). */
+ response_push(0x94);
+ response_push(0x09);
+ response_push(0x19);
+ response_push(0xC0);
set_irq(CDIRQ_ACK);
} else {
response_push(stat_reg);
@@ -1335,10 +1380,10 @@ static void process_pending(uint32_t cycles) {
response_push(0x00);
response_push(0x20);
response_push(0x00);
- response_push('S');
- response_push('C');
- response_push('E');
- response_push('I');
+ response_push(disc_scex[0]);
+ response_push(disc_scex[1]);
+ response_push(disc_scex[2]);
+ response_push(disc_scex[3]);
set_irq(CDIRQ_COMPLETE);
}
fire_cdrom_irq();
@@ -1463,11 +1508,19 @@ uint32_t cdrom_read(uint32_t addr) {
switch (addr) {
case 0x1F801800: {
uint8_t s = index_reg & 0x03;
- s |= (1 << 2); /* ADPCM empty */
+ /* Bit 2 is ADPBUSY (XA-ADPCM playback in progress), NOT "ADPCM
+ * empty" — it must idle at 0. The oracle (beetle cdc.cpp, Read
+ * A==0) never raises it. Raising it permanently made every BIOS
+ * status poll see "XA busy" and steered the kernel CD driver
+ * init down a different branch from real hardware. */
if (param_count == 0) s |= (1 << 3);
if (param_count < PARAM_FIFO_SIZE) s |= (1 << 4);
if (response_read < response_count) s |= (1 << 5);
if (data_fifo_ready()) s |= (1 << 6);
+ /* Bit 7 BUSYSTS: command written but not yet executed (our queued
+ * path; the synchronous path leaves no guest-observable window).
+ * Mirrors beetle's PendingCommandCounter/phase<=1 busy window. */
+ if (queued_cmd.pending) s |= (1 << 7);
ret = s;
break;
}
diff --git a/runtime/src/code_provider.c b/runtime/src/code_provider.c
index ed5a9041..18930588 100644
--- a/runtime/src/code_provider.c
+++ b/runtime/src/code_provider.c
@@ -7,6 +7,8 @@
* overlay_backend_resolve (env PSX_OVERLAY_BACKEND > [runtime] overlay_backend >
* auto; auto => gcc if a compile cmd is wired, else sljit). gcc is the default. */
+#include
+
#include "code_provider.h"
#include "autocompile.h"
#include "overlay_sljit.h"
diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c
index ab952c28..518c378c 100644
--- a/runtime/src/dirty_ram_interp.c
+++ b/runtime/src/dirty_ram_interp.c
@@ -2178,6 +2178,23 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
#ifndef PSX_NO_DEBUG_TOOLS
extern void debug_server_cyc_observe(uint32_t block_leader_phys);
#endif
+ /* Async-interrupt latency fix. A guest wait loop that lives ENTIRELY in
+ * dirty RAM (e.g. libcd's CdSync spin, as in Kula World) re-enters this
+ * interpreter once per short (~7-insn) block and exits before the
+ * per-invocation `(insns_executed & 0xFFF)` gate below can fire — so a
+ * pending, IEc+IM2-enabled interrupt is never taken and the loop spins for
+ * seconds while the CD IRQ that would set its wait-flag is never serviced.
+ * Poll on a GLOBAL invocation counter so short-block loops still yield to
+ * interrupts (this is the interpreter analogue of a block-leader poll in
+ * static code). psx_check_interrupts runs the handler and returns with
+ * registers restored, so continuing the loop afterward is safe. */
+ {
+ static uint32_t s_interp_entry_poll = 0;
+ if ((++s_interp_entry_poll & 0x3Fu) == 0) {
+ cpu->pc = pc;
+ psx_check_interrupts(cpu);
+ }
+ }
for (int i = 0; i < MAX_INSNS_PER_DISPATCH; i++) {
uint32_t next_pc = 0;
#ifndef PSX_NO_DEBUG_TOOLS
diff --git a/runtime/src/fntrace.c b/runtime/src/fntrace.c
index 12567008..33aeaeea 100644
--- a/runtime/src/fntrace.c
+++ b/runtime/src/fntrace.c
@@ -43,6 +43,13 @@ void fntrace_set_game_range(uint32_t lo, uint32_t hi) {
int fntrace_is_game_started(void) { return s_game_started; }
static inline int armed_match(uint32_t target) {
+ /* PSX_FNTRACE_ALL=1 records every dispatch from power-on — the ring
+ * then holds the earliest boot execution (before any TCP client can
+ * arm it) for offline first-divergence diffs. Checked once. */
+ static int boot_all = -1;
+ if (boot_all == -1) { const char *e = getenv("PSX_FNTRACE_ALL");
+ boot_all = (e && *e && *e != '0') ? 1 : 0; }
+ if (boot_all) return 1;
if (s_arm_record_all) return 1;
/* Hot path: when nothing is armed, record nothing. Recording every
* dispatch by default makes the ring fill in seconds and burns ~10%
@@ -101,11 +108,18 @@ void fntrace_record(CPUState* cpu, uint32_t target) {
boot_state_trigger_capture(cpu);
}
}
- if (!armed_match(target)) return;
/* Honor the one-shot capture freeze (insn_freeze): once latched, the ring
* preserves the pre-divergence window instead of evicting it. */
extern int g_insn_log_frozen;
- if (g_insn_log_frozen) return;
+ int was_frozen = g_insn_log_frozen;
+ /* Null-page dispatch guard: a dispatch whose phys target sits below the
+ * exception vectors (0x80/0xA0) is a jump through a null/garbage pointer —
+ * the wedge itself, never legitimate code. Latch the capture freeze so the
+ * ring preserves the window that led here (this culprit entry, with its
+ * ra, still records; the wedge storm after it does not). */
+ if ((target & 0x1FFFFFFFu) < 0x40u) g_insn_log_frozen = 1;
+ if (!armed_match(target)) return;
+ if (was_frozen) return;
uint64_t idx = g_fntrace_seq++ & (FNTRACE_RING_CAP - 1);
FntraceEntry* e = &g_fntrace_ring[idx];
e->frame = (uint32_t)s_frame_count;
diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp
index 1ebea609..b3290463 100644
--- a/runtime/src/main.cpp
+++ b/runtime/src/main.cpp
@@ -2580,6 +2580,20 @@ int main(int argc, char** argv) {
std::fprintf(stdout, "psxrecomp: SPU float-shadow enabled (verified-enhancement)\n");
spu_init();
cdrom_init(disc_path_str.empty() ? NULL : disc_path_str.c_str());
+ if (!disc_path_str.empty()) {
+ /* GetID must report the inserted disc's license region (the BIOS CD
+ * driver revalidates it mid-game). Derive it from the disc's boot
+ * serial via the same disc_identity module the launch check uses. */
+ const auto ident = PSXRecompV4::identify_disc(
+ disc_path_str, /*expected_serial*/"", /*expected_crc*/0,
+ /*has_expected_crc*/false, /*compute_crc*/false);
+ if (ident.region == "PAL") cdrom_set_disc_scex("SCEE");
+ else if (ident.region == "NTSC-J") cdrom_set_disc_scex("SCEI");
+ else if (ident.region == "NTSC-U") cdrom_set_disc_scex("SCEA");
+ if (!ident.region.empty())
+ std::fprintf(stdout, "psxrecomp: disc region %s (serial %s)\n",
+ ident.region.c_str(), ident.detected_serial.c_str());
+ }
{
int divisor = 1; /* default: authentic 1x timing */
if (disc_speed == "instant") divisor = 0;
diff --git a/runtime/src/memory.c b/runtime/src/memory.c
index 60833b48..0939d58b 100644
--- a/runtime/src/memory.c
+++ b/runtime/src/memory.c
@@ -32,6 +32,19 @@ static uint8_t ram[RAM_SIZE];
static uint8_t scratchpad[SCRATCHPAD_SIZE];
static uint8_t bios_rom[BIOS_ROM_SIZE];
+/* Physical address translation for guest accesses. The 2 MB main RAM is
+ * mirrored 4x across the first 8 MB of each segment (mem-ctrl RAM_SIZE
+ * register, default 0x0B88) and games rely on it — Kula World's crt0
+ * parks $sp in the 4th mirror (0x807FFFF8). Fold the mirrors here so the
+ * RAM bounds checks below see canonical offsets; without this, mirror
+ * writes fell into the open-bus no-op and mirror reads returned 0 (the
+ * guest's stack silently vanished and $ra came back as 0). */
+static inline uint32_t psx_phys_addr(uint32_t addr) {
+ uint32_t phys = addr & 0x1FFFFFFFu;
+ if (phys < 0x00800000u) phys &= (uint32_t)(RAM_SIZE - 1);
+ return phys;
+}
+
/* Expose RAM pointer for oracle comparison (find_first_divergence). */
uint8_t *memory_get_ram_ptr(void) { return ram; }
uint8_t *memory_get_scratchpad_ptr(void) { return scratchpad; }
@@ -924,7 +937,7 @@ static uint32_t psx_read_word_raw(uint32_t addr) {
* fatal (frame 9705). Ignore writes, read as 0, count for telemetry. */
if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; }
- uint32_t phys = addr & 0x1FFFFFFFu;
+ uint32_t phys = psx_phys_addr(addr);
if (phys < RAM_SIZE) {
uint32_t v = (uint32_t)ram[phys]
@@ -1014,7 +1027,7 @@ static void psx_write_word_raw(uint32_t addr, uint32_t val) {
* We have no cache model, so silently discard RAM/scratchpad writes. */
if (sr_ptr && (*sr_ptr & 0x10000u)) return;
- uint32_t phys = addr & 0x1FFFFFFFu;
+ uint32_t phys = psx_phys_addr(addr);
if (phys < RAM_SIZE) {
if (phys == D44_PHYS) d44_note(phys, read_ram_word(phys), val);
@@ -1073,7 +1086,7 @@ uint16_t psx_read_half(uint32_t addr) {
static uint16_t psx_read_half_raw(uint32_t addr) {
/* KSEG2 guard — see psx_read_word_raw. */
if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; }
- uint32_t phys = addr & 0x1FFFFFFFu;
+ uint32_t phys = psx_phys_addr(addr);
if (phys < RAM_SIZE) {
return (uint16_t)ram[phys] | ((uint16_t)ram[phys + 1] << 8);
@@ -1108,7 +1121,7 @@ static void psx_write_half_raw(uint32_t addr, uint16_t val) {
/* KSEG2 guard — see psx_read_word_raw. */
if (addr >= 0xC0000000u) { g_kseg2_ignored_writes++; return; }
- uint32_t phys = addr & 0x1FFFFFFFu;
+ uint32_t phys = psx_phys_addr(addr);
if (phys < RAM_SIZE) {
debug_server_trace_write_check(phys, (uint32_t)read_ram_half(phys), (uint32_t)val, 2);
@@ -1157,7 +1170,7 @@ uint8_t psx_read_byte(uint32_t addr) {
static uint8_t psx_read_byte_raw(uint32_t addr) {
/* KSEG2 guard — see psx_read_word_raw. */
if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; }
- uint32_t phys = addr & 0x1FFFFFFFu;
+ uint32_t phys = psx_phys_addr(addr);
if (phys < RAM_SIZE) {
return ram[phys];
@@ -1336,7 +1349,7 @@ static void psx_write_byte_raw(uint32_t addr, uint8_t val) {
/* KSEG2 guard — see psx_read_word_raw. */
if (addr >= 0xC0000000u) { g_kseg2_ignored_writes++; return; }
- uint32_t phys = addr & 0x1FFFFFFFu;
+ uint32_t phys = psx_phys_addr(addr);
if (phys < RAM_SIZE) {
debug_server_trace_write_check(phys, (uint32_t)ram[phys], (uint32_t)val, 1);
diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c
index b215b1c3..a4153055 100644
--- a/runtime/src/overlay_loader.c
+++ b/runtime/src/overlay_loader.c
@@ -498,7 +498,13 @@ static void persist_sljit_shard(uint32_t entry_phys, uint32_t lo, uint32_t len,
* sets s_async_cache_dirty; the DISPATCH thread rescans-and-registers on its
* next miss (candidate table stays single-threaded). PSX_SLJIT_SYNC=1 forces
* the legacy synchronous JIT-on-miss path. */
+#ifdef _WIN32
static volatile LONG s_async_cache_dirty = 0;
+# define async_cache_dirty_exchange(v) InterlockedExchange(&s_async_cache_dirty, (v))
+#else
+static volatile int s_async_cache_dirty = 0;
+# define async_cache_dirty_exchange(v) __atomic_exchange_n(&s_async_cache_dirty, (v), __ATOMIC_SEQ_CST)
+#endif
static int g_sljit_async = 1;
/* MASTER SAFETY GATE (2026-06-25): the sljit EMITTER mistranslates some overlay
@@ -935,7 +941,7 @@ void overlay_loader_async_publish(uint32_t entry_phys, uint32_t lo, uint32_t len
if (!wok) { remove(tmp); return; }
if (!MoveFileExA(tmp, path, MOVEFILE_REPLACE_EXISTING)) { remove(tmp); return; }
s_sljit_persist_writes++; /* telemetry-only; benign cross-thread incr */
- InterlockedExchange(&s_async_cache_dirty, 1);
+ async_cache_dirty_exchange(1);
#else
(void)entry_phys; (void)lo; (void)len; (void)crc; (void)blob; (void)blob_size;
#endif
@@ -1608,7 +1614,7 @@ int overlay_loader_dispatch(CPUState *cpu, uint32_t addr) {
if (head < 0 && s_active && overlay_cache_window_contains(phys)) {
/* Pick up shards the background compile worker just published (idempotent
* rescan; runs only when a publish has flipped the dirty flag). */
- if (g_sljit_tier_enabled && g_sljit_async && InterlockedExchange(&s_async_cache_dirty, 0)) {
+ if (g_sljit_tier_enabled && g_sljit_async && async_cache_dirty_exchange(0)) {
scan_sljit_cache_dir();
head = idx_head(phys);
}