mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
Merge branch 'pr9-kula' into merge-pr9
# Conflicts: # runtime/src/beetle_libretro.cpp # runtime/src/dirty_ram_interp.c # runtime/src/memory.c
This commit is contained in:
+13
@@ -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/
|
||||
|
||||
@@ -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:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/kula-world-titlescreen.png" alt="Kula World title screen running on PSXRecomp" width="480">
|
||||
</p>
|
||||
|
||||
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 <stdlib.h>` 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).
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/kula-world-macos.png" alt="Kula World running natively on macOS (Apple Silicon)" width="480">
|
||||
<br>
|
||||
<em>Kula World, statically recompiled, running natively on macOS (Apple Silicon).</em>
|
||||
</p>
|
||||
|
||||
## Philosophy — toward 100% static recompilation
|
||||
|
||||
The goal is simple and absolute: **a PS1 game should run as native code, not be
|
||||
|
||||
@@ -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 <bios> <out>) 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 <stdlib.h>` 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.
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
@@ -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 <game.cue>
|
||||
```
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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=<byte> [PSX_CD_TRAP_NTH=<n>] 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.
|
||||
@@ -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 = "."
|
||||
@@ -304,7 +304,15 @@ std::string find_c_compiler(const std::optional<std::string>& cli_override) {
|
||||
static const std::array<const char*, 3> 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;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,22 @@ std::optional<PS1Executable> 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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h> /* getenv — declared here; glibc/windows.h leak it, macOS SDK does not */
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <cstdarg>
|
||||
#include <csignal>
|
||||
|
||||
#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;
|
||||
|
||||
+62
-9
@@ -16,6 +16,9 @@
|
||||
#include "event_ring.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#ifndef _WIN32
|
||||
# include <signal.h>
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# include <windows.h>
|
||||
@@ -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=<byte> 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;
|
||||
}
|
||||
|
||||
@@ -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 <stddef.h>
|
||||
|
||||
#include "code_provider.h"
|
||||
#include "autocompile.h"
|
||||
#include "overlay_sljit.h"
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-2
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
-6
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user