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: + +

+ Kula World title screen running on PSXRecomp +

+ +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 running natively on macOS (Apple Silicon) +
+ 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); }