diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 0da8c6ac..be601fa1 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -1,9 +1,18 @@ name: CLI release +# Release packaging ONLY. Deliberately does NOT run on pull_request or on +# pushes to master. +# +# It ran on both until 2026-07-25 and was a net negative: the Windows `build` +# job failed often enough that a red check stopped carrying information, so it +# was routinely ignored -- at which point it is pure noise plus CI minutes. A +# check nobody trusts is worse than no check, because it still costs attention. +# +# What remains is the part that does real work: attaching the CLI package to a +# published GitHub release, plus workflow_dispatch so it can be run by hand +# before cutting one. If per-PR verification comes back, it should come back as +# something fast and reliable enough to be believed. on: - pull_request: - push: - branches: [master] workflow_dispatch: release: types: [published] @@ -31,6 +40,11 @@ jobs: - name: Build CLI package shell: msys2 {0} run: python tools/build_cli.py release + - name: Test boot path handling + shell: msys2 {0} + run: | + cmake --build recompiler/build-cli --target cli_boot_path_test --parallel 2 + ctest --test-dir recompiler/build-cli -R '^cli_boot_path_test$' --output-on-failure - name: Smoke test packaged CLI shell: msys2 {0} run: dist/psxrecomp-cli-windows-x86_64/psxrecomp.exe --help @@ -40,6 +54,21 @@ jobs: path: dist/psxrecomp-cli-*.zip if-no-files-found: error + linux-boot-path-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - name: Configure boot path test + run: cmake -S recompiler -B recompiler/build-boot-path-test -DBUILD_TESTING=ON + - name: Build boot path test + run: cmake --build recompiler/build-boot-path-test --target cli_boot_path_test --parallel 2 + - name: Run boot path test + run: >- + ctest --test-dir recompiler/build-boot-path-test + -R '^cli_boot_path_test$' --output-on-failure + publish-release-assets: if: github.event_name == 'release' needs: build diff --git a/.gitignore b/.gitignore index 8c859153..f239b16a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ # BIOS ROM (copyrighted, not distributable) -bios/ +bios/* +!bios/*.toml +# OpenBIOS is MIT — the one BIOS image that is legal to track (see +# THIRD_PARTY_ATTRIBUTION.md; pin recorded in bios/OpenBIOS.toml; notice +# shipped alongside as bios/OpenBIOS.LICENSE). +!bios/openbios.bin +!bios/*.LICENSE # DuckStation — upstream source is a git submodule pointing at stenzek/duckstation. # Our custom changes live in tools/duckstation/psxrecomp_oracle.patch (committed). @@ -35,7 +41,7 @@ release-stage/ # Vendored upstream sources (cloned locally, not checked in) beetle-psx/ -# Prebuilt SDL2 / dep zips (large, regenerable) +# Explicit-fallback SDL2 / dep zips (large, regenerable) sdl2-dev.zip sdl2-msvc/ diff --git a/.gitmodules b/.gitmodules index c467250f..4ba55f45 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "lib/recomp-net"] path = lib/recomp-net url = https://github.com/TechnicallyComputers/recomp-net.git +[submodule "recomp-ui"] + path = recomp-ui + url = https://github.com/mstan/recomp-ui.git + branch = master diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d31e8bb..ac8ab37a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,10 +131,28 @@ Correctness is demonstrated, not asserted: - **A decoder / codegen change requires a playthrough check**, not just a clean build: cosim/lockstep first, then run the affected area. +### Automated tests — run these first + +```sh +cmake -S recompiler -B recompiler/build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build recompiler/build +cd recompiler/build && ctest --output-on-failure +``` + +29 tests, under five seconds, no BIOS dump or disc image required. See +[`docs/TESTING.md`](docs/TESTING.md) for running individual tests, what the +suite covers, and the three known-failing tests that are deliberately not +registered. + +If you add a test, register it with `ctest` in the same commit. An unregistered +test cannot fail, and a test that cannot fail is not a test. + ### Regression checklist -A framework change should not break a game that worked before it. When practical, -build against the known-working game repos above and confirm, for each: +The automated suite does not cover whether a game still runs; only playing one +does. A framework change should not break a game that worked before it. When +practical, build against the known-working game repos above and confirm, for +each: - The game **boots**. - The **attract demo** plays without issue. diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md index 5c7dfff2..88b4f0ae 100644 --- a/ENHANCEMENTS.md +++ b/ENHANCEMENTS.md @@ -209,8 +209,8 @@ Findings (tracked for the next work cycle): has no sprite-tag ws hooks (ws work parked at 4:3). **Policy gates confirmed already in-tree (no code change needed for the merge):** -- Vulkan is hidden by default: launcher offers only Software<->OpenGL - (launcher.cpp:347/776); PSX_ENABLE_VULKAN defaults OFF (runtime.cmake:484); +- Vulkan is hidden by default: the shared launcher offers only + Software<->OpenGL; PSX_ENABLE_VULKAN defaults OFF (runtime.cmake); runtime downgrades renderer=vulkan -> opengl when not compiled (main.cpp:2463). Vulkan stays a dev/CLI-only backend (--renderer vulkan on a VK-enabled build). - Widescreen carries an EXPERIMENTAL tag in the launcher. diff --git a/ISSUES.md b/ISSUES.md index 2dd155d5..d494c6ee 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -513,45 +513,6 @@ sites aren't, it's an emit gap (fix in code_generator.cpp). If neighbors are absent too, it's a discovery gap (fix in function discovery seeds). Either way, then close Issue #3 alongside. -## Issue #6 — Launcher art has rough cutout edges (memory cards + controllers) - -**Status:** open, cosmetic — deferred -**Date opened:** 2026-06-12 -**Phase:** Launcher initiative (UI polish) - -### Symptom - -This issue tracked cosmetic problems in the removed in-tree launcher asset -pipeline. It is retained only as historical context. - -### Cause - -`FloodTransparent` uses a binary alpha decision (`max(R,G,B) < thresh` -→ alpha 0, else keep). There is no feathering of the boundary band, so -the object silhouette inherits the threshold's hard step. The memory -card and (grey) controller bodies are closer in luminance to the dark -mockup background than the disc is, so the same threshold leaves more -fringe on them. - -### Fix options (later) - -- Soft alpha ramp across a luminance band (`t_lo..t_hi`) instead of a - hard cutoff, applied to the flooded boundary pixels. -- Or supersample: crop at 2–4× from the mockup, knock out, then - downscale with high-quality bicubic so the edge anti-aliases. -- Or hand-mask the four assets once in an image editor (cleanest, but - manual). -- Best long-term: replace the mockup-derived crops with proper source - renders (transparent PNGs) — the `decorator: image(...)` pipeline is - already in place, so it's a drop-in. - -### Notes - -Tooling: this historical note referred to the removed in-tree launcher asset -pipeline. The active launcher path is downstream `recomp-ui`. - ---- - ## Issue #7 — sljit live execution is unvalidated (pure-live save-load wedge) **Status:** open, root-caused — fix in progress (branch `feat/sljit-backend`) diff --git a/README.md b/README.md index 467c3a12..2178391d 100644 --- a/README.md +++ b/README.md @@ -4,120 +4,83 @@ # PSXRecomp -Generic static recompiler framework for PlayStation 1: MIPS R3000A to C to -native x64. +**A general-purpose static recompiler for the PlayStation 1.** It turns a PS1 +disc into a native executable — MIPS R3000A translated to C, compiled to x64, +linked against a hardware-accurate runtime. Not an emulator: the game becomes a +program your CPU runs directly. + +Titles brought up on it ship as standalone builds with widescreen, mods, live +language switching and a launcher. They can run on either the bundled, +open-source **OpenBIOS** or a compatible **retail BIOS** supplied by the player; +OpenBIOS is the default, so a retail BIOS dump is not normally required. The +framework is general-purpose: bringing up a new game is a matter of +configuration and reverse engineering, not new engine work. + + + + + + + + + + + + +
Tomba!
Tomba!
Tomba! 2
Tomba! 2
Ape Escape
Ape Escape
Mega Man X4
Mega Man X4
Mega Man X5
Mega Man X5
Mega Man X6
Mega Man X6
Background on the original prototype: [I Built a PS1 Static Recompiler With No Prior Experience (and Claude Code)](https://1379.tech/i-built-a-ps1-static-recompiler-with-no-prior-experience-and-claude-code/) -[![PSXRecomp demo](https://img.youtube.com/vi/CID9oVhgCyY/maxresdefault.jpg)](https://www.youtube.com/watch?v=CID9oVhgCyY) +## Games -## How to use PSXRecomp +Each game is its own repository that pins a framework commit as a submodule and +ships its own playable release with an in-app launcher. Validation and feature +coverage continue to improve per title. -PSXRecomp takes a PlayStation disc image and BIOS and creates a recompilation -project containing C source and build scripts. +| Game | Repository | Latest build | Notes | +|---|---|---|---| +| *Tomba!* | [TombaRecomp](https://github.com/mstan/TombaRecomp) | [releases](https://github.com/mstan/TombaRecomp/releases/latest) | Widescreen, supersampling, save/load, mod packages. | +| *Tomba! 2* | [Tomba2Recomp](https://github.com/mstan/Tomba2Recomp) | [releases](https://github.com/mstan/Tomba2Recomp/releases/latest) | Multi-track disc support; adaptive widescreen through 21:9. | +| *Ape Escape* | [ApeEscapeRecomp](https://github.com/mstan/ApeEscapeRecomp) | [releases](https://github.com/mstan/ApeEscapeRecomp/releases/latest) | Widescreen to 21:9, memory-card save/load, dual-analog. | +| *Mega Man X4* | [MegaManX4Recomp](https://github.com/mstan/MegaManX4Recomp) | [releases](https://github.com/mstan/MegaManX4Recomp/releases/latest) | Playable; 2D widescreen. | +| *Mega Man X5* | [MegaManX5Recomp](https://github.com/mstan/MegaManX5Recomp) | [releases](https://github.com/mstan/MegaManX5Recomp/releases/latest) | Playable; 2D widescreen. | +| *Mega Man X6* | [MegaManX6Recomp](https://github.com/mstan/MegaManX6Recomp) | [releases](https://github.com/mstan/MegaManX6Recomp/releases/latest) | Playable; stages, controller, save/load; 2D widescreen to 21:9. | +| *Tsumu Light* | [TsumuLightRecomp](https://github.com/mstan/TsumuLightRecomp) | [releases](https://github.com/mstan/TsumuLightRecomp/releases/latest) | Japanese-only title (SLPS-02253); first consumer of [live language switching](#swap-language-on-the-fly). | +| *Xenogears* — **community** | [OpokXeno/xenogears-recomp](https://github.com/OpokXeno/xenogears-recomp) | — | Independent project by [@OpokXeno](https://github.com/OpokXeno), who also contributed widescreen cull-site work upstream. | -### Generate a project with the released CLI +Each game repo carries its own build/run instructions, keyboard/controller +mappings, and per-game settings. **This repository builds the framework and a +standalone BIOS runtime supporting OpenBIOS and a compatible retail BIOS** — +see [Release Package](#release-package) below. -1. Download `psxrecomp-cli-windows-x86_64.zip` from - [Releases](https://github.com/mstan/psxrecomp/releases). -2. Extract the whole zip to a folder. Keep its contents together. -3. Open PowerShell in that folder and run: - -```powershell -.\psxrecomp.exe build ` - --disc "C:\Games\My Game\game.cue" ` - --bios "C:\BIOS\SCPH1001.BIN" ` - --output "C:\Projects\MyGameRecomp" -``` - -Use the `.cue` file when a game has one, and keep its `.bin` track files beside -it. Single-file `.bin` and `.iso` images are also accepted. - -The output folder contains: - -- generated C source for the game and BIOS; -- `game.toml`, which you can edit for game-specific settings; -- `CMakeLists.txt` and build scripts; and -- a local copy of the PSXRecomp runtime source needed by the project. - -The downloaded CLI is self-contained. You do not need to install Python or -build this repository to generate a project. - -### Build the generated project - -Install CMake, Ninja, a C/C++ compiler, and SDL2 development files. Then run: - -```powershell -powershell -ExecutionPolicy Bypass -File "C:\Projects\MyGameRecomp\build.ps1" -``` - -The generated project also includes a shell build script for macOS and Linux: - -```sh -sh /path/to/MyGameRecomp/build.sh -``` - -The ready-made CLI release is currently for 64-bit Windows. You can build the -CLI from source on another operating system using the instructions below. - -The generated project is a practical starting point, not a promise that every -game works without game-specific fixes. PSX games can load extra code and use -hardware in ways that require additional configuration or development. - -Use only disc and BIOS files you obtained legally. PSXRecomp does not include -them. Generated game and BIOS source is derived from those files, so do not -redistribute it. - -### Build the CLI from source - -You need Git, Python 3, CMake, Ninja, and a C++20 compiler. - -```sh -git clone --recurse-submodules https://github.com/mstan/psxrecomp.git -cd psxrecomp -python tools/build_cli.py release -``` - -The ready-to-use CLI archive is written to `dist/`. To package debug binaries -instead, run `python tools/build_cli.py debug`. - -On Linux/macOS source checkouts, the same development setup can be driven by: - -```sh -sh tools/setup_dev.sh -``` - -That script builds the CLI/recompiler tools and, when BIOS/generated sources are -available, the BIOS-only runtime. Game projects should still be generated with -`psxrecomp build` and built from their generated `build.sh`. - -> **Where the project is headed.** Development so far has been **breadth-first**: -> stand up as many games as possible and get them into a playable alpha, proving -> the framework generalizes. That phase has largely delivered — seven titles now -> run and ship public builds (see [Games](#games)). The project is now pivoting to -> a **depth / optimization** phase: pushing each game toward 100% static coverage, -> tightening timing accuracy, driving load times toward zero, and hardening the -> renderer and audio paths. Expect the fleet to get *faster and more accurate* -> rather than *wider* from here. +Bringing up a title of your own? Start with +[`CONTRIBUTING.md`](CONTRIBUTING.md) and open an issue — community projects are +listed here alongside the rest. ## What It Is PSXRecomp translates PS1 MIPS binaries into C, then compiles that C as a native executable linked against a PS1 hardware runtime. The v4 architecture -recompiles the real `SCPH1001.BIN` BIOS and runs it as the kernel — that -**low-level (LLE) recompiled BIOS is the foundation and the correctness oracle.** -Everything is architected LLE-first: accuracy comes first, and convenience is -layered on top, opt-in, never underneath. +links two recompiled low-level BIOS backends: the bundled OpenBIOS and a +compatible retail BIOS (currently `SCPH1001.BIN`). Whichever one the player +selects runs as the kernel; that **low-level (LLE) recompiled BIOS is the +foundation and the correctness oracle.** Everything is architected LLE-first: +accuracy comes first, and convenience is layered on top, opt-in, never +underneath. Three things sit on that foundation: - **An optional HLE tier.** A high-level BIOS layer can be laid over the - recompiled kernel to skip the boot sequence and service a few BIOS calls - directly — a player-facing convenience and optimization, enabled by default - but fully opt-out (`[runtime] bios_hle = false`). Anything it doesn't - implement falls straight through to the recompiled BIOS, so the LLE path stays - load-bearing and remains the oracle every accuracy check runs against. + selected recompiled kernel — OpenBIOS or retail BIOS — to skip the boot + sequence and service a few BIOS calls directly. It is a player-facing + convenience and optimization, enabled by default but fully opt-out + (`[runtime] bios_hle = false`). Anything it doesn't implement falls straight + through to the selected recompiled BIOS, so the LLE path stays load-bearing + and remains the oracle every accuracy check runs against. The boot-skip half + works on both linked BIOS backends — the bundled OpenBIOS and a player's + retail BIOS dump reach the game the same way — while the kernel-call half is + enabled per image and says so at startup when it isn't. - **Capture-and-compile for overlays.** PS1 games stream code off the disc at runtime (*overlays*) that no ahead-of-time recompiler can see. PSXRecomp captures each overlay the moment it loads and recompiles it to native code, @@ -138,48 +101,257 @@ repositories and link this one in as a **git submodule** to build a game binary. [`docs/EXECUTION_MODEL.md`](docs/EXECUTION_MODEL.md) (how a game actually runs — static / native-overlay / interpreter), then [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), -[`docs/BUILDING.md`](docs/BUILDING.md), and +[`docs/BUILDING.md`](docs/BUILDING.md), +[`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) (versioned runtime mods), [`CONTRIBUTING.md`](CONTRIBUTING.md). -## Games +## Which PlayStation BIOS does it use? -Each game is its own repository that pins a framework commit as a submodule and -ships its own release with an in-app launcher. All are **alpha** — playable, not -yet fully validated end to end. +Builds support two recompiled BIOS backends: **OpenBIOS** and a compatible +**retail BIOS**. OpenBIOS is a free, open-source PlayStation BIOS from the +[PCSX-Redux](https://github.com/grumpycoders/pcsx-redux) project that we're +allowed to distribute. It is bundled and runs by default, so you usually do not +need to provide a BIOS dump. Bring a game disc image and play. -| Game | Repository | Latest build | Notes | -|---|---|---|---| -| *Tomba!* | [TombaRecomp](https://github.com/mstan/TombaRecomp) | [releases](https://github.com/mstan/TombaRecomp/releases/latest) | Most mature target; widescreen, supersampling, save/load. | -| *Tomba! 2* | [Tomba2Recomp](https://github.com/mstan/Tomba2Recomp) | [releases](https://github.com/mstan/Tomba2Recomp/releases/latest) | Multi-track disc support, selectable 21:9. | -| *Ape Escape* | [ApeEscapeRecomp](https://github.com/mstan/ApeEscapeRecomp) | [releases](https://github.com/mstan/ApeEscapeRecomp/releases/latest) | New launcher, widescreen, memory-card save/load. | -| *Mega Man X4* | [MegaManX4Recomp](https://github.com/mstan/MegaManX4Recomp) | [releases](https://github.com/mstan/MegaManX4Recomp/releases/latest) | Playable; widescreen. | -| *Mega Man X5* | [MegaManX5Recomp](https://github.com/mstan/MegaManX5Recomp) | [releases](https://github.com/mstan/MegaManX5Recomp/releases/latest) | Early bring-up. | -| *Mega Man X6* | [MegaManX6Recomp](https://github.com/mstan/MegaManX6Recomp) | [releases](https://github.com/mstan/MegaManX6Recomp/releases/latest) | Playable; stages, controller, save/load. | -| *Tsumu Light* | [TsumuLightRecomp](https://github.com/mstan/TsumuLightRecomp) | [releases](https://github.com/mstan/TsumuLightRecomp/releases/latest) | Japanese title (SLPS-02253); early bring-up. | +**If you'd rather use a retail BIOS**, pick your dumped `SCPH1001.BIN` in +settings and it will be used instead. Clear that choice to return to OpenBIOS. -Each game repo carries its own build/run instructions, keyboard/controller -mappings, and per-game settings. **This repository builds the framework and a -BIOS-only runtime** — see [Release Package](#release-package) below. +Two things worth knowing: + +- The retail BIOS has to be the *exact* image linked into the build, so a + different dump can't be swapped in. If yours doesn't match, the game says + which one it expects, and you can carry on with OpenBIOS. +- **Save files work either way.** Memory cards are shared. *Savestates* are not: + one made with OpenBIOS won't load under the retail BIOS, or the other way + round, so the game won't let you mix them. + +Titles with a verified OpenBIOS incompatibility require the compatible retail +BIOS instead and say so up front. + +> Developers: see [`docs/BIOS_SELECTION.md`](docs/BIOS_SELECTION.md) for the +> `[runtime] openbios` setting and the selection rules. + +## Widescreen + +Not a stretch and not a crop — a genuinely **wider field of view**, computed at +recompile time by widening the game's own projection and culling maths. 4:3 +output stays byte-identical when widescreen is off. + +This is framework tooling, not a per-game bolt-on. Any project built on +PSXRecomp can enable it from a `[widescreen]` block in its `game.toml` — the +generic cull-widening, FOV and aspect machinery lives here, and bringing up a +new title is a matter of locating that game's cull sites rather than writing new +rendering code. + +It works on both 3D and 2D engines. 2D is the harder case: a sprite engine has +no camera to widen, so the background tile ring, streamer and packet budget all +have to be widened in step with the renderer. + + + + +
Mega Man X6 at 16:9
Mega Man X6 — 16:9. A 2D sprite engine widened: more stage either side, no stretching.
+ + + + +
Ape Escape at 21:9
Ape Escape — 21:9. A wider 3D frustum, not a zoomed one.
+ + + + +
Tomba! 2 adaptive widescreen
Tomba! 2 — adaptive. The view tracks the window, up to an ultrawide cap.
+ +See [`WIDESCREEN.md`](WIDESCREEN.md) for the per-game configuration. + +## Mods + +Mods are **versioned packages**, not patched discs. A `.psxmod` is an +installation, provenance and trust boundary; each package contributes any number +of independently toggleable **features**, and enabling one never silently +reconfigures another. + +Crucially, your disc image is never rewritten. The player selects a verified +stock BIN/CUE, and resolution produces guarded native operations and sparse disc +overlays *over* that image — so the original stays intact and a mod can be +turned off as easily as it was turned on. + +

+ The mods panel in the launcher +

+ +Tomba! ships three today — widescreen, skip-FMV, and a warp debug menu that +re-enables the developers' own hidden menu. Full format in +[`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md). + +## Swap language on the fly + +A general localization layer captures the game's own strings out of memory and +substitutes translated bytes as it runs. Any source language to any target — the +mechanism has no built-in notion of a "default" language, so a Japanese-only +release can be played in English just as readily as an English one can be played +in another language. + +Switching is live. Tables are human-editable TOML under `translations/`, one +column per language, hot-reloaded while the game is running — and the launcher +carries a language picker. A translator can edit a line and see it in-game +without rebuilding anything. + +The hard part is glyphs, not strings. Substituted text is drawn through the +game's *own* glyph routine with per-character proportional advances calibrated +by measuring the real ink width of each glyph tile in VRAM, plus auto-fit +condensing so longer translations still fit a box sized for the original. That +means no engine changes and no regeneration to add a language. + +See [`docs/STRING_TRANSLATION.md`](docs/STRING_TRANSLATION.md); *Tsumu Light* +(SLPS-02253) is the first title using it. + +## Renderers + +| Backend | Status | +|---|---| +| **OpenGL** | **Default.** GPU-authoritative VRAM/FBO renderer; moves rasterization and supersampling onto the GPU. | +| **Vulkan** | **Experimental.** Built when the SDK is present, opt-in at runtime; falls back to OpenGL if unavailable. | +| **Software** | CPU rasterizer — the reference look, and the most portable fallback. | + +## How to use PSXRecomp + +PSXRecomp takes a PlayStation disc image and creates a recompilation project +that supports the bundled OpenBIOS and a compatible retail BIOS. The CLI asks +for a retail BIOS dump so it can generate that backend alongside OpenBIOS. + +### Generate a project with the released CLI + +1. Download `psxrecomp-cli-windows-x86_64.zip` from + [Releases](https://github.com/mstan/psxrecomp/releases). +2. Extract the whole zip to a folder. Keep its contents together. +3. Open PowerShell in that folder and run: + +```powershell +.\psxrecomp.exe build ` + --disc "C:\Games\My Game\game.cue" ` + --bios "C:\BIOS\SCPH1001.BIN" ` + --output "C:\Projects\MyGameRecomp" +``` + +Use the `.cue` file when a game has one, and keep its `.bin` track files beside +it. Single-file `.bin` and `.iso` images are also accepted. + +The output folder contains: + +- generated C source for the game and compatible retail BIOS, with the bundled + OpenBIOS backend supplied by the framework; +- `game.toml`, which you can edit for game-specific settings; +- `CMakeLists.txt` and build scripts; and +- a local copy of the PSXRecomp runtime source needed by the project. + +The downloaded CLI is self-contained. You do not need to install Python or +build this repository to generate a project. + +### Build the generated project + +Install CMake, Ninja, and a C/C++ compiler. The build fetches its integrity- +pinned SDL3 release automatically. Then run: + +```powershell +powershell -ExecutionPolicy Bypass -File "C:\Projects\MyGameRecomp\build.ps1" +``` + +The generated project also includes a shell build script for macOS and Linux: + +```sh +sh /path/to/MyGameRecomp/build.sh +``` + +The ready-made CLI release is currently for 64-bit Windows. You can build the +CLI from source on another operating system using the instructions below. + +The generated project is a practical starting point, not a promise that every +game works without game-specific fixes. PSX games can load extra code and use +hardware in ways that require additional configuration or development. + +Use only disc and retail BIOS files you obtained legally. PSXRecomp does not +include those copyrighted files; it includes only the redistributable OpenBIOS +image. Generated game and retail BIOS source is derived from your files, so do +not redistribute it. + +### Build the CLI from source + +You need Git, Python 3, CMake, Ninja, and a C++20 compiler. + +```sh +git clone --recurse-submodules https://github.com/mstan/psxrecomp.git +cd psxrecomp +python tools/build_cli.py release +``` + +The ready-to-use CLI archive is written to `dist/`. To package debug binaries +instead, run `python tools/build_cli.py debug`. + +On Linux/macOS source checkouts, the same development setup can be driven by: + +```sh +sh tools/setup_dev.sh +``` + +That script builds the CLI/recompiler tools and, when the OpenBIOS and retail +BIOS generated sources are available, the standalone BIOS runtime. Game +projects should still be generated with `psxrecomp build` and built from their +generated `build.sh`. + +> **Where the project is headed.** Development so far has been **breadth-first**: +> bring up varied games as playable public builds and prove that the framework +> generalizes. With that foundation established, the project is now focused on a +> **depth / optimization** phase: pushing each game toward 100% static coverage, +> tightening timing accuracy, driving load times toward zero, and hardening the +> renderer and audio paths. Expect existing projects to get *faster and more +> accurate* from here. + +## Release Package + +**This repository's release is a standalone BIOS runtime, not a game.** It can +boot either the bundled OpenBIOS or a compatible retail BIOS for memory-card +management; to play a title, grab its release from the [Games](#games) table. + +1. Download `PSXRecomp-v*-windows-x64.zip` from Releases. +2. Extract it and run `PSXRecomp.exe`. +3. It boots on the bundled OpenBIOS. To use your compatible retail BIOS dump + instead, pick it in settings. + +The package includes `bios/openbios.bin` and its MIT notice at +`bios/OpenBIOS.LICENSE`, but no retail PS1 BIOS, game disc image, generated game +code, or save data. If you choose a retail BIOS, that path is remembered next to +the executable as `bios.cfg`; clear the choice (or delete that file) to go back +to OpenBIOS. + +The game recomp projects use the same runtime picker contract but ship a +**Dear ImGui launcher**: on first run it asks for the game disc image and uses +OpenBIOS automatically. The optional retail BIOS row lets you select your own +verified dump or clear that choice to return to OpenBIOS. The launcher also +configures video, controls, and per-game settings. Keyboard/controller mappings +live in each game's repo and launcher, not here. ## Philosophy — toward 100% static recompilation The goal is simple and absolute: **a PS1 game should run as native code, not be emulated.** Every MIPS instruction the game executes should ideally have been translated to C and compiled ahead of time. No interpreter on the hot path, no -HLE shims, no "good enough" approximation of the hardware — the recompiled BIOS -*is* the kernel, and the recompiled game *is* the game. +HLE shims, no "good enough" approximation of the hardware — the selected +recompiled BIOS, OpenBIOS or retail BIOS, *is* the kernel, and the recompiled +game *is* the game. PS1 games make that goal hard in one specific way: **overlays.** Games stream code off the disc into RAM at runtime and execute it, then overwrite it with the next overlay. That code does not exist in the executable at build time, so a pure ahead-of-time recompiler cannot see it. This is the frontier the project is -working through, and it is why this is an **alpha/beta**: today a *majority* of a -supported game runs as statically recompiled native code, but **not yet 100%.** +working through. Today a *majority* of a supported game runs as statically +recompiled native code, but **not yet 100%.** How we close the gap, without ever compromising correctness: -1. **Static first.** The main executable and the BIOS are fully recompiled - ahead of time. This is the bulk of execution and it is always native. +1. **Static first.** The main executable and both supported BIOS backends — + OpenBIOS and retail BIOS — are fully recompiled ahead of time. This is the + bulk of execution and it is always native. 2. **Capture → compile → cache for overlays.** As the game runs, overlays are captured the moment they load. Offline, each is recompiled to a native DLL keyed by its content, cached, and on later runs loaded and dispatched as @@ -188,8 +360,8 @@ How we close the gap, without ever compromising correctness: 3. **Interpreter failover — only for code that isn't static yet.** A small MIPS interpreter runs *runtime-installed* code (overlays/dirty RAM) that hasn't been captured-and-compiled. It is a safety net and a coverage feeder, - never a substitute for recompiling static code, and never on the BIOS/main-EXE - path. + never a substitute for recompiling static code, and never on the selected + BIOS (OpenBIOS or retail BIOS) or main-EXE path. 4. **Precision over recall.** A piece of code we *haven't* compiled safely falls back to the interpreter and gets captured for next time — under-coverage self-heals. A piece we compile *wrong* would corrupt the machine, so the @@ -217,83 +389,73 @@ a game is played; this branch is where that machinery is being built. ## Status -The framework is at **alpha**: the LLE recompiled BIOS boots and hands off to -the game across all seven targets, and each runs as majority-native code with -the capture-and-compile pipeline filling overlays as they're reached. The -breadth-first push is essentially done; work now is depth and optimization. +The LLE recompiled BIOS — either bundled OpenBIOS or the compatible retail +backend — boots and hands off to the game across supported projects. Games run +as majority-native code, with the capture-and-compile pipeline filling overlays +as they're reached. The breadth-first push is essentially done; work now is +depth and optimization. Core subsystems, framework-wide: | Subsystem | State | |---|---| -| BIOS recompilation (`SCPH1001.BIN`) | Boots to shell and hands off to game; also the correctness oracle | -| HLE BIOS tier | Optional boot-skip + service layer over the recompiled BIOS; default on, opt-out | -| Game EXE recompilation | Title/menus/save-load/gameplay reached across the fleet | +| BIOS recompilation (OpenBIOS + compatible retail BIOS) | Both boot to the shell and hand off to the game; the selected LLE backend is the correctness oracle | +| HLE BIOS tier | Optional boot-skip + service layer over the selected recompiled BIOS (OpenBIOS or retail BIOS); default on, opt-out | +| Game EXE recompilation | Title/menus/save-load/gameplay reached across supported projects | | Overlay capture → compile → cache | `static → gcc → tcc`; coverage grows as a game is played | | Interpreter failover | Correctness net for not-yet-native code; being compiled away by sharding | | CD-ROM / MDEC / XA | FMVs stream and play; XA/CDDA timing stays authentic | -| Memory cards | Save and load verified on multiple titles | +| Memory cards | Save and load supported across game projects | | SIO0 controllers | Digital pad + DualShock config; per-game analog/digital selection | | GPU | Software + OpenGL + Vulkan backends; widescreen FOV/HUD work per game | | SPU | Working; reverb/noise/sweep and exact SPU-IRQ accuracy still being tightened | | Interrupts, COP0, timers, GTE | Working; cycle-accuracy foundation is an active depth-phase focus | -Per-game maturity varies — see the [Games](#games) table. None of the titles is -declared fully validated end to end; users perform the final playthrough -validation on each release. +Validation scope varies by game — see the [Games](#games) table and each +project's release notes for current coverage. Open depth-phase fronts: cycle/IRQ-phase timing accuracy, load-time-toward-zero (data sharding), renderer parity between the software/GL/Vulkan backends, and driving overlay coverage the last mile to 100% static. -Running this repository's runtime without a game is useful for **BIOS-only -memory-card management**; to build and play a title, use its game repo from the -[Games](#games) table. - -## Release Package - -**This repository's release is BIOS-only** — it is the framework runtime, not a -game. Use it for BIOS boot and memory-card management; to play a title, grab its -release from the [Games](#games) table. - -1. Download `PSXRecomp-v*-windows-x64.zip` from Releases. -2. Extract it and run `PSXRecomp.exe`. -3. Select your legally obtained `SCPH1001.BIN` BIOS when prompted. - -The package does not include a PS1 BIOS, game disc image, generated game code, -or save data. The selected BIOS path is saved next to the executable as -`bios.cfg`; delete that file to pick a different BIOS later. - -The game recomp projects use the same runtime picker contract but ship a -**Dear ImGui launcher**: on first run it prompts for your legally obtained BIOS -and game disc image, then lets you configure video, controls, and per-game -settings before launching. Keyboard/controller mappings live in each game's repo -and launcher, not here. +Running this repository's standalone BIOS runtime without a game is useful for +**memory-card management under OpenBIOS or a compatible retail BIOS**; to build +and play a title, use its game repo from the [Games](#games) table. ## Setup Builds natively on **Windows (MSVC/MinGW)**, **macOS (Apple Silicon & Intel)**, -and **Linux**. The BIOS thread scheduler uses host fibers — Win32 Fibers on -Windows, `ucontext` on POSIX (`runtime/src/psx_fiber.c`) — so the recompiled -BIOS's cooperative thread switching (the CD-boot handoff in particular) behaves -the same on every platform. +and **Linux**. The selected BIOS backend — OpenBIOS or retail BIOS — uses host +fibers for its thread scheduler: Win32 Fibers on Windows and `ucontext` on POSIX +(`runtime/src/psx_fiber.c`). This keeps the selected recompiled backend's +cooperative thread switching, especially the CD-boot handoff, consistent on +every platform. Requirements at a glance (full details, dependency table, and per-platform prerequisites in [`docs/BUILDING.md`](docs/BUILDING.md)): - A C/C++ toolchain: MSVC or MinGW/MSYS2 (Windows), Apple Clang (macOS), Clang/GCC (Linux). CMake 3.20+; on macOS/Linux also `ninja` and `pkg-config`. -- SDL2 (system / bundled). -- A legally obtained `SCPH1001.BIN` BIOS dump. Not included. +- SDL3 3.4+ (a system package when available, otherwise fetched automatically). + SDL2 is available only as an explicit build fallback. +- A retail PS1 BIOS is **optional** — builds use the bundled OpenBIOS by + default. Supply a legally obtained `SCPH1001.BIN` dump only if you want to + select the retail BIOS instead (see + [Which PlayStation BIOS does it use?](#which-playstation-bios-does-it-use)). - For game projects, a legally obtained game disc/EXE dump. Not included. -Build the framework (recompiler tool + BIOS-only runtime): +Every runtime and game configure uses SDL3 unless you explicitly append +`-DPSX_SDL_BACKEND=SDL2` to its CMake command. CMake prints the selected backend +during configuration; it never silently falls back from SDL3 to SDL2. + +Build the framework (recompiler tool + standalone BIOS runtime with OpenBIOS and +retail BIOS support): ```sh git clone --recurse-submodules https://github.com/mstan/psxrecomp.git && cd psxrecomp cmake -S recompiler -B recompiler/build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build recompiler/build -cmake -S runtime -B runtime/build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build runtime/build --target psx-runtime +cmake -S runtime -B runtime/build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_RECOMP_UI=OFF && cmake --build runtime/build --target psx-runtime ``` On Windows swap `-G Ninja` for your generator if you prefer (e.g. @@ -307,18 +469,20 @@ generated C is optimized. Game projects generate their own Keyboard and Xbox-style controller input work out of the box; the default fullscreen toggle is F11 / Alt+Enter / Cmd+F. **Full button maps, controller configuration, and rebinding live in each game's repo and in-app launcher** — -they're game-facing, not part of the framework. The BIOS-only framework runtime -accepts keyboard input for navigating the BIOS shell and memory-card tools. +they're game-facing, not part of the framework. The standalone framework runtime +accepts keyboard input for navigating the selected OpenBIOS or retail BIOS shell +and memory-card tools. ## Architecture -The recompiler emits C functions and dispatch tables for BIOS and game code. -The runtime loads the BIOS/game assets into emulated PS1 memory, links the -generated C as native code, and simulates hardware through MMIO handlers for -GPU, DMA, timers, CD-ROM, MDEC, SIO0, memory cards, SPU, GTE, and interrupt -delivery. The recompiled `SCPH1001.BIN` is the low-level (LLE) kernel and the -correctness oracle; an optional HLE tier lays instant boot-skip and a few BIOS -services on top, always falling through to the recompiled BIOS. +The recompiler emits C functions and dispatch tables for game code and both BIOS +backends: bundled OpenBIOS and a compatible retail BIOS. The runtime loads the +selected BIOS and game assets into emulated PS1 memory, links the generated C as +native code, and simulates hardware through MMIO handlers for GPU, DMA, timers, +CD-ROM, MDEC, SIO0, memory cards, SPU, GTE, and interrupt delivery. The selected +recompiled BIOS is the low-level (LLE) kernel and correctness oracle; an optional +HLE tier lays instant boot-skip and a few BIOS services on top, always falling +through to that OpenBIOS or retail BIOS kernel. Code that can't be seen ahead of time (disc-streamed **overlays**) is captured and compiled to native code the first time it appears (`static → gcc → tcc` @@ -381,10 +545,11 @@ game code) is planned so discoveries can be shared safely in the future. Contributions are welcome — AI-assisted or not — as long as they're reviewed, tested, and keep the core game-agnostic. A few things hold this project together: -the faithful recompiled BIOS is the baseline and oracle, generated code is never -hand-edited (fix the recompiler and regenerate), and a change proves itself -against the Beetle oracle / on screen rather than by assertion. Game-specific work -lives in the game repos, which pin an exact framework commit as a submodule. +the selected faithfully recompiled BIOS — OpenBIOS or retail BIOS — is the +baseline and oracle, generated code is never hand-edited (fix the recompiler and +regenerate), and a change proves itself against the Beetle oracle / on screen +rather than by assertion. Game-specific work lives in the game repos, which pin +an exact framework commit as a submodule. Read [`CONTRIBUTING.md`](CONTRIBUTING.md) before opening a PR — it covers the core rules, how to verify a change, the regression checklist across the known games, @@ -396,13 +561,14 @@ discussion happens in the **R.A.I.D.** Discord (invite below). PolyForm Noncommercial 1.0.0. See `LICENSE`. -The PSX BIOS and game disc images remain copyrighted by their respective -owners. This project distributes no BIOS images, no disc images, and no -game assets — those are always supplied by the user from their own -collection. Release executables (and per-game overlay caches) contain -statically recompiled (machine-translated) builds of the original code, -the same distribution model used by other static recompilation projects -such as N64: Recompiled. +Retail PS1 BIOS images and game disc images remain copyrighted by their +respective owners and are not distributed. This project does distribute the +from-scratch, MIT-licensed OpenBIOS image under the notice in +[`bios/OpenBIOS.LICENSE`](bios/OpenBIOS.LICENSE). Game assets and disc data are +always supplied by the user from their own collection. Release executables (and +per-game overlay caches) contain statically recompiled (machine-translated) +builds of the original code, the same distribution model used by other static +recompilation projects such as N64: Recompiled. --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4837695b..942b1ae9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,17 +1,19 @@ -# PSXRecomp v0.1.0-alpha +# PSXRecomp OpenBIOS integration -Initial public BIOS-only framework release package. +OpenBIOS is now a first-class, redistributable runtime backend. Highlights: - Packages the PSXRecomp v4 BIOS runtime as `PSXRecomp.exe`. -- Prompts for a user-provided `SCPH1001.BIN` BIOS on first launch. -- Saves the selected BIOS path in `bios.cfg` next to the executable. +- Bundles PCSX-Redux OpenBIOS and its MIT notice. +- Uses OpenBIOS when the player has not explicitly selected another BIOS. +- Keeps a verified, user-provided `SCPH1001.BIN` as an optional runtime choice. +- Lets launcher users clear a retail selection and return to OpenBIOS. +- Prevents savestates created under one BIOS from loading under the other. - Adds Xbox-style controller support through SDL/XInput. - Adds configurable controller mapping via `input.ini`. -- Includes the recent Tomba-driven renderer/runtime fixes for the BIOS logo, - menu text seams, dialog/pause panel seams, terrain shading, and shaded - textured primitives. +- Includes the load-delay ownership fix validated in Tomba!, plus OpenBIOS + smoke coverage in Ape Escape, Tomba! 2, and Mega Man X6. -This package does not include a PS1 BIOS, game disc image, generated game code, -save data, or copyrighted Sony/game assets. +This package does not include a retail PS1 BIOS, game disc image, generated game +code, save data, or copyrighted Sony/game assets. diff --git a/SCPH1001.toml b/SCPH1001.toml new file mode 100644 index 00000000..7ae1da94 --- /dev/null +++ b/SCPH1001.toml @@ -0,0 +1,45 @@ +# SCPH-1001 (US, v2.2 12/04/95) — the reference BIOS. +# Boot + kernel + shell all recompiled from ROM. + +[program] +name = "SCPH1001 BIOS" +id = "SCPH-1001" +rom = "bios/US-PSX-SCPH1001.BIN" +load_address = "0xBFC00000" +entry_pc = "0xBFC00000" +text_size = "0x80000" + +[recompiler] +seeds = "recompiler/seeds/phase2_ghidra_seeds.json" +out_dir = "generated" +out_stem = "SCPH1001" +strict = true + +# A0 table: 0xC1 entries copied from ROM 0xBFC04300 to RAM 0x200 +[[recompiler.bios_vectors]] +ram_addr = "0xA0" +index_reg = 9 +table_rom_addr = "0xBFC04300" +table_count = 0xC1 +table_ram_addr = "0x200" + +# B0 table: 0x44 entries at kernel 0x874 (ROM 0xBFC10874) +[[recompiler.bios_vectors]] +ram_addr = "0xB0" +index_reg = 9 +table_rom_addr = "0xBFC10874" +table_count = 0x44 +table_ram_addr = "0x874" + +# C0 table: 0x1E entries at kernel 0x674 (ROM 0xBFC10674) +[[recompiler.bios_vectors]] +ram_addr = "0xC0" +index_reg = 9 +table_rom_addr = "0xBFC10674" +table_count = 0x1E +table_ram_addr = "0x674" + +# SIO data-byte handler: 4-instruction trampoline at RAM 0xCF0 -> func 0x641C +[[recompiler.bios_aliases]] +ram_addr = "0xCF0" +target_key = "0x641C" diff --git a/SCPH101.toml b/SCPH101.toml new file mode 100644 index 00000000..8d18728a --- /dev/null +++ b/SCPH101.toml @@ -0,0 +1,44 @@ +# SCPH-101 (US PSOne, v4.5 05/25/00) +# Boot + kernel are byte-identical to SCPH1001 (534 seeds kept). +# Shell (ROM 0x18000+, 568 seeds) differs — runs via dirty-RAM interpreter. + +[program] +name = "SCPH101 BIOS (PSOne)" +id = "SCPH-101" +rom = "bios/US-PSOne-SCPH101.BIN" +load_address = "0xBFC00000" +entry_pc = "0xBFC00000" +text_size = "0x80000" + +[recompiler] +seeds = "recompiler/seeds/phase2_ghidra_seeds_SCPH101.json" +out_dir = "generated" +out_stem = "SCPH101" +strict = true + +# SCPH101 shares SCPH1001's A0/B0/C0 dispatch tables and kernel layout. +# The BIOS vector table ROM addresses and aliases are identical. +[[recompiler.bios_vectors]] +ram_addr = "0xA0" +index_reg = 9 +table_rom_addr = "0xBFC04300" +table_count = 0xC1 +table_ram_addr = "0x200" + +[[recompiler.bios_vectors]] +ram_addr = "0xB0" +index_reg = 9 +table_rom_addr = "0xBFC10874" +table_count = 0x44 +table_ram_addr = "0x874" + +[[recompiler.bios_vectors]] +ram_addr = "0xC0" +index_reg = 9 +table_rom_addr = "0xBFC10674" +table_count = 0x1E +table_ram_addr = "0x674" + +[[recompiler.bios_aliases]] +ram_addr = "0xCF0" +target_key = "0x641C" diff --git a/SCPH5552.toml b/SCPH5552.toml new file mode 100644 index 00000000..35a4f1e2 --- /dev/null +++ b/SCPH5552.toml @@ -0,0 +1,44 @@ +# SCPH-5552 (EUR, v3.0 01/06/97) +# Boot + kernel are byte-identical to SCPH1001 (534 seeds kept). +# Shell (ROM 0x18000+, 568 seeds) differs — runs via dirty-RAM interpreter. + +[program] +name = "SCPH5552 BIOS (Europe)" +id = "SCPH-5552" +rom = "bios/EUR-PSX-SCPH5552.bin" +load_address = "0xBFC00000" +entry_pc = "0xBFC00000" +text_size = "0x80000" + +[recompiler] +seeds = "seeds/phase2_ghidra_seeds_SCPH5552.json" +out_dir = "generated" +out_stem = "SCPH5552" +strict = true + +# SCPH5552 shares SCPH1001's A0/B0/C0 dispatch tables and kernel layout. +# The BIOS vector table ROM addresses and aliases are identical. +[[recompiler.bios_vectors]] +ram_addr = "0xA0" +index_reg = 9 +table_rom_addr = "0xBFC04300" +table_count = 0xC1 +table_ram_addr = "0x200" + +[[recompiler.bios_vectors]] +ram_addr = "0xB0" +index_reg = 9 +table_rom_addr = "0xBFC10874" +table_count = 0x44 +table_ram_addr = "0x874" + +[[recompiler.bios_vectors]] +ram_addr = "0xC0" +index_reg = 9 +table_rom_addr = "0xBFC10674" +table_count = 0x1E +table_ram_addr = "0x674" + +[[recompiler.bios_aliases]] +ram_addr = "0xCF0" +target_key = "0x641C" diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index ecc20694..f2587c08 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -1,172 +1,547 @@ -# TCP Debug Server Commands +# TCP Debug Server Commands + +Protocol: **JSON over newline**, one object per line, responses on same connection. + +- Request shape: `{"id": N, "cmd": "", ...params}` +- Success: `{"id": N, "ok": true, ...data}` +- Failure: `{"id": N, "ok": false, "error": ""}` + +There are **two live** servers, both implementing this protocol with overlapping command sets: + +| Server | Port | Source | +|---|---|---| +| **Native** (our recompiled runtime) | `4370` | `runtime/src/debug_server.c` | +| **Beetle PSX** (oracle) | `4380` | `runtime/src/beetle_debug_server.c` | + +> **DuckStation (port 4371) was retired as the oracle on 2026-05-05** and is no +> longer built from this repository — there is no `duckstation` entry in +> `.gitmodules`. The **D** column in the curated inventory below is historical +> and reflects DuckStation, not Beetle. For authoritative per-command +> native/Beetle coverage, use the generated +> [Complete command index](#complete-command-index-generated) at the bottom of +> this file, which is derived from the two servers' command tables. + +The `debug_client.py` CLI can target either, or `compare` two at once to diff state live — that's how divergence hunts work. + +```bash +python tools/debug_client.py [args] # native (port 4370) +python tools/debug_client.py --port 4380 # psx-beetle +python tools/debug_client.py --ds [args] # duckstation (port 4371) +python tools/debug_client.py compare # run on both, diff results +``` + +Commands without a bespoke CLI mapping pass through generically: extra +args of the form `key=value` become JSON fields (ints when numeric, else +strings), so every server command is reachable, e.g. +`debug_client.py --port 4370 gpu_frame_dump frame=14528 count=65536`. + +--- + +## Command inventory + +Columns: **N** = native, **D** = DuckStation oracle. + +| Command | N | D | Params | Description | +|---|---|---|---|---| +| `ping` / `frame` | ✓ | ✓ | — | Heartbeat + current frame number | +| `get_registers` (`regs`) | ✓ | ✓ | — | All 32 GPRs + PC + HI + LO (native also: COP0 SR/Cause/EPC, I_STAT, I_MASK) | +| `read_ram` | ✓ | ✓ | `addr`, `len` | Read bytes from PS1 address space as hex string — up to the full 2 MB in ONE response line. `dump_ram` is an alias (the old chunked multi-line variant is gone: it broke the one-request/one-response protocol and wedged the server) | +| `write_ram` | ✓ | ✓ | `addr`, `val` | Write **one byte** to PS1 address space. Note the parameter is `val` (not `hex`), and the write is a single byte per call — this row previously documented both incorrectly | +| `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | +| `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | +| `gpu_state` | ✓ | ✓ | — | Display area, display depth, draw offset, GPUSTAT, clip rect, xfer state | +| `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | +| `irq_state` | ✓ | ✓ | — | `I_STAT`, `I_MASK` (both), plus chain state on native | +| `dma_state` | ✓ | ✓ | — | DPCR, DICR, all 7 channel states (madr/bcr/chcr) | +| `event_state` | | ✓ | — | EvCB table summary (stub on DS — events are BIOS-level) | +| `overlay_state` | | ✓ | — | Current overlay info | +| `cdrom_sector_dump` | ✓ | | `offset`, `len` | Dump bytes from the last CD-ROM sector observed by the controller, including LBA/mode metadata | +| `cdrom_sector_history` | ✓ | | `count`, optional `lba` | Dump newest CD-ROM sector history entries, including raw XA subheader fields, CPU/audio delivery flags, and the first 128 bytes | +| `cdrom_sector_history_clear` | ✓ | | — | Reset the CD-ROM sector history ring | +| `watch` | ✓ | ✓ | `addr` | Set byte-level memory watchpoint (fires per-frame on change) | +| `unwatch` | ✓ | ✓ | `addr` | Remove memory watchpoint | +| `set_input` | ✓ | ✓ | `buttons`, optional `frames`, optional `lx`, `ly`, `rx`, `ry` | Override pad1 buttons and optional analog axes (PS1 inverted bitmask, 0 = pressed; axes 0-255). Holds until `clear_input` on both backends; pass `frames=N` (beetle) to auto-release after N frames | +| `clear_input` | ✓ | ✓ | — | Remove input and analog axis overrides | +| `turbo` | ✓ | | `enabled` | Enable/disable TCP-controlled frontend turbo for fast-forward validation | +| `turbo_state` | ✓ | | — | Query TCP-controlled turbo state | +| `pause` | ✓ | | — | **REMOVED** — still registered, but always returns an error. Query a ring buffer (`fn_entry_tail`, `wtrace_dump`, `gpu_frame_dump`) instead of synthesizing a snapshot | +| `continue` (`c`) | ✓ | | — | **REMOVED** — nothing to resume, since `pause` is gone | +| `step` | ✓ | | — | **REMOVED** — query a ring buffer over the window of interest instead of advancing N frames synchronously | +| `run_to_frame` | ✓ | | — | **REMOVED** — use `frame_range` / `read_frame_ram` against the live frame ring instead | +| `history` | ✓ | ✓ | — | Ring buffer stats (frames available) | +| `get_frame` | ✓ | ✓ | `frame` | Full frame record from ring buffer | +| `frame_range` | ✓ | ✓ | `start`, `end` | Range query, max 200 frames | +| `frame_timeseries` | ✓ | ✓ | `start`, `end` | Compact timeseries, max 200 frames | +| `set_snapshot` | ✓ | ✓ | `slot`, `addr`, `size` | Configure per-frame RAM snapshot region (slots 0-3) | +| `get_snapshots` | ✓ | ✓ | — | Show snapshot config | +| `screenshot` | ✓ | ✓ | `path` (optional) | Write a **PNG** of the current display to `path` (default `psx_screenshot.png` in the runtime cwd); single metadata response `{path,width,height}`. `screenshot_file` is an alias; the old inline-hex-row `screenshot` is gone (it streamed h+1 response lines per request and poisoned the connection) | +| `first_failure` | ✓ | | — | Find first divergence point between runs (native-side tracking) | +| `read_frame_ram` | ✓ | | `addr`, `len`, `frame` | Read RAM **as of a specific frame** (from ring buffer) | +| `wtrace_range` | ✓ | | `lo`, `hi` | Set RAM-write trace range (ring of 262 144 writes with RA — `WRITE_TRACE_CAP`, `1 << 18`) | +| `wtrace_dump` | ✓ | beetle | optional `addr_lo`, `addr_hi`, `count`, `newest` | Dump RAM-write trace entries as JSON. The address filter is applied server-side over the FULL ring before the emit cap — always pass it when hunting a specific buffer, otherwise you only see the oldest `count` entries of the whole ring | +| `wtrace_clear` | ✓ | | — | Reset the trace ring | +| `mmio_dump` | ✓ | | optional `addr`, `count`, `newest` | Dump the always-on MMIO write ring (256K entries, ALL 0x1F801xxx writes — SPU/DMA traffic rolls it in well under a minute of gameplay; for display history use `gp1_dump`) | +| `mmio_clear` | ✓ | | — | Reset the MMIO write ring | +| `gp1_dump` | ✓ | | optional `frame_lo`, `frame_hi`, `count`, `newest` | Dump the dedicated ALWAYS-ON GP1 (0x1F801814 display control) ring — 512K entries ≈ 15 min of gameplay (Tomba writes ~10 GP1/frame), survives the general MMIO ring's eviction. Frame filter is server-side over the full ring. Each entry: val + func/pc/cpu_pc/ra/sp/a0/a1/sr/epc/frame | +| `pc_break` | | ✓² | `addr` | DS execute breakpoint, state captured on hit (via `pc_hit_last`) | +| `pc_unbreak` | | ✓² | `addr` | Remove an execute breakpoint | +| `pc_break_list` | | ✓² | — | List active execute breakpoints | +| `pc_hit_last` | | ✓² | — | Captured state (PC, $ra, all GPRs, COP0) from most recent PC break hit | +| `pc_hit_clear` | | ✓² | — | Clear the last-hit record | +| `quit` | ✓ | | — | Shutdown native runtime | + +¹ Native `vram_peek` is the legacy name; DS calls it `read_vram`. Same semantics. +² The `pc_*` family is specific to the DS oracle: DuckStation's CPU core honours `CPU::AddBreakpointWithCallback`, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks). + +--- + +## Divergence-hunt workflow + +When a recompiled-BIOS bug is suspected, the two servers let you find the **first** divergence instead of chasing symptoms. Standard procedure (inherited from v3's `DEBUG.md`): + +1. **Sync state via PC + registers, not frame number.** Frames drift after even a single timing glitch. Pause both servers; compare `get_registers` until they match. +2. **Dump both sides fully.** Compare `get_frame`, `gpu_state`, `irq_state`, `dma_state` (DS), `dump_ram` over the same regions. +3. **Byte-level comparison.** Tiny mismatches usually point at one subsystem. Use `debug_client.py compare ` for automatic diff. +4. **Find the earliest mismatch**, not a later symptom. Ring-buffer queries (`frame_range`, `read_frame_ram`) help locate which frame went wrong. +5. **Trace the write.** Use `watch` to catch the divergent store, or DS's `pc_break` on the suspect function entry. Look at `$ra` in `pc_hit_last` to identify the caller chain. +6. **Classify.** codegen (recompiler generates wrong instruction), runtime (MMIO or kernel simulation wrong), timing (IRQ cadence), or BIOS (real-hardware quirk we didn't model). +7. **Minimal fix** in the correct subsystem. Never hand-deliver state to hide the symptom (see CLAUDE.md §0). + +--- + +## Server send budget + serve-stall telemetry + +The server is pumped on the **main thread**: every millisecond it spends +sending a response is a millisecond the emulator does not run. Inline +responses are bounded — 2 s per zero-progress chunk and **15 s total per +response**; a client that exceeds the budget is disconnected (the runtime +never stalls indefinitely). Responses bigger than the budget allows must +use the `*_dump_file` variants, which write to disk instead of the socket. + +Cumulative serve-stall is exported as `tcp_send_stall_ms` / +`tcp_clients_dropped` in `psx_freeze_heartbeat.json` (plus per-tick +`tcp_ms` in its ring) and in every wedge/fatal dump header. **Check these +first when diagnosing slow or stalled frames** — on 2026-06-10 two +"attract-idle degradations" turned out to be a TCP client trickle-draining +mega-dumps, throttling the main loop to 6 fps (all 8 watchdog stack +samples inside `WS2_32!send`). A slow-frames wedge with a large +`tcp_send_stall_ms` delta over the same window is observer interference, +not a guest bug. + +## Call-contract (bail) telemetry + +The dispatch call contract (Bug D family fix, 2026-06-10) guards every +generated continuation: it may only run if the guest actually returned to +the call site ($ra == site return address, $sp == caller's sp at the +call). Violations begin a "bail" unwind that abandons stale C frames and +re-dispatches the guest's true target. Counters in +`psx_freeze_heartbeat.json` and dump headers: + +- `bail_first` — contract violations detected (wild returns). Nonzero + during gameplay means the game executed a wild control transfer (e.g. + Tomba's dead jumptable case `jal 0x80120B3C`, the chest freeze). A + small count with the game continuing normally is the fix working. +- `bail_resolved` — unwinds that resolved at an enclosing call site whose + contract matched (multi-level return). +- `bail_flattened` — unwinds that reached the outermost dispatch loop and + re-dispatched the wild target on a clean host stack. +- `bail_anomaly` — bail flag observed at exception entry (must stay 0; + anything else is a runtime bug). + +--- + +## `bios_info` — linked recompiled-BIOS identity (native only) + +Reports which BIOS image this build's recompiled C was generated from +(`psx_bios_image`, emitted into the generated dispatch from the BIOS profile) +and whether the loaded ROM matches it: `image_id`, `sha256`, `crc32`, `size`, +`bundled` (redistributable image shipped with the game), the kernel-bless +window, the HLE anchors (`shell_entry_phys` / `deliver_event_ret`; 0 = +structurally unavailable on this BIOS), and `image_wordsum` vs +`loaded_wordsum` with `match`. With the launch identity gate a running +process always reports `match:1`. + +- `{"cmd":"bios_info"}` + +## `s3_smear_watch` — callee-saved-register smear tripwire (native only) + +Latches the first interpreted instruction in a PC window whose execution +changes `$s3` (`runtime/src/dirty_ram_interp.c`). A `jalr`'s exec_one spans +the entire nested native callee, so the latch names the callee that returned +with a clobbered callee-saved register; the insn ring is frozen at the latch. + +- `{"cmd":"s3_smear_watch","lo":"","hi":""}` — arm (each arming + fully re-specifies the watch). Optional `"excl":""`: exact + encoding to ignore, so a watched loop's own `$s3` advance (e.g. an + `addi s3,s3,8` list walk) doesn't trip the latch. +- `{"cmd":"s3_smear_watch"}` — report the latch: `valid`, `pc`, `insn`, + `s3_old`/`s3_new`, `call_target` (rs at the call site for jr/jalr), + `frame`. +- `{"cmd":"s3_smear_watch","lo":"0"}` — disarm. + +## `callret_watch` — interp JALR call-resolution ring (native only) + +64-entry ring (`runtime/src/dirty_ram_interp.c`) recording, for every +interpreted JALR whose call PC lies in a window, which resolution tier ran +the callee and the full post-call outcome — the complement of +`s3_smear_watch`: the tripwire names the callee that came back smeared, this +ring names the return path that let it come back. + +- `{"cmd":"callret_watch","lo":"","hi":""}` — arm (resets the ring). +- `{"cmd":"callret_watch"}` — dump (newest last): per entry `cyc`, `f` + (frame), `pc`, `tgt`, `path` (`CRES_*` tier code, see the enum in + dirty_ram_interp.c; `|0x100` = finish() escaped), pre-call + `sp_b`/`ra_b`/`s0_b`/`s3_b`, post-call `pc_a`/`ra_a`/`sp_a`/`s0_a`/ + `s3_a`/`v0_a`, `bail`/`rfe`/`esc`/`in_exc` flags, `dstatic`/`dblocks`/ + `dexc` engine-attribution deltas across the call, `last_func`. +- `{"cmd":"callret_watch","lo":"0"}` — disarm. + +## `hle_dump` — BIOS-HLE tier call ring (native only) + +Always-on ring (`runtime/src/bios_hle.c`, 16K entries) recording every +A0/B0/C0 kernel-vector dispatch the HLE tier's hook observes, plus the boot +shell-skip event. + +The hook is installed when EITHER axis is on (`bios_hle_plan.h`), so a run with +the boot-skip on but kernel calls left to LLE — the default on the bundled +OpenBIOS, which exports no `deliver_event_ret` — reports +`backend: "LLE (recompiled BIOS)"` and still fills the ring with `route: 0` +vector observations plus the one `route: 2` boot entry. Only a run with BOTH axes +off installs no hook and leaves the ring empty; use `bioscall_dump` for LLE-side +vector observation there. + +- `{"cmd":"hle_dump"}` — status: `backend` (`HLE (LLE fallback)` / + `LLE (recompiled BIOS)`), `boot_skip`, `boot_turbo_active`, `total`. +- `{"cmd":"hle_dump","tail":N}` — last N entries: `seq`, `cycle` (guest + cycle), `vec` (0xA0/0xB0/0xC0, or 0x30000 for the boot skip), `fn` ($t1 + function number), `a0..a3`, `ra`, `v0` (result when HLE-serviced), `route` + (0 = fell through to LLE, 1 = serviced in HLE, 2 = boot shell-skip). +- Filters: `"fn":N`, `"route":0|1|2`. + +--- + +## Rule when the server can't answer your question + +If an inspection need isn't covered by the existing commands, **do not fall back to printf or log files**. Instead: + +1. Add a handler in `runtime/src/debug_server.c` (native) +2. Add the matching handler in `runtime/src/beetle_debug_server.c` (Beetle oracle) + when the question needs a cross-check against hardware behaviour +3. Keep field names parallel between the two +4. Run `python tools/gen_tcp_commands.py` to refresh the generated index, and add + a row to the curated inventory above if the command needs explaining + +> Step 4 used to read "Update this file", and that did not survive contact with +> reality: this document described 47 commands while the servers registered 292. +> The index is now generated from the command tables, and +> `python tools/gen_tcp_commands.py --check` fails when it drifts. Prose in the +> curated inventory is still hand-written and still worth adding. + +The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` is absolute: **no `fprintf(stderr, …)` in source code, ever, for any reason**. + + + +## Complete command index (generated) + +**292 commands registered** — 279 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -Protocol: **JSON over newline**, one object per line, responses on same connection. - -- Request shape: `{"id": N, "cmd": "", ...params}` -- Success: `{"id": N, "ok": true, ...data}` -- Failure: `{"id": N, "ok": false, "error": ""}` - -There are **two** servers, both implementing this protocol with overlapping command sets: - -| Server | Port | Source | -|---|---|---| -| **Native** (our recompiled runtime) | `4370` | `runtime/src/debug_server.c` | -| **DuckStation** (oracle) | `4371` | `duckstation/src/core/psxrecomp_debug_server.cpp` (patched, see `tools/duckstation/psxrecomp_oracle.patch`) | - -The `debug_client.py` CLI can target either, or `compare` two at once to diff state live — that's how divergence hunts work. - -```bash -python tools/debug_client.py [args] # native (port 4370) -python tools/debug_client.py --port 4380 # psx-beetle -python tools/debug_client.py --ds [args] # duckstation (port 4371) -python tools/debug_client.py compare # run on both, diff results -``` - -Commands without a bespoke CLI mapping pass through generically: extra -args of the form `key=value` become JSON fields (ints when numeric, else -strings), so every server command is reachable, e.g. -`debug_client.py --port 4370 gpu_frame_dump frame=14528 count=65536`. - ---- - -## Command inventory - -Columns: **N** = native, **D** = DuckStation oracle. - -| Command | N | D | Params | Description | -|---|---|---|---|---| -| `ping` / `frame` | ✓ | ✓ | — | Heartbeat + current frame number | -| `get_registers` (`regs`) | ✓ | ✓ | — | All 32 GPRs + PC + HI + LO (native also: COP0 SR/Cause/EPC, I_STAT, I_MASK) | -| `read_ram` | ✓ | ✓ | `addr`, `len` | Read bytes from PS1 address space as hex string — up to the full 2 MB in ONE response line. `dump_ram` is an alias (the old chunked multi-line variant is gone: it broke the one-request/one-response protocol and wedged the server) | -| `write_ram` | ✓ | ✓ | `addr`, `hex` | Write bytes to PS1 address space | -| `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | -| `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | -| `gpu_state` | ✓ | ✓ | — | Display area, display depth, draw offset, GPUSTAT, clip rect, xfer state | -| `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | -| `irq_state` | ✓ | ✓ | — | `I_STAT`, `I_MASK` (both), plus chain state on native | -| `dma_state` | ✓ | ✓ | — | DPCR, DICR, all 7 channel states (madr/bcr/chcr) | -| `event_state` | | ✓ | — | EvCB table summary (stub on DS — events are BIOS-level) | -| `overlay_state` | | ✓ | — | Current overlay info | -| `sljit_async` | ✓ | | — | Off-thread sljit compile-worker telemetry: `worker_running`, `async_on`, always-on counters (`enqueued`/`compiled`/`failed`/`queued_now`), `last_compile_ms`/`max_compile_ms` (off-thread compile time), resolved `cache_dir`, and a `recent[]` ring of the last 64 compiles (per-shard `phys`/`crc`/`code_len`/`ms`/`ok`). Query it to prove the worker compiles off the dispatch thread without a frame hitch | -| `cdrom_sector_dump` | ✓ | | `offset`, `len` | Dump bytes from the last CD-ROM sector observed by the controller, including LBA/mode metadata | -| `cdrom_sector_history` | ✓ | | `count`, optional `lba` | Dump newest CD-ROM sector history entries, including raw XA subheader fields, CPU/audio delivery flags, and the first 128 bytes | -| `cdrom_sector_history_clear` | ✓ | | — | Reset the CD-ROM sector history ring | -| `watch` | ✓ | ✓ | `addr` | Set byte-level memory watchpoint (fires per-frame on change) | -| `unwatch` | ✓ | ✓ | `addr` | Remove memory watchpoint | -| `set_input` | ✓ | ✓ | `buttons`, optional `frames`, optional `lx`, `ly`, `rx`, `ry` | Override pad1 buttons and optional analog axes (PS1 inverted bitmask, 0 = pressed; axes 0-255). Holds until `clear_input` on both backends; pass `frames=N` (beetle) to auto-release after N frames | -| `clear_input` | ✓ | ✓ | — | Remove input and analog axis overrides | -| `turbo` | ✓ | | `enabled` | Enable/disable TCP-controlled frontend turbo for fast-forward validation | -| `turbo_state` | ✓ | | — | Query TCP-controlled turbo state | -| `pause` | ✓ | ✓ | — | Pause emulation | -| `continue` (`c`) | ✓ | ✓ | — | Resume emulation | -| `step` | ✓ | ✓ | `[count]` | Step N frames (default 1) | -| `run_to_frame` | ✓ | ✓ | `frame` | Run until frame number, then pause | -| `history` | ✓ | ✓ | — | Ring buffer stats (frames available) | -| `get_frame` | ✓ | ✓ | `frame` | Full frame record from ring buffer | -| `frame_range` | ✓ | ✓ | `start`, `end` | Range query, max 200 frames | -| `frame_timeseries` | ✓ | ✓ | `start`, `end` | Compact timeseries, max 200 frames | -| `set_snapshot` | ✓ | ✓ | `slot`, `addr`, `size` | Configure per-frame RAM snapshot region (slots 0-3) | -| `get_snapshots` | ✓ | ✓ | — | Show snapshot config | -| `screenshot` | ✓ | ✓ | `path` (optional) | Write a 24-bit BMP of the current display to `path` (default `psx_screenshot.bmp` in the runtime cwd); single metadata response `{path,width,height}`. `screenshot_file` is an alias; the old inline-hex-row `screenshot` is gone (it streamed h+1 response lines per request and poisoned the connection) | -| `first_failure` | ✓ | | — | Find first divergence point between runs (native-side tracking) | -| `read_frame_ram` | ✓ | | `addr`, `len`, `frame` | Read RAM **as of a specific frame** (from ring buffer) | -| `wtrace_range` | ✓ | | `lo`, `hi` | Set RAM-write trace range (ring of 1024 writes with RA) | -| `wtrace_dump` | ✓ | beetle | optional `addr_lo`, `addr_hi`, `count`, `newest` | Dump RAM-write trace entries as JSON. The address filter is applied server-side over the FULL ring before the emit cap — always pass it when hunting a specific buffer, otherwise you only see the oldest `count` entries of the whole ring | -| `wtrace_clear` | ✓ | | — | Reset the trace ring | -| `mmio_dump` | ✓ | | optional `addr`, `count`, `newest` | Dump the always-on MMIO write ring (256K entries, ALL 0x1F801xxx writes — SPU/DMA traffic rolls it in well under a minute of gameplay; for display history use `gp1_dump`) | -| `mmio_clear` | ✓ | | — | Reset the MMIO write ring | -| `gp1_dump` | ✓ | | optional `frame_lo`, `frame_hi`, `count`, `newest` | Dump the dedicated ALWAYS-ON GP1 (0x1F801814 display control) ring — 512K entries ≈ 15 min of gameplay (Tomba writes ~10 GP1/frame), survives the general MMIO ring's eviction. Frame filter is server-side over the full ring. Each entry: val + func/pc/cpu_pc/ra/sp/a0/a1/sr/epc/frame | -| `pc_break` | | ✓² | `addr` | DS execute breakpoint, state captured on hit (via `pc_hit_last`) | -| `pc_unbreak` | | ✓² | `addr` | Remove an execute breakpoint | -| `pc_break_list` | | ✓² | — | List active execute breakpoints | -| `pc_hit_last` | | ✓² | — | Captured state (PC, $ra, all GPRs, COP0) from most recent PC break hit | -| `pc_hit_clear` | | ✓² | — | Clear the last-hit record | -| `quit` | ✓ | | — | Shutdown native runtime | - -¹ Native `vram_peek` is the legacy name; DS calls it `read_vram`. Same semantics. -² The `pc_*` family is specific to the DS oracle: DuckStation's CPU core honours `CPU::AddBreakpointWithCallback`, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks). - ---- - -## Divergence-hunt workflow - -When a recompiled-BIOS bug is suspected, the two servers let you find the **first** divergence instead of chasing symptoms. Standard procedure (inherited from v3's `DEBUG.md`): - -1. **Sync state via PC + registers, not frame number.** Frames drift after even a single timing glitch. Pause both servers; compare `get_registers` until they match. -2. **Dump both sides fully.** Compare `get_frame`, `gpu_state`, `irq_state`, `dma_state` (DS), `dump_ram` over the same regions. -3. **Byte-level comparison.** Tiny mismatches usually point at one subsystem. Use `debug_client.py compare ` for automatic diff. -4. **Find the earliest mismatch**, not a later symptom. Ring-buffer queries (`frame_range`, `read_frame_ram`) help locate which frame went wrong. -5. **Trace the write.** Use `watch` to catch the divergent store, or DS's `pc_break` on the suspect function entry. Look at `$ra` in `pc_hit_last` to identify the caller chain. -6. **Classify.** codegen (recompiler generates wrong instruction), runtime (MMIO or kernel simulation wrong), timing (IRQ cadence), or BIOS (real-hardware quirk we didn't model). -7. **Minimal fix** in the correct subsystem. Never hand-deliver state to hide the symptom (see CLAUDE.md §0). - ---- - -## Server send budget + serve-stall telemetry - -The server is pumped on the **main thread**: every millisecond it spends -sending a response is a millisecond the emulator does not run. Inline -responses are bounded — 2 s per zero-progress chunk and **15 s total per -response**; a client that exceeds the budget is disconnected (the runtime -never stalls indefinitely). Responses bigger than the budget allows must -use the `*_dump_file` variants, which write to disk instead of the socket. - -Cumulative serve-stall is exported as `tcp_send_stall_ms` / -`tcp_clients_dropped` in `psx_freeze_heartbeat.json` (plus per-tick -`tcp_ms` in its ring) and in every wedge/fatal dump header. **Check these -first when diagnosing slow or stalled frames** — on 2026-06-10 two -"attract-idle degradations" turned out to be a TCP client trickle-draining -mega-dumps, throttling the main loop to 6 fps (all 8 watchdog stack -samples inside `WS2_32!send`). A slow-frames wedge with a large -`tcp_send_stall_ms` delta over the same window is observer interference, -not a guest bug. - -## Call-contract (bail) telemetry - -The dispatch call contract (Bug D family fix, 2026-06-10) guards every -generated continuation: it may only run if the guest actually returned to -the call site ($ra == site return address, $sp == caller's sp at the -call). Violations begin a "bail" unwind that abandons stale C frames and -re-dispatches the guest's true target. Counters in -`psx_freeze_heartbeat.json` and dump headers: - -- `bail_first` — contract violations detected (wild returns). Nonzero - during gameplay means the game executed a wild control transfer (e.g. - Tomba's dead jumptable case `jal 0x80120B3C`, the chest freeze). A - small count with the game continuing normally is the fix working. -- `bail_resolved` — unwinds that resolved at an enclosing call site whose - contract matched (multi-level return). -- `bail_flattened` — unwinds that reached the outermost dispatch loop and - re-dispatched the wild target on a clean host stack. -- `bail_anomaly` — bail flag observed at exception entry (must stay 0; - anything else is a runtime bug). - ---- - -## `hle_dump` — BIOS-HLE tier call ring (native only) - -Always-on ring (`runtime/src/bios_hle.c`, 16K entries) recording every -A0/B0/C0 kernel-vector dispatch the HLE tier's hook observes, plus the boot -shell-skip event. Empty in pure-LLE mode (the tier installs no hook there — -use `bioscall_dump` for LLE-side vector observation). - -- `{"cmd":"hle_dump"}` — status: `backend` (`HLE (LLE fallback)` / - `LLE (recompiled BIOS)`), `boot_skip`, `boot_turbo_active`, `total`. -- `{"cmd":"hle_dump","tail":N}` — last N entries: `seq`, `cycle` (guest - cycle), `vec` (0xA0/0xB0/0xC0, or 0x30000 for the boot skip), `fn` ($t1 - function number), `a0..a3`, `ra`, `v0` (result when HLE-serviced), `route` - (0 = fell through to LLE, 1 = serviced in HLE, 2 = boot shell-skip). -- Filters: `"fn":N`, `"route":0|1|2`. - ---- - -## Rule when the server can't answer your question - -If an inspection need isn't covered by the existing commands, **do not fall back to printf or log files**. Instead: - -1. Add a handler in `runtime/src/debug_server.c` (native) -2. Add the matching handler in `duckstation/src/core/psxrecomp_debug_server.cpp` (DS), regenerate the patch via the instructions in `tools/duckstation/README.md` -3. Keep field names parallel between the two -4. Update this file - -The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` is absolute: **no `fprintf(stderr, …)` in source code, ever, for any reason**. +47 of 292 have prose above; **245 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. + +Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. + +| Command | Native | Beetle | Described above | +|---|:--:|:--:|:--:| +| `a0_history` | ✓ | | | +| `audio_events` | ✓ | ✓ | | +| `audio_stats` | ✓ | ✓ | | +| `audio_wav` | ✓ | ✓ | | +| `autocompile_run` | ✓ | | | +| `autocompile_status` | ✓ | | | +| `bios_info` | ✓ | | ✓ | +| `bioscall_dump` | ✓ | | | +| `c0_history` | ✓ | | | +| `call_focus_dump` | ✓ | | | +| `call_focus_reset` | ✓ | | | +| `call_focus_stats` | ✓ | | | +| `callret_watch` | ✓ | | ✓ | +| `capture_freeze` | ✓ | | | +| `capture_quads` | ✓ | | | +| `card_buffer_dump` | ✓ | | | +| `card_data_writes` | ✓ | | | +| `card_data_writes_reset` | ✓ | | | +| `card_mgr_clear` | ✓ | | | +| `card_mgr_trace` | ✓ | | | +| `card_read_summary` | ✓ | | | +| `card_read_summary_reset` | ✓ | | | +| `card_trace_dump` | ✓ | | | +| `card_txn_dump` | ✓ | | | +| `cd_overwrite` | ✓ | | | +| `cd_read_log` | ✓ | | | +| `cdc_volume` | | ✓ | | +| `cdrom_bursts` | ✓ | | | +| `cdrom_cmd_dump` | | ✓ | | +| `cdrom_cmd_reset` | | ✓ | | +| `cdrom_command_history` | ✓ | | | +| `cdrom_command_history_clear` | ✓ | | | +| `cdrom_instant_rate` | ✓ | | | +| `cdrom_sector_dump` | ✓ | | ✓ | +| `cdrom_sector_history` | ✓ | | ✓ | +| `cdrom_sector_history_clear` | ✓ | | ✓ | +| `cdrom_state` | ✓ | | | +| `cdrom_timing` | ✓ | | | +| `cdrom_trace_clear` | ✓ | | | +| `cdrom_trace_dump` | ✓ | | | +| `ce_profile` | ✓ | | | +| `chain_trace` | ✓ | | | +| `clear_input` | ✓ | ✓ | ✓ | +| `continue` | ✓ | | ✓ | +| `cyc_watch` | ✓ | ✓ | | +| `cyc_watch_clear` | ✓ | ✓ | | +| `cyc_watch_dump` | ✓ | ✓ | | +| `cycles_to_next_event` | ✓ | | | +| `d44_ring` | ✓ | | | +| `data_shards` | ✓ | | | +| `devtrace_ctl` | ✓ | ✓ | | +| `devtrace_dump` | ✓ | ✓ | | +| `dirty_block_dump_file` | ✓ | | | +| `dirty_block_log` | ✓ | | | +| `dirty_break_clear` | ✓ | | | +| `dirty_break_range` | ✓ | | | +| `dirty_break_state` | ✓ | | | +| `dirty_flow_log` | ✓ | | | +| `dirty_insn_dump_file` | ✓ | | | +| `dirty_insn_gate` | ✓ | | | +| `dirty_insn_log` | ✓ | | | +| `dirty_ram_stats` | ✓ | | | +| `dirty_ram_unsupported` | ✓ | | | +| `disp_ring` | ✓ | | | +| `dispatch_check` | ✓ | | | +| `dispatch_stats` | ✓ | | | +| `dispatch_tail` | ✓ | | | +| `display_ring_aux` | ✓ | | | +| `display_ring_get` | ✓ | | | +| `display_ring_stats` | ✓ | | | +| `dma_cdrom_history` | ✓ | | | +| `dma_state` | ✓ | | ✓ | +| `dma_trace_clear` | ✓ | | | +| `dma_trace_dump` | ✓ | | | +| `dump_buffer` | ✓ | | | +| `dump_ram` | ✓ | ✓ | ✓ | +| `evcb_snapshot` | ✓ | | | +| `evcb_walk_dump` | ✓ | | | +| `evcb_walk_stats` | ✓ | | | +| `event_ring_clear` | ✓ | | | +| `event_ring_dump` | ✓ | | | +| `event_ring_tail` | ✓ | | | +| `exc_ring` | | ✓ | | +| `first_failure` | ✓ | ✓ | ✓ | +| `fmv_state` | ✓ | | | +| `fn_clear` | ✓ | | | +| `fn_disable` | ✓ | | | +| `fn_entry_dump` | ✓ | | | +| `fn_entry_tail` | ✓ | | ✓ | +| `fn_exit_dump` | ✓ | | | +| `fn_filter` | ✓ | | | +| `fn_stats` | ✓ | | | +| `fntrace_arm` | ✓ | ✓ | | +| `fntrace_arm_clear` | ✓ | | | +| `fntrace_armed` | ✓ | | | +| `fntrace_arms` | | ✓ | | +| `fntrace_clear` | ✓ | | | +| `fntrace_disarm` | | ✓ | | +| `fntrace_dump` | ✓ | ✓ | | +| `fntrace_reset` | | ✓ | | +| `fntrace_unfiltered` | | ✓ | | +| `frame` | ✓ | | ✓ | +| `frame_fingerprint` | ✓ | | | +| `frame_perf` | ✓ | | | +| `frame_range` | ✓ | ✓ | ✓ | +| `frame_timeseries` | ✓ | ✓ | ✓ | +| `freeze_check` | ✓ | | | +| `game_options` | ✓ | | | +| `get_frame` | ✓ | ✓ | ✓ | +| `get_quads` | ✓ | | | +| `get_registers` | ✓ | ✓ | ✓ | +| `get_snapshots` | ✓ | ✓ | ✓ | +| `gl_coh_ring` | ✓ | | | +| `gl_fbo_peek` | ✓ | | | +| `gl_interp` | ✓ | | | +| `gl_present_ring` | ✓ | | | +| `gl_vram_diff` | ✓ | | | +| `gl_wide_fast` | ✓ | | | +| `gl_ws_ablate` | ✓ | | | +| `gp1_dump` | ✓ | | ✓ | +| `gpu_frame_dump` | ✓ | | ✓ | +| `gpu_opcodes` | ✓ | | | +| `gpu_ring_stats` | ✓ | | | +| `gpu_state` | ✓ | | ✓ | +| `gte_frame_stats` | ✓ | | | +| `gte_intpl_dump` | ✓ | | | +| `gte_latch_dump` | ✓ | | | +| `gte_ring_dump` | ✓ | | | +| `gte_state` | ✓ | | | +| `history` | ✓ | ✓ | ✓ | +| `hle_dump` | ✓ | | ✓ | +| `idle_skip` | ✓ | | | +| `imask_trace` | ✓ | | | +| `insn_freeze` | ✓ | | | +| `insn_freeze_snapshot` | ✓ | | | +| `insn_freeze_status` | ✓ | | | +| `insn_freeze_target` | ✓ | | | +| `irq_state` | ✓ | | ✓ | +| `irqctx_ring` | ✓ | | | +| `kernel_bless` | ✓ | | | +| `latency` | ✓ | | | +| `load_transitions` | ✓ | | | +| `lockstep` | ✓ | | | +| `lockstep_func` | ✓ | | | +| `mc_status` | ✓ | | | +| `mdec_state` | ✓ | | | +| `mdec_trace` | ✓ | | | +| `mdec_trace_clear` | ✓ | | | +| `mem_words` | ✓ | | | +| `mmio_clear` | ✓ | | ✓ | +| `mmio_dump` | ✓ | | ✓ | +| `mmx6_freshfix` | ✓ | | | +| `overlay_candidates` | ✓ | | | +| `overlay_capture_dump` | ✓ | | | +| `overlay_cps_probe` | ✓ | | | +| `overlay_diff_off` | ✓ | | | +| `overlay_diff_on` | ✓ | | | +| `overlay_dump` | ✓ | | | +| `overlay_fp_dump` | ✓ | | | +| `overlay_irq_ratelimit` | ✓ | | | +| `overlay_irq_suppress_off` | ✓ | | | +| `overlay_irq_suppress_on` | ✓ | | | +| `overlay_loader_status` | ✓ | | | +| `overlay_native_block` | ✓ | | | +| `overlay_native_event_granularity` | ✓ | | | +| `overlay_native_off` | ✓ | | | +| `overlay_native_on` | ✓ | | | +| `overlay_native_ring` | ✓ | | | +| `overlay_rescan` | ✓ | | | +| `overlay_shadow_detail` | ✓ | | | +| `overlay_shadow_dump` | ✓ | | | +| `pace_state` | ✓ | | | +| `pad_status` | ✓ | ✓ | | +| `parity_ctl` | ✓ | ✓ | | +| `parity_dump` | ✓ | ✓ | | +| `pause` | ✓ | | ✓ | +| `phase_hot` | ✓ | | | +| `phase_profile` | ✓ | | | +| `ping` | ✓ | ✓ | ✓ | +| `present_ring` | ✓ | | | +| `press` | ✓ | ✓ | | +| `probe_clear` | ✓ | | | +| `probe_trace` | ✓ | | | +| `quit` | ✓ | | ✓ | +| `ra_load_watch` | ✓ | | | +| `read_frame_ram` | ✓ | ✓ | ✓ | +| `read_ram` | ✓ | ✓ | ✓ | +| `record_frame` | ✓ | | | +| `record_frame_dump` | ✓ | | | +| `record_reads_dump` | ✓ | | | +| `restore_trace` | ✓ | | | +| `restore_trace_clear` | ✓ | | | +| `restore_trace_window` | ✓ | | | +| `rtrace_arm` | ✓ | ✓ | | +| `rtrace_clear` | ✓ | | | +| `rtrace_disarm` | | ✓ | | +| `rtrace_disarm_all` | | ✓ | | +| `rtrace_dump` | ✓ | ✓ | | +| `rtrace_ranges` | ✓ | ✓ | | +| `rtrace_reset` | | ✓ | | +| `rtrace_stats` | ✓ | ✓ | | +| `run_to_frame` | ✓ | | ✓ | +| `s3_smear_watch` | ✓ | | ✓ | +| `savestate` | ✓ | | | +| `screenshot` | ✓ | ✓ | ✓ | +| `screenshot_file` | ✓ | ✓ | ✓ | +| `set_input` | ✓ | ✓ | ✓ | +| `set_snapshot` | ✓ | ✓ | ✓ | +| `sio_arm_audit` | ✓ | | | +| `sio_burst_stats` | ✓ | | | +| `sio_ctrl_reg_clear` | ✓ | | | +| `sio_ctrl_reg_trace` | ✓ | | | +| `sio_ctrl_reg_window` | ✓ | | | +| `sio_irq_dump` | ✓ | | | +| `sio_irq_window` | ✓ | | | +| `sio_pc_trace` | ✓ | | | +| `sio_pc_window` | ✓ | | | +| `sio_state` | ✓ | | ✓ | +| `sio_trace` | ✓ | ✓ | | +| `sio_trace_reset` | | ✓ | | +| `sio_trace_window` | ✓ | | | +| `sio_write_window` | | ✓ | | +| `sp_ring` | ✓ | | | +| `spu_events` | ✓ | ✓ | | +| `spu_events_reset` | ✓ | | | +| `spu_ram` | ✓ | | | +| `spu_status` | ✓ | | | +| `spu_voices` | ✓ | ✓ | | +| `sreg_trace_clear` | ✓ | | | +| `sreg_trace_dump` | ✓ | | | +| `sreg_trace_find` | ✓ | | | +| `sreg_trace_stats` | ✓ | | | +| `stack_profile` | ✓ | | | +| `starv_ring` | ✓ | | | +| `step` | ✓ | | ✓ | +| `synth_recurse` | ✓ | | | +| `thread_ctx_ring` | ✓ | | | +| `thread_trace` | ✓ | | | +| `thread_trace_clear` | ✓ | | | +| `timers_state` | ✓ | | | +| `turbo` | ✓ | | ✓ | +| `turbo_audio_sink` | ✓ | | | +| `turbo_loads` | ✓ | | | +| `turbo_state` | ✓ | | ✓ | +| `unknown_dispatch_log` | ✓ | | | +| `unwatch` | ✓ | | ✓ | +| `vblank_rate` | ✓ | | | +| `vk_perf` | ✓ | | | +| `vram_peek` | ✓ | ✓ | ✓ | +| `vsync_query_hle` | ✓ | | | +| `warm_cd_route` | ✓ | | | +| `watch` | ✓ | | ✓ | +| `wide_full` | ✓ | | | +| `wide_shot` | ✓ | | | +| `write_ram` | ✓ | | ✓ | +| `ws_aspect` | ✓ | | | +| `ws_aspect_cone_site` | ✓ | | | +| `ws_backdrop_margin` | ✓ | | | +| `ws_backdrop_ring` | ✓ | | | +| `ws_backdrop_stretch` | ✓ | | | +| `ws_census` | ✓ | | | +| `ws_dbg_stretch` | ✓ | | | +| `ws_dome` | ✓ | | | +| `ws_dome_probe` | ✓ | | | +| `ws_far_threshold` | ✓ | | | +| `ws_hud_mode` | ✓ | | | +| `ws_margin` | ✓ | | | +| `ws_nw` | ✓ | | | +| `wtrace_add` | ✓ | | | +| `wtrace_all_dump` | ✓ | ✓ | | +| `wtrace_all_reset` | ✓ | ✓ | | +| `wtrace_all_stats` | ✓ | ✓ | | +| `wtrace_arm` | ✓ | ✓ | | +| `wtrace_boot_dump` | ✓ | | | +| `wtrace_boot_reset` | ✓ | | | +| `wtrace_boot_stats` | ✓ | | | +| `wtrace_boot_summary` | ✓ | | | +| `wtrace_clear` | ✓ | | ✓ | +| `wtrace_del` | ✓ | | | +| `wtrace_disarm` | ✓ | ✓ | | +| `wtrace_disarm_all` | ✓ | ✓ | | +| `wtrace_dump` | ✓ | ✓ | ✓ | +| `wtrace_range` | ✓ | | ✓ | +| `wtrace_ranges` | ✓ | ✓ | | +| `wtrace_reset` | ✓ | ✓ | | +| `wtrace_stats` | ✓ | ✓ | | +| `wtrace_trans_dump` | ✓ | | | +| `wtrace_trans_reset` | ✓ | | | +| `wtrace_trans_stats` | ✓ | | | +| `xlate` | ✓ | | | +| `xprobe` | ✓ | | | +| `xprobe_arm` | ✓ | | | + + diff --git a/THIRD_PARTY_ATTRIBUTION.md b/THIRD_PARTY_ATTRIBUTION.md index 35129ccb..b57d5f90 100644 --- a/THIRD_PARTY_ATTRIBUTION.md +++ b/THIRD_PARTY_ATTRIBUTION.md @@ -1,5 +1,18 @@ # Third-Party Attribution +## OpenBIOS — PCSX-Redux's free PS1 BIOS + +[OpenBIOS](https://github.com/grumpycoders/pcsx-redux) (src/mips/openbios) by +the PCSX-Redux authors, licensed **MIT** (notice: `bios/OpenBIOS.LICENSE`; +the binary also links permissively-licensed code from +[uC-sdk](https://github.com/grumpycoders/uC-sdk), noted there too). Vendored +as the prebuilt image `bios/openbios.bin` (pin and build recipe recorded in +`bios/OpenBIOS.toml`) and statically recompiled by +`psxrecomp-bios --config bios/OpenBIOS.toml` exactly like the retail BIOS. +Normal runtime builds stage it automatically so players supply only a disc; +`bios/OpenBIOS.LICENSE` always rides alongside the shipped image. + + ## sljit — stack-less JIT compiler (Tier-2 in-process overlay backend) [sljit](https://github.com/zherczeg/sljit) by Zoltan Herczeg, licensed diff --git a/WIDESCREEN.md b/WIDESCREEN.md index 6594947c..17130b48 100644 --- a/WIDESCREEN.md +++ b/WIDESCREEN.md @@ -65,19 +65,59 @@ sprite_anchor_addr = "0x1F800070" # scratchpad holding the prim's # GTE-projected anchor SXY at tag time. hud_sprt_squash = true # center/edge-squash untagged SPRTs # (screen-space HUD/menus). +auto_ui_squash = true # pre-scan the current GPU linked list, + # select its highest populated UI rank, + # and share one anchor across each + # complete glyph/icon run. clear_reveal = true # clear synthetic native-wide side margins # at opted-in scene/map boundaries (default false). nw_left_hud_packet_lo = "0x000E3400" # optional targeted left-HUD packet range nw_left_hud_packet_hi = "0x000E4100" # (half-open); avoids shifting 2D scenery. offer_ultrawide = true # separate experimental 21:9 launcher row. +adaptive_view = true # expose live resize-driven aspect mode. + +# Proven object/model participation comparisons may be forced only while a +# widened world view is configured. The complete instruction word guards +# same-address overlay variants; true 4:3 executes the vanilla comparison. +[[widescreen.cull.keep]] +address = "0x8002B310" +expected = "0x28A21C01" # SLT/SLTU/SLTI/SLTIU only +result = 1 # forced comparison result, 0 or 1 ``` +When `adaptive_view` is enabled by the game, the launcher's **Aspect ratio** +selector gains an **Adaptive** entry. The previously selected fixed aspect +still determines the initial window shape. Once the game window exists, +resizing it continuously +updates the GTE/native-wide projection, cull margins, wide render target, and +present aspect. The live ratio is clamped to 4:3 on the narrow side and to the +widest mode the game offers (`16:9`, or `21:9` with `offer_ultrawide`). BIOS, +FMV, menus, and other title-classified 2D frames retain their existing 4:3 +pillarbox policy. The user choice persists as `[video] adaptive_view` in +`settings.toml`. + +The native-wide draw-area early-out correction was independently identified +and contributed by **OpokXeno** in +[PR #73](https://github.com/mstan/psxrecomp/pull/73). The landed variant keeps +that mirror-space correction while restricting it to active framebuffer +targets, so offscreen texture targets retain the original host-side pruning. + Tomba's values are Ghidra-evidenced: `0x8005E08C` is the shared per-prim helper all ~23 character render functions (RTPS cluster `0x800459E0`–`0x8004FB54`) call; the RTPS preamble stores the anchor SXY to scratchpad `0x1F800070`. +`auto_ui_squash` is a runtime-only opt-in and does not require regenerated game +code. It applies only on the projection-and-stretch path: eligible +axis-aligned textured quads/rectangles in the front populated ordering-table +layer are grouped by texture and screen row before any command is transformed. +Depth-sorted world packets, full-frame backdrops, and true 4:3 frames remain +untouched. + **Changing `sprite_tag_funcs` requires a game regen** (the tag callback is -emitted into the generated C). Everything else is runtime-only. +emitted into the generated C). `widescreen.cull.keep` is consumed by both the +native recompiler and the dirty-RAM interpreter: regenerate main/overlay native +code after changing it, while the interpreter reads the same guarded records +from the runtime config. --- diff --git a/accuracy/axis5_sio_controller.md b/accuracy/axis5_sio_controller.md index 2263f5f6..52a09878 100644 --- a/accuracy/axis5_sio_controller.md +++ b/accuracy/axis5_sio_controller.md @@ -6,7 +6,13 @@ against the in-tree Beetle/Mednafen oracle `frontio.cpp`, `input/dualshock.cpp`, `input/gamepad.cpp`, `input/memcard.cpp`) and nocash psx-spx "Controllers and Memory Cards". -READ-ONLY research. No code was modified. Citations use `file:line`. +Historical read-only research; citations use the line numbers from that audit. + +> **Status 2026-07-26:** Tomba now offers explicit Analog / D-Pad modes instead +> of Hybrid. The Tomba-only `g_pad_legacy_cfg` protocol fork, its game-config +> key, debug command, and helper script have been removed. References to them +> below document the retired implementation and are not descriptions of the +> current runtime. --- @@ -245,7 +251,10 @@ every title — which is exactly what the LEGACY comment predicted, letting the ## 4. Prioritized fix list (player-visible impact first) -> **STATUS 2026-06-27 (branch wt/tomba2-axis5-controller):** Fixes 1, 2, 3 IMPLEMENTED +> **STATUS 2026-07-26:** Fixes 1, 2, 3 are implemented. Fix 4 is also complete: +> Tomba no longer uses Hybrid, and the legacy pad-config branch has been removed. +> +> **Prior status 2026-06-27 (branch wt/tomba2-axis5-controller):** Fixes 1, 2, 3 IMPLEMENTED > in sio.c (0x43 live frame; 0x45 live analog byte; 0x44 analog-mode-lock + hybrid-flip > lock gate), transcribed from dualshock.cpp. Validated no-regression on Tomba 2 > (digital pad, pad 0xFFFF at rest, boots). These are gated to the MODERN SM and leave @@ -275,10 +284,10 @@ every title — which is exactly what the LEGACY comment predicted, letting the game-locked mode) — mirror `dualshock.cpp:203,714-725`. Prevents the hybrid heuristic from fighting games that pin DualShock. -4. **[P1] Retire `g_pad_legacy_cfg` once 1–3 land** (sio.c:665-717, the LEGACY - block + `legacy_pad_config` game.toml key + Tomba's opt-in). The completeness - path the file itself mandates: one correct SM for all titles, no per-game - compat branch. Validate Tomba on the modern SM after 1–3. +4. **[DONE 2026-07-26] Retire `g_pad_legacy_cfg`.** Tomba now exposes only + explicit Analog / D-Pad modes, so there is no live type flip to accommodate. + The flag, legacy SIO branches, config key, debug command, helper script, and + Tomba opt-in were removed. All titles use the modern DualShock state machine. 5. **[P2] Echo-back `0x4D` rumble map** (D7). Even with no rumble output, echo the previous `rumble_magic[]` bytes (`dualshock.cpp:993-1024`) so a game @@ -321,8 +330,8 @@ the always-on rings), drive the same input, then diff: does), then deflect the stick. `pad_status.analog` must NOT change while locked; cross-check Beetle stays in its locked mode across the same input. -- **Fix 4 (retire legacy):** run Tomba on the modern SM with `legacy_pad_config` - off. `pad_status` at rest = `0xFFFF` both slots, no disconnect blips; menu +- **Fix 4 (retire legacy):** run Tomba in both explicit Analog and D-Pad modes + on the modern SM. `pad_status` at rest = `0xFFFF` both slots, no disconnect blips; menu responsive; in-game d-pad↔stick transitions produce no phantom dash/unpause. Diff a 600-byte `sio_trace` window vs Beetle for the same navigation — RX streams should match transaction-for-transaction (allowing the D3 stick-order diff --git a/bios/OpenBIOS.LICENSE b/bios/OpenBIOS.LICENSE new file mode 100644 index 00000000..cfe37313 --- /dev/null +++ b/bios/OpenBIOS.LICENSE @@ -0,0 +1,30 @@ +License notice for bios/openbios.bin — OpenBIOS, the PCSX-Redux project's +free PS1 BIOS (https://github.com/grumpycoders/pcsx-redux, src/mips/openbios). +Built at the commits pinned in bios/OpenBIOS.toml. The binary also links code +from uC-sdk (https://github.com/grumpycoders/uC-sdk), a mixture of permissive +(non-reciprocal) licenses that require this mention. + +The OpenBIOS source carries the following notice, reproduced here because the +binary is redistributed with games verified against it: + +MIT License + +Copyright (c) 2019 PCSX-Redux authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bios/OpenBIOS.toml b/bios/OpenBIOS.toml new file mode 100644 index 00000000..de6a781b --- /dev/null +++ b/bios/OpenBIOS.toml @@ -0,0 +1,126 @@ +# bios/OpenBIOS.toml — BIOS build profile for PCSX-Redux OpenBIOS. +# +# OpenBIOS is the PCSX-Redux project's from-scratch, MIT-licensed PS1 BIOS +# (https://github.com/grumpycoders/pcsx-redux, src/mips/openbios). MIT means +# the image is REDISTRIBUTABLE: a game verified against it ships its BIOS +# with the build and the player supplies only a disc (redistributable=true +# below removes the whole BIOS-selection surface at runtime). +# +# Pinned build (bios/openbios.bin + the ELFs the seeds derive from): +# pcsx-redux 55fbf0468345acfbe5512628a3be5c0cfe3f22e7 +# uC-sdk 69e06871824e2d62069487a7426ded09090ceb69 +# toolchain g++-mipsel-none-elf (GCC 16.1.0, static.grumpycoder.net build) +# build make -C src/mips/openbios PREFIX=mipsel-none-elf FORMAT=elf32-littlemips +# Regenerating seeds after a re-pin (--extra is REQUIRED to keep the 5 +# empirically-discovered dispatch-miss seeds; without it they are silently +# dropped and the regenerated set has 641 entries, not 646): +# tools/gen_bios_seeds_from_elf.py --profile bios/OpenBIOS.toml \ +# --elf .../openbios.elf --elf .../shell.elf \ +# --extra recompiler/seeds/openbios_dispatch_miss.json \ +# --out recompiler/seeds/openbios_elf_seeds.json +# +# The address model below was established by direct disassembly of the boot +# path (reset vector -> copy loops), each copy confirmed twice (boot + the +# warm-restart path reloading lengths from ROM constants; the shell length +# additionally confirmed by the blob's own self-relocation constants). + +[program] +name = "PCSX-Redux OpenBIOS" +id = "OPENBIOS" +rom = "bios/openbios.bin" +load_address = "0xBFC00000" +entry_pc = "0xBFC00000" +text_size = "0x80000" + +[program.image] +sha256 = "fabe498fbf224e4721f12f31b6f5fe0659205e341dc4e5c5f91b9bd1a1011c57" +license = "MIT" +redistributable = true + +[recompiler] +seeds = "recompiler/seeds/openbios_elf_seeds.json" +out_dir = "generated" +out_stem = "OpenBIOS" +strict = true + +# OpenBIOS's boot-time bulk code copies. Windows are BYTE-PROVEN against +# openbios.elf (readelf -lW program headers give the .data LMA; the embedded +# shell blob was located by content search; both ranges memcmp-verified +# ELF-content == image-content — an earlier hand-disassembly of the copy +# routines mis-read both source offsets by 0xB8/0x2C0-class deltas, which +# emitted every shell/ramtext function from shifted bytes): +# - kernel .data (VMA 0x500, WAX — code+data): LMA 0xBFC1E4D4, FileSiz +# 0x46A0, copied to RAM 0x500 at boot. Byte-verbatim -> kernel_bless. +# - shell blob (objcopy of shell.elf, linked at 0x80030000): embedded in +# .rodata at ROM 0xA500, size 0x13D44, copied to RAM 0x80030000. The +# first-crash a1=0xBFC1E244 was exactly this blob's END pointer mid-copy. +# NOT modeled, by design: +# - the 4x16-byte vector stubs copied to RAM 0x80/0xA0/0xB0/0xC0 +# (runtime-written trampolines -> dirty-RAM interp territory, rule 18); +# - the RAM->RAM A0-table move (0x4844 -> 0x200): nothing dispatches TO the +# table, discovery gets entries from ELF symbols, and runtime dispatch +# keys on the actual jump targets. +[recompiler.address_model] +normalize_mask = "0x1FFFFFFF" + +[[recompiler.address_model.copy]] +name = "Kernel ramtext" +rom_lo = "0x1FC1E4D4" +rom_hi = "0x1FC22B74" +ram_lo = "0x00000500" +runtime_base = "0x00000500" +dispatch_key = "ram" +kernel_bless = true + +[[recompiler.address_model.copy]] +name = "Shell" +rom_lo = "0x1FC0A500" +rom_hi = "0x1FC1E244" +ram_lo = "0x00030000" +runtime_base = "0x80030000" +dispatch_key = "rom" +kernel_bless = false + +# No [[recompiler.install_slots]]: none identified yet. Runtime-installed +# stubs fall to the dirty-RAM interpreter, which is correct (just slower) +# until a slot is identified and added. + +# HLE anchors: only the boot-skip anchor is exported. deliver_event_ret is +# deliberately ABSENT (0) until the B0 event services are validated against +# this kernel's EvCB behavior (they are believed compatible — OpenBIOS +# reimplements the documented kernel ABI, EvCB anchor 0x120/0x124, TCB +# 0x108/0x110 — but believed is not verified). Absent = the kernel-call HLE +# tier is STRUCTURALLY UNAVAILABLE here and config/env cannot force it on. +# +# That refusal is scoped to kernel calls ONLY. The boot-skip is a separate axis +# gated on shell_entry_phys alone and works under pure LLE, so `bios_hle` (and +# the deprecated `fast_boot` alias) skips the boot animation and goes straight +# to the game on THIS image exactly as it does on retail SCPH-1001 — the flag +# means one thing on every BIOS. Both axes are decided in one pure place, +# psx_bios_hle_plan() (runtime/include/bios_hle_plan.h), which exists because +# deriving the boot-skip from the anchor-gated call tier silently took the skip +# away on this image (fixed 2026-07-27). +# +# shell_entry_phys was verified against the generated C (2026-07-23): +# - startShell (ELF symbol, ROM 0xBFC074DC) ends `lui $t9,0x8003; jr $t9` +# (0xBFC07534/38) — a TAIL jump to the RAM shell copy at 0x80030000 +# that goes through the dispatch trampoline, where the hook fires on +# the pre-normalize phys 0x00030000; +# - $ra at that point is the jal link 0xBFC06EA8 (startShell restores its +# stack before the tail jump), so the intercept's resume-at-$ra lands +# exactly at the shell call's continuation; +# - that continuation OVERWRITES $v0 immediately (addiu $v0,$zero,8 — a +# boot-progress POST byte), so the intercept's v0=0 is never observed, +# and the boot proceeds unconditionally to the SYSTEM.CNF/EXE load +# (gameMainThunk), exactly the retail LoadRunShell contract the skip +# models. The shell's own animation (waitVSync/flip/convexHull in +# shell.elf) is the only thing skipped. +[recompiler.runtime_exports] +shell_entry_phys = "0x00030000" + +# No [runtime] block: a BIOS profile states facts about an image, never runtime +# preferences, and load_bios_config rejects one outright. This file used to end +# with `[runtime] bios_hle = false`, which nothing read — the recompiler parsed +# it into a field with no consumers. Runtime options live in game.toml / +# settings.toml; per-image HLE capability is expressed by which anchors +# [recompiler.runtime_exports] above does and does not export. diff --git a/bios/SCPH1001.toml b/bios/SCPH1001.toml new file mode 100644 index 00000000..1f95ade7 --- /dev/null +++ b/bios/SCPH1001.toml @@ -0,0 +1,79 @@ +# bios/SCPH1001.toml — BIOS build profile for the retail Sony SCPH-1001 image. +# +# One profile per BIOS image. Consumed by `psxrecomp-bios --config`, by +# tools/regen_bios.sh (which reads rom/seeds/out_dir/out_stem from here), and +# by tools/bios_emitter_fingerprint.sh (which hashes this file, the seeds and +# the ROM into the generated/.emitter.sha staleness stamp). +# +# The .BIN image itself stays untracked (bios/* gitignore rule) — profiles are +# the only tracked contents of this directory. + +[program] +name = "Sony SCPH-1001 BIOS" +id = "SCPH-1001" +rom = "bios/SCPH1001.BIN" +load_address = "0xBFC00000" +entry_pc = "0xBFC00000" +text_size = "0x80000" + +[recompiler] +seeds = "recompiler/seeds/phase2_ghidra_seeds.json" +out_dir = "generated" +out_stem = "SCPH1001" +strict = true + +# The BIOS's boot-time bulk copies of CODE out of ROM into RAM. Single source +# of truth for every relocation window in the recompiler AND in the C it +# emits (the generated dispatch's normalize(), the kernel-bless guard, the +# game/shell-overlap tests). Each entry is a CLAIM that the copy is +# byte-verbatim; the runtime's kernel-bless memcmp enforces it for the +# bless window. All bounds are [lo, hi) with hi EXCLUSIVE. +# +# dispatch_key = "ram": functions in the window are keyed by RAM address +# (normalize folds ROM -> RAM). +# dispatch_key = "rom": functions keep their ROM key (normalize folds the +# RAM alias back to ROM). +[recompiler.address_model] +normalize_mask = "0x1FFFFFFF" + +[[recompiler.address_model.copy]] +name = "Kernel Part 2" +rom_lo = "0x1FC10000" +rom_hi = "0x1FC18000" +ram_lo = "0x00000500" +runtime_base = "0x00000500" # KUSEG — the kernel executes uncached-region code cached at 0x500 +dispatch_key = "ram" +kernel_bless = true + +[[recompiler.address_model.copy]] +name = "Shell" +rom_lo = "0x1FC18000" +rom_hi = "0x1FC43000" +ram_lo = "0x00030000" +runtime_base = "0x80030000" # KSEG0 — LoadRunShell jumps to 0x80030000 +dispatch_key = "rom" +kernel_bless = false +# NOTE (do not "fix" without evidence): runtime/src/bios_hle.c documents the +# actual LoadRunShell copy as 0x67FF0 bytes (ROM 0xBFC18000 -> RAM 0x30000.. +# 0x97FF0). Every pre-profile window constant covered only 0x2B000 of that, +# and dirty_ram_interp.c tracks live RAM in [0x5B000,0x8F000) as an open +# class-B bug. Widening this window is a REAL BEHAVIOR CHANGE requiring its +# own evidence-backed commit — it is not a parameterization cleanup. + +# Kernel-RAM PCs the BIOS overwrites with 4-instruction dispatch stubs at +# runtime (CLAUDE.md rule 18; docs/dynamic_handler_install.md). The emitter +# plants a dirty-check hook at these PCs so the installed stub executes. +[[recompiler.install_slots]] +ram_addr = "0x00000CF0" # SIO data-byte handler dispatch slot + +# Per-image anchors couriered into the generated C (psx_bios_image) for the +# runtime HLE tier. Values are FACTS about this image, not tunables: the +# shell entry is where LoadRunShell (ROM 0xBFC06FF0) jumps after its copy; +# deliver_event_ret is the instruction after the kernel DeliverEvent loop's +# `jalr v0` (kernel RAM 0x1718), the $ra a delivered event callback returns +# through. Omit a key on a BIOS that has no such anchor — the runtime then +# treats that HLE feature as structurally unavailable instead of firing on a +# wrong address. +[recompiler.runtime_exports] +shell_entry_phys = "0x00030000" +deliver_event_ret = "0x80001720" diff --git a/bios/openbios.bin b/bios/openbios.bin new file mode 100644 index 00000000..f7597d11 Binary files /dev/null and b/bios/openbios.bin differ diff --git a/build_recompiler.sh b/build_recompiler.sh deleted file mode 100755 index e94ebcbd..00000000 --- a/build_recompiler.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -cmake -S recompiler -B recompiler/build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build recompiler/build diff --git a/build_runtime.sh b/build_runtime.sh deleted file mode 100755 index dfe41a17..00000000 --- a/build_runtime.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -cmake -S runtime -B runtime/build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build runtime/build --target psx-runtime diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3293157c..cefb7073 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,7 +64,8 @@ the paths to its generated C. The runtime provides: (`cdrom.c`, `iso_reader.cpp`), MDEC, SIO0 controllers/memory cards (`sio.c`, `memcard.c`), SPU (`spu.c`), GTE (`gte.cpp`), and interrupt delivery (`interrupts.c`). -- **Host services** — window/input/audio via SDL2, a cooperative-thread +- **Host services** — window/input/audio via SDL3 by default (SDL2 is an + explicit compatibility backend), a cooperative-thread scheduler on host fibers (`psx_fiber.c`: Win32 Fibers / POSIX `ucontext`), and the optional debug TCP server (`debug_server.c`). @@ -75,14 +76,31 @@ kernel, and it is the reference implementation and the correctness oracle. There are no per-vector HLE shims replacing it. On top of that, PSXRecomp carries an optional **HLE tier** (`bios_hle`, on by -default for player convenience) that can synthesize the post-boot kernel handoff -state to skip the BIOS boot sequence, and intercept a small set of BIOS services -— always falling through to the recompiled BIOS for anything it doesn't -implement. LLE stays fully linked and is what every accuracy check runs against. -Turn it off with `[runtime] bios_hle = false` or `PSX_BIOS_HLE=0`; with it off the -build behaves as pure LLE. (Design notes: +default for player convenience) that skips the BIOS boot sequence and intercepts +a small set of BIOS services — always falling through to the recompiled BIOS for +anything it doesn't implement. LLE stays fully linked and is what every accuracy +check runs against. Turn it off with `[runtime] bios_hle = false` or +`PSX_BIOS_HLE=0`; with it off the build behaves as pure LLE. (Design notes: [`docs/internal/HLE_SCHEDULER_CARVEOUT_PLAN.md`](internal/HLE_SCHEDULER_CARVEOUT_PLAN.md).) +Those are **two independent axes**, and the distinction matters because a build +links more than one BIOS ([`BIOS_SELECTION.md`](BIOS_SELECTION.md)): + +| axis | what it needs from the image | on retail SCPH-1001 | on bundled OpenBIOS | +|---|---|---|---| +| boot-skip | `shell_entry_phys` — works under pure LLE | yes | yes | +| kernel-call HLE | `deliver_event_ret` — the kernel's own DeliverEvent `$ra` | yes | refused, loudly | + +So **"skip the BIOS and go straight to the game" means the same thing on every +BIOS**: the boot-skip is not synthesis, it just returns immediately from the +shell call, so it needs nothing BIOS-specific beyond knowing where the shell is +entered. Whether the *kernel-call* tier is additionally available is a separate, +per-image question, and refusing it must never cancel the boot-skip — deriving +one from the other is the bug fixed in 2026-07. Both axes are decided in one pure +place, `psx_bios_hle_plan()` +([`runtime/include/bios_hle_plan.h`](../runtime/include/bios_hle_plan.h)), which +is unit-tested over the whole matrix. + ### Static / overlay / interpreter dispatch This is the heart of the system and has its own doc, @@ -123,7 +141,10 @@ Three GPU backends behind one interface: - **OpenGL** — GPU-authoritative VRAM/FBO renderer, the default; moves rasterization and supersampling onto the GPU. Falls back to software if GL init fails. (See [`docs/internal/GL_RENDERER_HANDOFF.md`](internal/GL_RENDERER_HANDOFF.md).) -- **Vulkan** — experimental, off by default (`PSX_ENABLE_VULKAN=OFF`). +- **Vulkan** — experimental. The build option `PSX_ENABLE_VULKAN` defaults **ON** + (compiled when the SDK tools are present), but it is not the runtime default + renderer: selecting it also requires the game to offer Vulkan and the user to + request it, otherwise the runtime falls back to OpenGL. Widescreen (a genuine wider GTE FOV, not a stretch) is opt-in and gen-time; see [`WIDESCREEN.md`](../WIDESCREEN.md) and @@ -146,9 +167,14 @@ backend against the interpreter. See ## Configuration -Every process has a **BIOS config**; a game adds a **game config** (`game.toml`), -and the two are merged (game wins on scalar keys; program blocks and generated -dispatch tables are additive). Full schema: +A game is configured by its **game config** (`game.toml`). A **BIOS config** +(`bios/*.toml`) describes a BIOS image's identity and address model. + +The two are **not merged.** The BIOS config is consumed only by the recompiler +at build time (`psxrecomp-bios`, and `psxrecomp-game` for the address model); +the shipping runtime never loads it — `runtime/src/main.cpp` contains no call to +`load_bios_config`. `[runtime]` keys therefore take effect only from `game.toml`, +`settings.toml`, the CLI, and the environment. Full schema: [`docs/config_schema.md`](config_schema.md). ## Where to go next diff --git a/docs/BIOS_SELECTION.md b/docs/BIOS_SELECTION.md new file mode 100644 index 00000000..c699730c --- /dev/null +++ b/docs/BIOS_SELECTION.md @@ -0,0 +1,149 @@ +# Which BIOS a build uses + +A PlayStation game needs a BIOS. PSXRecomp can supply one — **OpenBIOS**, an +MIT-licensed, from-scratch PS1 BIOS from the PCSX-Redux project that we are +allowed to redistribute — so a player can be handed a build and a disc image and +just play. A player who prefers their own dumped retail BIOS can use that +instead. + +Both recompiled BIOS backends are linked into every normal build. The OpenBIOS +image itself and its MIT notice are staged in `bios/` beside the executable; +the retail image is never shipped and comes from the player. Which backend runs +is decided when the game launches, not when it is built. + +## The rule + +> **No BIOS chosen → OpenBIOS. A BIOS the player explicitly chose → that BIOS.** + +That is the whole user-facing contract. A player never has to find a BIOS file +to start playing, and never loses the ability to use their own. + +A developer can switch this off for a title that is known not to work on +OpenBIOS; then a retail BIOS is required and the player is prompted for one. + +## What counts as "chosen" + +Only a deliberate act by the player: + +- `--bios ` on the command line, or +- a BIOS selected in the launcher / settings, which is then remembered. + +**Finding a BIOS file on disk does not count.** Earlier versions searched +directories near the executable and would quietly adopt whatever they found. +That made behaviour depend on what happened to be lying around — two players +with identical builds could get different BIOSes. Discovery is no longer a +selection mechanism. + +A player who has chosen a BIOS can clear that choice and go back to OpenBIOS; +the choice is not permanent. + +## "A retail BIOS" means one specific image + +A build compiles in exactly one retail image (currently SCPH-1001) alongside +OpenBIOS. The compiled-in code is generated *from that exact image*, so a +different dump — SCPH-101, SCPH-5552, a bad rip — cannot be substituted: the +recompiled code would execute against data it does not match, and crash. + +Every build therefore checks the identity of a chosen BIOS before using it, and +says which image it expects if the check fails. This is not DRM and not a +preference; it is the difference between running and wild-jumping. + +The acceptance gate compares **file size and CRC32** against each linked +backend's recorded identity (`bios_backend_for_file` in `runtime/src/main.cpp`). +A SHA-256 is also recorded per image (`image_sha256` in +`runtime/include/psx_bios_image.h`) and is surfaced for provenance and bug +reports, but it is **not** part of the accept/reject decision. + +If a chosen BIOS does not match: + +- with OpenBIOS available, the player is told why and can continue on OpenBIOS; +- with OpenBIOS disabled for the title, the choice is rejected and re-prompted. + +## Developer setting + +One optional line in `game.toml`: + +```toml +[runtime] +openbios = true # default. false = this title requires a retail BIOS. +``` + +Set `openbios = false` only for a title with a **verified** OpenBIOS +incompatibility. Per-title compatibility is not implied by the framework +supporting OpenBIOS — verify a title before shipping it that way. + +This setting is deliberately **not** overridable by a player's `settings.toml`, +unlike most runtime options. It records a developer's compatibility finding, not +a preference. A player re-enabling OpenBIOS on a title known to break under it +produces confusing bug reports and no benefit. + +There is no third mode. A title either allows OpenBIOS (and still honours an +explicit retail choice) or requires retail. "OpenBIOS only, retail forbidden" +would take away a working option from players for no reason. + +## Boot behaviour does not depend on which BIOS runs + +Whichever BIOS is active, `[runtime] bios_hle` (on by default) skips the BIOS +boot sequence and goes straight to the game. That is deliberate and enforced: the +skip is not a synthesized handoff, it just returns immediately from the BIOS's +call into its shell, so all it needs is the per-image address of that shell entry +— which every linked BIOS records. A player who switches BIOS gets the same +boot, and the flag means one thing everywhere. + +The HLE tier's *other* half — servicing a few kernel calls (the B0 event family) +in the runtime instead of the recompiled BIOS — **is** per-image, because it needs +the kernel's own `DeliverEvent` return address to send a delivered callback back +to. Retail SCPH-1001 exports it; OpenBIOS deliberately does not until its event +semantics are validated, so OpenBIOS runs those calls on the recompiled kernel and +prints one line at startup saying so. That refusal is scoped to kernel calls and +never cancels the boot-skip. + +Until 2026-07 it did: the boot-skip was derived from the already-refused +kernel-call decision, so choosing OpenBIOS silently sat the player in the boot +animation while the identical build on retail went straight to the game. The two +axes are now resolved in one pure, unit-tested function +(`psx_bios_hle_plan()`, `runtime/include/bios_hle_plan.h`), and the test asserts +the boot decision is byte-identical across the two images. + +## Save data + +**Memory cards are unaffected.** They are card images; they carry no BIOS state +and move freely between BIOSes and between builds. + +**Savestates are BIOS-specific.** The kernel occupies RAM, and OpenBIOS and +retail lay that RAM out differently, so a savestate captured under one BIOS is +not valid under the other. Savestates therefore record which BIOS was active and +refuse to load under a different one. Without that check, switching BIOS would +silently corrupt a restored state — the only quiet failure mode in this design, +which is why it is enforced rather than warned about. + +## Why both are compiled in + +The alternative — a build flavour per BIOS — was the original design, and it +required a game to state its BIOS in three places that nothing cross-checked +(`bios_config` in `game.toml`, plus two CMake variables). Setting one and +forgetting another produced a build that linked one BIOS's code against another +BIOS's generated game code, and it compiled cleanly. + +Carrying both costs roughly 20 MB of binary and removes that entire class of +mistake: one build configuration, one source of truth, the choice made at +runtime where it can be validated. + +It is affordable because the game's own recompiled code is **identical** +regardless of which BIOS it was generated against — verified byte-for-byte on +Tomba!, Tomba! 2 and Ape Escape. Only the BIOS code is duplicated, and the two +images collide on just 22 symbols, which are namespaced per BIOS. + +## Attribution + +OpenBIOS is MIT-licensed. Its notice is vendored at `bios/OpenBIOS.LICENSE`, +with the upstream source pin and build recipe in `bios/OpenBIOS.toml` and +attribution in `THIRD_PARTY_ATTRIBUTION.md`. Builds that ship it credit the +PCSX-Redux authors in the launcher, whether or not the licence compels it. + +Native runtime builds automatically stage both `bios/openbios.bin` and +`bios/OpenBIOS.LICENSE`. Release packaging must copy that directory as a unit; +shipping the image without its notice violates the distribution contract. + +Retail BIOS images are **not** redistributable and are never shipped. A player +using one supplies their own dump. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index ac97a05e..d02dbf0c 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -8,8 +8,11 @@ Silicon & Intel), and **Linux**. There are two things you can build: - a **game** — done from that game's own repository, which links this framework in as a submodule (see [Linking the framework](#linking-the-framework)). -You always supply your own **`SCPH1001.BIN` BIOS** and, for a game, your own -legally-obtained **disc image**. This project ships none of those. +For the retail **`SCPH1001.BIN` BIOS** you supply your own dump; the +MIT-licensed **OpenBIOS** alternative is bundled (`bios/openbios.bin`, see +[Regenerating BIOS backends](#regenerating-bios-backends)). For a +game you always supply your own legally-obtained **disc image** — this +project ships no game data. ## Toolchain requirements @@ -25,7 +28,8 @@ legally-obtained **disc image**. This project ships none of those. | Dependency | How it's provided | Used for | |---|---|---| -| **SDL2** | System. Windows/MSVC: prebuilt pack auto-found at `../sdl2-msvc/SDL2-*`. Windows/MinGW + macOS + Linux: found via pkg-config. | Window, input, GL/Vulkan context, audio, threads | +| **SDL3 3.4+** | Preferred system package; otherwise SDL 3.4.10 is fetched from the official release archive with a pinned SHA-256. | Window, input, GL/Vulkan context, audio, threads | +| **SDL2** | Optional explicit fallback (`-DPSX_SDL_BACKEND=SDL2`). Windows/MSVC uses `../sdl2-msvc/SDL2-*`; MinGW/macOS/Linux use pkg-config. | Compatibility and A/B testing | | **fmt** 9.1.0 | vendored `recompiler/lib/fmt` | String formatting (runtime uses it header-only) | | **toml11** | vendored `recompiler/lib/toml11` | Parsing `game.toml` / configs | | **ELFIO** | vendored `recompiler/lib/ELFIO` | ELF parsing (recompiler only) | @@ -33,38 +37,56 @@ legally-obtained **disc image**. This project ships none of those. | **TinyCC (TCC) 0.9.27** | Not in this repo — downloaded at release-packaging time and bundled beside the game exe in `overlay_toolchain/`. | Toolchain-free overlay compilation for players (run as a subprocess) | | **Python 3** | System (development) or an embedded copy bundled in releases | Runs `tools/compile_overlays.py` in the overlay pipeline | | **OpenGL** | System (`opengl32` on Windows; `find_package(OpenGL)` elsewhere) | The GL renderer | -| **Vulkan** | Headers only, optional, **off by default** (`PSX_ENABLE_VULKAN=OFF`); loaded dynamically via SDL. Shader compilation needs `glslc` from the Vulkan SDK. | The experimental Vulkan renderer | +| **Vulkan** | Headers only, **on by default** (`PSX_ENABLE_VULKAN=ON`) — built when the SDK tools are available, otherwise skipped; loaded dynamically via SDL. Shader compilation needs `glslc` from the Vulkan SDK. Pass `-DPSX_ENABLE_VULKAN=OFF` to exclude it. | The experimental Vulkan renderer | Developers building overlays locally just need `gcc` on `PATH` (the `gcc` tier); the bundled `tcc` matters only for end-user release packages. ### Get the source -This repository has no required git submodules: +This repository has two submodules, **neither of which is required** for the +recompiler and runtime builds described below: + +| Submodule | Needed when | +|---|---| +| `recomp-ui` | `-DPSX_RECOMP_UI=ON` (the launcher UI). Configure **fails** with a `FATAL_ERROR` if this is ON and the submodule is absent. | +| `lib/recomp-net` | `-DPSX_NETPLAY=ON` (netplay). | + +A plain clone is enough to build the recompiler and the runtime: ```sh git clone https://github.com/mstan/psxrecomp.git ``` +Add `--recurse-submodules` if you intend to build the launcher or netplay: + +```sh +git clone --recurse-submodules https://github.com/mstan/psxrecomp.git +``` + ## Per-platform prerequisites **Windows (MSYS2/MinGW — recommended for release parity):** ```sh # In an MSYS2 MinGW64 shell: pacman -S --needed mingw-w64-x86_64-toolchain mingw-w64-x86_64-cmake \ - mingw-w64-x86_64-ninja mingw-w64-x86_64-SDL2 mingw-w64-x86_64-ccache + mingw-w64-x86_64-ninja mingw-w64-x86_64-ccache ``` **macOS:** ```sh -brew install sdl2 pkg-config ninja cmake +brew install ninja cmake ``` **Linux (Debian/Ubuntu):** ```sh -sudo apt install build-essential cmake ninja-build pkg-config libsdl2-dev +sudo apt install build-essential cmake ninja-build ``` +These are the SDL3-default prerequisites. For the SDL2 fallback, additionally +install `mingw-w64-x86_64-SDL2`, `sdl2`, or `libsdl2-dev` respectively, plus +`pkg-config` outside MSVC. + ## Build the framework Two CMake trees: the recompiler (a tool) and the runtime (the engine). @@ -74,12 +96,23 @@ Two CMake trees: the recompiler (a tool) and the runtime (the engine). cmake -S recompiler -B recompiler/build -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build recompiler/build -# 2. (Optional) regenerate the BIOS C from your SCPH1001.BIN -# Requires bios/SCPH1001.BIN to be present. -bash tools/regen_bios.sh +# 2. REQUIRED before the first runtime build: generate a BIOS backend. +# Step 3 configures psx-runtime from generated/OpenBIOS_full.c. If generated/ +# is empty, CMake does NOT fall back -- it fails at configure time with +# "Cannot find source file: .../generated/OpenBIOS_full.c" followed by +# "No SOURCES given to target: psx-runtime". Re-run this step whenever the +# recompiler emitter changes; a stale generated/ raises a fingerprint-mismatch +# warning from runtime.cmake. +# +# OpenBIOS can always be regenerated from the tracked image. Regenerating +# the retail backend requires your own bios/SCPH1001.BIN dump and is genuinely +# optional -- OpenBIOS alone is enough to build and boot. +bash tools/regen_bios.sh --config bios/OpenBIOS.toml +bash tools/regen_bios.sh --config bios/SCPH1001.toml # optional, needs your own dump # 3. Runtime → produces psx-runtime (BIOS-only for this repo) -cmake -S runtime -B runtime/build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake -S runtime -B runtime/build -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DPSX_RECOMP_UI=OFF cmake --build runtime/build --target psx-runtime ``` @@ -89,11 +122,20 @@ On Linux/macOS, `tools/setup_dev.sh` performs the same source-checkout setup: sh tools/setup_dev.sh ``` -It checks for the native toolchain and SDL2 development package, builds the CLI -and recompiler tools, refreshes generated BIOS C when `bios/SCPH1001.BIN` is -present, and builds the BIOS-only runtime when BIOS/generated sources are -available. It does not create per-game runtime targets; use the CLI generator -for game projects. +It checks the native toolchain, builds the CLI and recompiler tools, refreshes +generated BIOS C when `bios/SCPH1001.BIN` is present, and builds the BIOS-only +runtime when BIOS/generated sources are available. It does not create per-game +runtime targets; use the CLI generator for game projects. + +### Check the build + +After step 1 above — no BIOS or disc needed — verify the tree is sane: + +```sh +cd recompiler/build && ctest --output-on-failure +``` + +29 tests, under five seconds. See [`TESTING.md`](TESTING.md). On Windows with MSVC or plain MinGW makefiles, swap `-G Ninja` for your generator (e.g. `-G "Unix Makefiles"`); everything else is identical. @@ -109,10 +151,15 @@ On Windows with MSVC or plain MinGW makefiles, swap `-G Ninja` for your generato | Option | Default | Effect | |---|---|---| | `PSX_DEBUG_TOOLS` | ON for Debug/RelWithDebInfo, OFF for Release | TCP debug server + heartbeat + per-block recording | -| `PSX_STATIC_RUNTIME` | ON for MinGW Release | Self-contained exe (statically links SDL2 + libgcc/libstdc++) | -| `PSX_LAUNCHER` | ON | Allow downstream game projects to wire recomp-ui launcher support | -| `PSX_ENABLE_VULKAN` | OFF | Build the experimental Vulkan renderer | -| `PSX_BUILD_COSIM` | OFF | Build the first-divergence co-sim oracle target | +| `PSX_SDL_BACKEND` | `SDL3` | Host backend. Set `SDL2` explicitly for compatibility or A/B testing. | +| `PSX_SDL3_FETCH` | ON | Fetch the pinned SDL3 release when a compatible system SDL3 package is unavailable. Set OFF for offline/system-only builds. | +| `PSX_STATIC_RUNTIME` | ON for MinGW Release | Self-contained exe (statically links SDL + libgcc/libstdc++) | +| `PSX_RECOMP_UI` | ON | Wire a downstream game's pinned recomp-ui launcher; set OFF for headless/generated builds | +| `PSX_ENABLE_VULKAN` | **ON** | Build the experimental Vulkan renderer when the SDK tools are present (skipped if not). Pass `OFF` to exclude it outright. | +| `PSX_NETPLAY` | OFF | Netplay support; requires the `lib/recomp-net` submodule | + +See [SDL backends](SDL_BACKENDS.md) for the fallback command and the initial +SDL3/SDL2 game A/B results. ## Build and run a game @@ -124,9 +171,13 @@ for TombaRecomp: # Extract the game's PS-X EXE from your disc (helper included in the game repo): python3 tools/extract_psx_exe.py tomba/tomba.bin SCUS_942.36 tomba/SCUS_942.36 -# Regenerate the game's C from the disc/EXE (game repos invoke the recompiler -# binary directly with their config): -../psxrecomp/recompiler/build/psxrecomp-game --config game.toml +# Regenerate the game's C from the disc/EXE. The framework is a submodule at +# psxrecomp/ inside the game repo, so build its recompiler once, then run it. +# (Game repos also ship tools/regen.sh (macOS/Linux) / tools/regen.ps1 (Windows) +# that wrap this command.) +cmake -S psxrecomp/recompiler -B psxrecomp/recompiler/build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build psxrecomp/recompiler/build +psxrecomp/recompiler/build/psxrecomp-game --config game.toml # Configure + build the game runtime: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release @@ -180,10 +231,42 @@ these files without it). If you hit this on a framework source file, please open an issue with your `gcc -v` / `as --version` — the build should apply the flag for you. -**`SDL2 MSVC dev package not found`.** The MSVC build expects the prebuilt SDL2 -pack beside the repo at `../sdl2-msvc/SDL2-*`. Use the MSYS2/MinGW toolchain -(which finds SDL2 via pkg-config) or place the pack there. +**`SDL3 3.4+ was not found`.** The default build normally downloads the pinned +release. Check network access, install a system SDL3 package and provide +`SDL3_DIR`, or re-enable `-DPSX_SDL3_FETCH=ON`. + +**`SDL2 MSVC dev package not found`.** This applies only when +`-DPSX_SDL_BACKEND=SDL2` is selected. Place the prebuilt SDL2 pack beside the +repo at `../sdl2-msvc/SDL2-*`, or use the MSYS2/MinGW toolchain with SDL2 +available through pkg-config. **Overlays never compile / stay slow.** In development you need `gcc` on `PATH` for the `gcc` tier; otherwise areas stay in the interpreter. See [`EXECUTION_MODEL.md`](EXECUTION_MODEL.md). + +## Regenerating BIOS backends + +Every normal runtime links both statically recompiled BIOS backends. OpenBIOS +symbols and retail SCPH-1001 symbols are namespaced so they coexist in one +executable; the active backend is selected at launch: + +```bash +# OpenBIOS (tracked and redistributable) +tools/regen_bios.sh --config bios/OpenBIOS.toml + +# Retail SCPH-1001 (requires your own local dump) +tools/regen_bios.sh --config bios/SCPH1001.toml +``` + +`PSXRECOMP_BIOS_STEMS` defaults to `OpenBIOS;SCPH1001`. The runtime build +automatically stages `bios/openbios.bin` and `bios/OpenBIOS.LICENSE` beside +every native executable. A release packager must copy that `bios/` directory +unchanged. + +At runtime, no explicit player choice means OpenBIOS. A retail BIOS selected +with the launcher, `settings.toml`, or `--bios` wins after its exact identity is +verified. `[runtime] openbios = false` is reserved for a title with a verified +OpenBIOS incompatibility. See [`BIOS_SELECTION.md`](BIOS_SELECTION.md). + +OpenBIOS seeds come from its ELF symbol tables (no Ghidra pass needed): +see the pin + regeneration recipe in `bios/OpenBIOS.toml`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 94bc5afd..a64de402 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -46,7 +46,22 @@ includes `generated/overlays.c` when it exists. --- -## Layer A — Dynamic DLL loading (runtime, SHELVED) +## Layer A — Dynamic DLL loading (runtime) — SHIPPED + +> **Status corrected 2026-07-27.** This section described Layer A as "SHELVED". +> It is not: dynamic DLL overlay loading is the **live production dispatch +> path**. `runtime/src/overlay_loader.c` is ~4,400 lines with 15 `LoadLibrary` +> call sites and was last modified 2026-07-26; +> [`COMPILING_OVERLAYS.md`](COMPILING_OVERLAYS.md) documents building the +> `cache/…/_.dll` shards that ship inside release packages. The +> shelving described below was a real moment in the project's history, and the +> account of the CRC-keying dead ends is worth keeping — but it was superseded. +> Content-hash dispatch replaced the unstable CRC key, and the Load Game crash +> was subsequently root-caused rather than worked around. +> +> Read everything below this banner as **history**, not current state. For the +> current pipeline see [`COMPILING_OVERLAYS.md`](COMPILING_OVERLAYS.md) and +> [`AOT_OVERLAY_PLAN.md`](AOT_OVERLAY_PLAN.md). ### What it was supposed to do @@ -100,6 +115,10 @@ but the Load Game crash was not diagnosed before shelving. The infrastructure is complete. The blocker is code correctness, not plumbing. +*(Historical. All three items were subsequently resolved — the CRC key was +replaced by content-hash dispatch and the cache layout now carries an emitter +tag, `//-/cg_/_.dll`.)* + --- ## Tomba overlay map (known) diff --git a/docs/LOAD_TIME_ZERO.md b/docs/LOAD_TIME_ZERO.md index 8ab5db91..f52c477d 100644 --- a/docs/LOAD_TIME_ZERO.md +++ b/docs/LOAD_TIME_ZERO.md @@ -375,9 +375,12 @@ rejected on paper. `traps.c:540`; store `data_shards.c` (494 LOC, no TODOs); config `[data_shards] funcs` `config_loader.cpp:716-723`. `.dss` per shard, no manifest (doc/impl drift). -- bios_hle: B0 events 0x07-0x0D only; boot = one-shot shell-skip at - `PSX_SHELL_ENTRY_PHYS` + boot-turbo until game start. A0/C0 observed, - fall through. +- bios_hle: B0 events 0x07-0x0D only; boot = one-shot shell-skip at the + image's `shell_entry_phys` + boot-turbo until game start. A0/C0 observed, + fall through. The boot-skip and the B0 tier are independent axes + (`bios_hle_plan.h`): the skip needs only `shell_entry_phys` and so lands + the same load-time win under OpenBIOS as under retail, even though + OpenBIOS refuses the B0 tier for want of `deliver_event_ret`. - `_wt-tomba-loadshards-fw` worktree dir is EMPTY/unregistered — the spike lives only as branch `spike/tomba-load-shards` (fw) + `spike/load-shards` (TombaRecomp). Recreate worktrees before E4. diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md new file mode 100644 index 00000000..44a88d5d --- /dev/null +++ b/docs/MOD_PACKAGES.md @@ -0,0 +1,392 @@ +# PSXRecomp mod packages and features + +A `.psxmod` is a versioned installation, provenance, and trust boundary. A +package may contribute any number of independently configurable **features**. +The launcher presents those features as the primary Mods list; package +installation, version selection, and removal are a secondary management view. + +Feature identity is always `(package_id, feature_id)`. Enabling one feature +never enables, disables, or reconfigures another feature. + +The player selects a verified stock BIN/CUE. Resolution produces guarded native +operations and sparse disc overlays without rewriting or replacing that stock +image. + +## Feature manifest + +```toml +format_version = 1 +id = "example.localization" +version = "1.2.0" +name = "Example Localization Pack" +author = "Example Author" +description = "Independent title and script features." +license = "MIT" +resolver = "declarative" + +[[target]] +game_id = "SLUS-00000" +# Required for disc overlays. Use the digest of the supported stock image. +disc_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +[[feature]] +id = "title-screen" +name = "Title Screen" +description = "Selects the title-screen artwork." +group = "Localization" +default_enabled = false + +[[feature]] +id = "retranslation" +name = "Retranslation" +description = "Uses the revised English script." +group = "Localization" + +[[option]] +feature = "title-screen" +id = "variant" +label = "Title artwork" +type = "choice" +default = "rockman" + +[[option.choice]] +value = "mega-man" +label = "Mega Man X6 (USA)" + +[[option.choice]] +value = "rockman" +label = "Rockman X6 (Japan)" + +[[patch]] +feature = "title-screen" +target = "main_exe" +address = 0x80041234 +expected = "2a 00 02 24" +replace = "0e 00 02 24" +when = { variant = "rockman" } + +[[overlay]] +feature = "retranslation" +target = "disc_raw" +offset = 123456 +file = "assets/retranslated-script.bin" +sha256 = "..." +# Optional additional guard over the same range in the stock image. +expected_sha256 = "..." +``` + +Every `[[option]]`, `[[patch]]`, and `[[overlay]]` in a feature-style manifest +must name its owning feature. Ambiguous operations are rejected. + +Option types are `boolean`, `choice`, and bounded `integer`. Conditions are +feature-local: `when = { option = "value", ... }` requires every listed option +to match. The legacy `when_option`/`when_value` pair remains accepted for a +single condition. + +## Bounded integer patches + +Package format 2 can encode a bounded integer option directly into a guarded +write: + +```toml +format_version = 2 + +[[feature]] +id = "starting-lives" +name = "Starting Lives" + +[[option]] +feature = "starting-lives" +id = "count" +label = "Lives" +type = "integer" +min = 0 +max = 99 +step = 1 +default = 2 + +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x8001DE64 +expected = "02 00 02 24" +replace_from = { option = "count", encoding = "u16le", offset = 0 } + +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x8001DE70 +expected = "03 00 02 24" +replace_from = { option = "count", encoding = "u16le", offset = 0, addend = 1 } +``` + +`replace_from` and literal `replace` are mutually exclusive. The referenced +option must be a bounded integer owned by the same feature. The initial +encodings are `u8`, `u16le`, and `u32le`. `offset` selects a byte field inside +the expected guard and defaults to zero. Generated replacement bytes begin as +an exact copy of the expected bytes, then the encoded value replaces only that +field. This lets a MIPS immediate, for example, retain a guard and collision +claim over its complete instruction. `addend` is the only supported transform, +and the complete declared option range after that addend must fit the unsigned +encoding. + +There is deliberately no host-endian encoding, signed inference, mask, shift, +scale, expression language, or partial-field merge. A package uses multiple +guarded `[[patch]]` entries when the same value has multiple destinations. +Generated bytes enter the ordinary pre-boot write plan, collision checks, and +fingerprint. A generated value identical to the stock guard is omitted as a +no-op, so an enabled stock-valued option does not claim or conflict on bytes it +does not change. + +Integer values use canonical decimal text. Leading plus signs, redundant +leading zeroes, values outside the bounds, and values not aligned to `step` are +rejected. + +## Ordered values and split MIPS immediates + +Package format 3 adds feature-local ordering constraints for related integer +fields: + +```toml +format_version = 3 + +[[constraint]] +feature = "rank-thresholds" +kind = "ordered_integer" +direction = "nondecreasing" +options = ["rank-c", "rank-b", "rank-a"] +``` + +All listed options must be integer options on that feature. Defaults must +satisfy the constraint. While a feature is enabled, an edit that would invert +the order is rejected with the neighboring option labels. Disabled features +may retain an incomplete or invalid draft, but cannot be enabled until it is +valid. `nonincreasing` is also supported. + +Format 3 also provides a narrow, typed transform for constants constructed by +a linked MIPS `LUI`/`ORI` pair: + +```toml +replace_from = { + option = "speed", + encoding = "mips_lui_ori_u32", + omit_when_default = true +} +``` + +The patch must target one aligned, fully guarded eight-byte `main_exe` +instruction pair. The loader verifies the opcodes and register linkage, then +places the raw high and low 16-bit halves into the two immediates. It does not +apply signed-`ADDIU` carry adjustment. `offset` and `addend` are not accepted +for this encoding. + +`omit_when_default` suppresses the entire patch when the selected value equals +the option default. This models source tools whose declared default means +"make no writes," including cases where multiple guarded sites contain +different stock values. For any nondefault selection, every declared site +retains its collision claim even if one generated replacement happens to equal +its stock guard. + +## Sparse fields and integer predicates + +Package format 4 separates a patch's complete expected-byte guard from the +fields it owns and writes. This is for semantic records whose independently +configurable fields share one useful guard: + +```toml +format_version = 4 + +[[option]] +feature = "saber-timing" +id = "frames" +label = "Frames" +type = "integer" +min = 0 +max = 99 +default = 2 + +# Positive values change only byte 0. +[[patch]] +feature = "saber-timing" +target = "main_exe" +address = 0x80077640 +expected = "02 42 01 02" +fields = [ + { offset = 0, option = "frames", encoding = "u8" }, +] +when_integer = { option = "frames", op = "gt", value = 0 } + +# Zero has a narrow, explicitly declared compound representation. +[[patch]] +feature = "saber-timing" +target = "main_exe" +address = 0x80077640 +expected = "02 42 01 02" +fields = [ + { offset = 0, replace = "01" }, + { offset = 2, replace = "00" }, +] +when_integer = { option = "frames", op = "eq", value = 0 } +``` + +`fields` is mutually exclusive with `replace` and `replace_from`. Every field +has a nonnegative `offset` and exactly one payload form: + +- `replace = "..."` supplies non-empty literal bytes; or +- `option` plus `encoding` uses a bounded integer option on the same feature, + with the optional checked `addend`. + +Fields must fit inside `expected` and their owned byte ranges may not overlap. +The supported dynamic encodings are the existing `u8`, `u16le`, `u32le`, and +linked `mips_lui_ori_u32` forms. A sparse MIPS field owns only the two +immediate halfwords while the complete linked instruction pair remains +guarded; as with format 3, that encoding does not accept an addend. + +Resolution omits individual fields whose generated payload already equals the +guard. If no field changes, the patch is a no-op. Otherwise the plan retains +the complete guard and the exact remaining owned ranges. Runtime guard +validation checks every byte of `expected` before applying any writes, while +collision detection and writing use only the owned fields. Thus two features +can safely own adjacent bytes in one record without either overwriting the +other. Overlapping guards must still agree on their expected bytes; conflicting +guards make the plan unsatisfiable and are rejected. + +`when_integer` is a patch-level, feature-local predicate over one bounded +integer option. `op` is exactly one of `eq`, `ne`, `lt`, `le`, `gt`, or `ge`, +and `value` is an integer constant inside the option bounds. Equality +constants must also be selectable under the option's `step`. String-valued +`when` conditions and one `when_integer` predicate may coexist and are ANDed. + +Sparse fields and integer predicates are still pre-boot plan construction. +They do not provide a general expression evaluator, masks, arithmetic beyond +the checked field addend, package code execution, or per-frame dispatch. + +## Trusted static plugins + +Package format 5 can activate a game-owned plugin that is already statically +linked into the executable: + +```toml +format_version = 5 + +[[feature]] +id = "warp-debug-menu" +name = "Warp Debug Menu" + +[[plugin]] +feature = "warp-debug-menu" +id = "example.warp-debug" +``` + +The plugin id is a stable registry key, not a library path or symbol name. The +package archive supplies no native code. Resolution fails before launch when +an enabled plugin has no registered implementation or when two features claim +the same plugin id. Active plugin identities and owners participate in the +canonical plan fingerprint. + +An implementation may register an activation callback, a deterministic +guest-VBlank callback, or both under the same id. Activation runs after the +launcher's final mod-plan commit and before renderer/window initialization; it +is appropriate for a game-owned mod that selects a fixed display aspect or +another pre-boot host feature. VBlank callbacks run from the emulated GPU +VBlank event, independent of host presentation, pacing, turbo, or skipped +frames. Trusted callbacks receive only the narrow C services exposed by +`runtime/include/mod_plugins.h`. Games should continue to use declarative +patches and overlays when those operations are sufficient. + +## Native operations + +`main_exe` writes use PSX guest virtual addresses. Expected bytes are checked +after the BIOS loads the executable, then the complete write plan is applied +before its entry point. Changed executable ranges use the existing dirty-RAM +interpreter/native-overlay machinery; untouched functions remain on the static +native path. + +Small `disc_raw` and `disc_user` patches are equal-length guarded writes and may +not cross a sector boundary: + +- `disc_raw` offsets use `lba * 2352 + byte_in_sector`. +- `disc_user` offsets use `lba * 2048 + byte_in_sector`. + +File-backed `[[overlay]]` operations are intended for large assets and may span +any number of sectors. Their paths must remain inside the archive. Payload +size and SHA-256 are verified while scanning, but disabled payloads are not +retained in memory. Enabled payloads are loaded and reverified during +resolution, then indexed by target and LBA before boot. A CD read performs a +direct indexed lookup rather than scanning every installed mod. + +Feature disc overlays require an exact `disc_sha256` on every target entry. +`expected_sha256` can additionally guard the replaced stock range. + +## State and migration + +`mods/state.toml` format 2 stores selected package versions separately from +per-feature enabled states and values: + +```toml +format_version = 2 + +[[package]] +id = "example.localization" +version = "1.2.0" + +[[feature]] +package_id = "example.localization" +id = "title-screen" +enabled = true + +[feature.values] +variant = "rockman" +``` + +State format 1 and package-only manifests remain readable as a migration aid. +They appear through one synthetic legacy feature. New packages should use +explicit features. + +The old `derived_disc` VCDIFF mechanism is legacy conversion scaffolding only. +Feature-style manifests reject it. It is not a product mod primitive, fallback, +or image-selection workflow; patched discs may be used offline as parity +oracles while converting known mods to native operations. + +## Resolution and diagnostics + +Before boot, the manager: + +1. verifies the selected stock game and revision; +2. expands only enabled features and their selected options; +3. orders active packages deterministically by dependencies; +4. verifies enabled payloads and operation bounds; +5. collision-checks the complete owned byte-range plan and guard + compatibility; +6. coalesces only truly identical target/range/expected/replacement writes or + identical overlays; and +7. produces a canonical SHA-256 plan fingerprint. + +Incompatible overlaps fail before launch. Structured diagnostics identify both +`(package, feature)` owners and the exact contested target range. The launcher +marks both feature rows and lets the user decide what to disable. It never +silently chooses a winner. + +Operation boundaries are not semantic boundaries. Legacy full-record writes +compose when both their expected and replacement bytes agree throughout the +owned intersection. Format-4 sparse patches collide only on declared owned +fields, while their complete guards must remain mutually compatible. +Partially overlapping overlays compose when their replacement payload bytes +agree. A differing owned byte or incompatible guard produces a diagnostic at +that exact location. Exact duplicate operations may be coalesced. + +Package-level dependencies and conflicts are reserved for actual implementation +relationships. Mutually exclusive choices such as US versus Japanese artwork +belong inside one feature as option values. + +## Trusted adapters and archive safety + +`resolver = "builtin:"` selects a resolver statically registered by the +game. Format-5 plugin ids likewise select only statically registered +implementations. Packages cannot load arbitrary native code or select +arbitrary symbols. + +The installer accepts stored or DEFLATE-compressed ZIP entries, validates CRCs, +rejects encrypted entries and unsafe or absolute paths, limits archives to 4096 +files and 256 MiB expanded size, stages extraction, validates the manifest, and +publishes the version atomically. diff --git a/docs/OVERLAY_XREF.md b/docs/OVERLAY_XREF.md new file mode 100644 index 00000000..9892888b --- /dev/null +++ b/docs/OVERLAY_XREF.md @@ -0,0 +1,201 @@ +# Overlay cross-reference tooling — `tools/overlay_xref.py` + +Static analysis of an overlay-heavy PS1 title has a hole in it: **the code you +care about does not exist in the boot EXE.** Titles like Tomba 2 ship a ~165 KB +loader and stream the entire game in as code overlays, so a Ghidra import of +`SCUS_944.54` covers `0x80010000`–`0x80038800` and nothing else. Every +`[widescreen.cull]` site this project has ever needed lives outside that range. + +`tools/overlay_xref.py` closes the hole by reading the runtime's overlay capture +set. It is **game-agnostic** — point it at any title's capture directory. + +--- + +## Why the capture set is a legitimate source of truth + +The runtime writes one JSON per distinct overlay body it has ever mapped to +`/overlay_captures.json.d/.json` +(schema `psxrecomp overlay capture v2`): + +| field | meaning | +|---|---| +| `load_addr` | virtual base the body was mapped at | +| `size` | body length in bytes | +| `bytes_b64` | the actual bytes that were in guest RAM | +| `executed_pcs` | PCs observed executing inside this body | + +The set is **content-hashed and additive across every run**, so it is an +always-on ring buffer that needs no arming — it already contains everything the +game has ever loaded, including bodies from sessions that ended months ago. This +satisfies the project's ring-buffer rule: you *query* it for the window of +interest rather than arming a trace and hoping to catch the event. + +For scale, Tomba 2's probe capture dir holds **30 769 captures across 116 +distinct load bases**, with ~34 M executed-PC records. + +--- + +## The problem it exists to solve + +`bias_sites`, `range_sites`, `screen_x_sites` and `depth_sites` are matched by +**address plus opcode class only**. Overlays genuinely map *different code* to +the same virtual address, so an address-only record can silently rewrite an +unrelated instruction. + +This is not theoretical. Measured on Tomba 2's shipped config: + +| address | finding | +|---|---| +| `0x80110A08` (committed `bias_site`) | **3 distinct words**: `lw $s1,20($sp)` / `addiu $a1,$v1,0x80` / `addu $v1,$zr,$zr`. The opcode guard saves this one. | +| `0x8013F138` (committed `bias_site`) | **5 variants, all `addiu $v0,$zr,imm`**, immediates `0x155 / 0x124 / 0x208 / 0x320 / 0x224`. Same opcode, so **one address-only record widens all five.** | +| `0x8010E2FC` | **two entirely different overlay bodies** at the same VA (bases `0x8010C000` and `0x8010A000`). | + +**Run `word ` on every candidate site before committing it.** If more than +one word appears, use a full expected-word guarded record rather than a bare +address. + +--- + +## Commands + +``` +overlay_xref.py -d [args] +``` + +| command | purpose | +|---|---| +| `index` | per-base summary: variant count, sizes, how many captures executed | +| `word VA...` | every distinct word at each VA, with capture ids and exec counts | +| `dis VA -n N` | disassemble N instructions, **one listing per distinct variant** | +| `func VA` | walk back to the prologue, forward past `jr $ra`, then list | +| `cover VA...` | which load bases/sizes cover each VA | +| `scan --op OP [--imm-min/--imm-max]` | every site matching an opcode, optionally by immediate range | +| `imm VALUE` | every site whose 16-bit immediate equals VALUE (ALU **and** load/store displacements) | +| `callers VA` | every `jal`/`j` site targeting VA — a call graph without Ghidra analysis | +| `window [--signed] [--gap N]` | find the centered-window gate idiom (below) | +| `ramimage OUT` | composite the captures into a flat guest-RAM image for Ghidra | + +A parse of the full Tomba 2 set takes ~85 s cold; results are cached to +`/.overlay_xref.cache` (~10 s warm). `--no-cache` forces a +re-parse. + +--- + +## The centered-window gate idiom + +Almost every PS1 visibility or proximity gate is written as one unsigned compare +against a recentred delta: + +``` +subu rT, rRef, rObj # signed coordinate delta +addiu rT, rT, BIAS # recentre <-- bias site +andi rT, rT, 0xFFFF # truncate back to 16-bit (optional) +sltiu rD, rT, SPAN # |delta| < SPAN/2 <-- range site +``` + +`window` finds these pairs. Instructions between the bias and the compare are +allowed **only when they carry the value** (a same-register `andi`/`addiu`/ +`sll`/`sra`/`or`); a genuine redefinition breaks the pair. + +> **The `andi` truncation is part of the idiom, not a clobber.** A naive +> "destination was rewritten, so this isn't a pair" heuristic breaks on it and +> silently reports zero real gates while still finding plenty of false ones. +> That failure is quiet and costs a whole analysis pass. + +### Reading the constants + +`SPAN == 2*BIAS + 1` is a symmetric window (`|delta| < BIAS`). Anything else is +one-sided or asymmetric and deserves a second look: + +| bias / span | shape | +|---|---| +| `0xE6 / 0x1CD` (230 / 461) | symmetric, `|delta| < 230` | +| `0x258 / 0x259` (600 / 601) | one-sided, `delta ∈ [-600, 0]` | +| `0x320 / 0x44D` (800 / 1101) | asymmetric, `delta ∈ [-800, 300]` | + +### A trap worth naming + +**Most window pairs in a 3D action title are 12-bit ANGLE windows inside +collision tests, not distance gates.** On Tomba 2, 43 pairs were found and the +strongest-looking candidates turned out to be attack/hit cones: + +``` +sqrt(dx² + dz²) (jal 0x80084080) +compare against summed radii at actor+0x80 +atan2 (jal 0x80085690) +angle window (andi 0xFFF, bias/span) +sb 1, 43(actor) # set the hit flag +``` + +Confirmed for `0x8001FBBC` (bias `0x580` / span `0x301`) and `0x8010E2FC` +(682 / 1365). Widening one of these changes **combat reach**, not what is drawn. +Always read the surrounding function before classifying a pair. + +--- + +## Feeding Ghidra: `ramimage` + +Overlay code only exists in RAM at runtime, so the decompiler cannot reach it +from a static EXE import. `ramimage` flattens the capture set into a raw guest +RAM image that Ghidra can analyse: + +```bash +python tools/overlay_xref.py -d \ + ramimage /ghidra/_ram.bin \ + --manifest <game>/ghidra/<title>_ram_conflicts.txt +``` + +Import it with `language = MIPS:LE:32:default`, `base_address = 0x80000000`, +compiler `default`, then let auto-analysis run. + +Where several overlays mapped different bodies to one address, the winner is the +variant seen **executing** in the most captures (ties broken by capture count). +Every disagreement is written to the conflict manifest. + +> ### The image is a composite fiction in the conflicting regions +> +> Tomba 2's image covers **74.4 % of the 2 MB address space** and has +> **44 576 conflicting addresses (~11 % of covered words)**. Inside those +> regions the listing is a blend of overlays that never coexisted. +> +> Treat Ghidra output from this image as a **lead, not a fact**. Confirm every +> instruction you intend to patch with `word <VA>` / `dis <VA>` against the real +> per-overlay variants. See the worked example below for why this is not +> optional. + +### Worked example — the decompiler will invent addressing + +Ghidra decompiled an actor state machine as reading the player through a global: + +```c +uVar2 = FUN_800782b0(param_1 + 0x2c, (int)*(short *)(DAT_800e7f5c + 0x2c), ...); +``` + +The actual instructions are: + +``` +0x8006A560: 8E0200DC lw $v0,220($s0) # player via <context>+0xDC +0x8006A568: 8445002C lh $a1,44($v0) +``` + +There is **exactly one** real reference to `0x800E7F5C` in the entire capture set +(`0x80069B70`) — confirmed independently by `imm 0x7F5C` and by Ghidra's own +xrefs. The `DAT_800e7f5c` in that function body is decompiler inference from +constant propagation, not a load. A site list built from the decompiler's view of +addressing here would have been wrong. + +Recommended split of labour: + +| task | use | +|---|---| +| call graph / xrefs | `overlay_xref.py callers`, `imm`, `scan` — exact, needs no analysis pass | +| readable control flow | Ghidra `create_function` + `get_code decompiler` | +| **anything you will patch** | `word` / `dis` on the real variants — always | + +--- + +## Related + +- `docs/config_schema.md` — `[widescreen.cull]` schema +- `WIDESCREEN.md` — native-wide / adaptive view design +- `docs/COMPILING_OVERLAYS.md` — how captures become compiled overlay DLLs diff --git a/docs/RECURSION_BUG.md b/docs/RECURSION_BUG.md index a084d4fa..4bc4e387 100644 --- a/docs/RECURSION_BUG.md +++ b/docs/RECURSION_BUG.md @@ -203,7 +203,7 @@ cmake --build ../psxrecomp/recompiler/build --target psxrecomp-game psxrecomp-bi (cd ../psxrecomp && ./recompiler/build/psxrecomp-bios.exe --config bios/SCPH1001.toml) ../psxrecomp/recompiler/build/psxrecomp-game.exe --config tomba_soak_e.toml # build: -cmake -S . -B <build-dir> -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_DEBUG_TOOLS=ON -DPSX_LAUNCHER=OFF \ +cmake -S . -B <build-dir> -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_DEBUG_TOOLS=ON -DPSX_RECOMP_UI=OFF \ -DCMAKE_C_COMPILER=C:/msys64/mingw64/bin/gcc.exe -DCMAKE_CXX_COMPILER=C:/msys64/mingw64/bin/g++.exe cmake --build <build-dir> --target psx-runtime -j8 # run (per-instance card; taskkill first): diff --git a/docs/RELOCATION_MANIFEST_FORMAT.md b/docs/RELOCATION_MANIFEST_FORMAT.md index 12c001f9..04dca27f 100644 --- a/docs/RELOCATION_MANIFEST_FORMAT.md +++ b/docs/RELOCATION_MANIFEST_FORMAT.md @@ -1,5 +1,18 @@ # Relocation Manifest Format +> **STATUS AMENDMENT (2026-07, BIOS profiles).** The bulk boot-time +> ROM-to-RAM copy case this document originally targeted is now carried by +> the BIOS profile instead: `[[recompiler.address_model.copy]]` in +> `bios/<STEM>.toml` (single source of truth for the recompiler, the +> emitted C, and the runtime via `psx_bios_image`; see +> `recompiler/src/bios_address_model.h`). The kernel-bless mechanism in +> `memory.c` remains the runtime enforcement of the "verify live RAM +> against the ROM source before dispatching AOT code" contract described +> here. This document stays authoritative ONLY for the oracle-observed +> case — regions with `src_rom_addr: null` that cannot be seen statically +> — which remains unimplemented. + + **Status:** Phase B (design + first iteration), 2026-04-30. **File on disk:** `generated/address_aliases.json`. **Authority:** `PLAN.md:941-967` ("BIOS Relocation Is a Hard Gate"), `CLAUDE.md:333-365` (Rule 18). diff --git a/docs/SAVESTATE_LOAD_PERFORMANCE.md b/docs/SAVESTATE_LOAD_PERFORMANCE.md new file mode 100644 index 00000000..50c58def --- /dev/null +++ b/docs/SAVESTATE_LOAD_PERFORMANCE.md @@ -0,0 +1,205 @@ +# Savestate load performance — solo and netplay + +Status: **shipped** (MotK / Bomberman runtime + vendored `recomp-net`). +Scope: host-side restore hitch after F-key / protocol load, and lockstep +correctness when both peers restore together. + +This is not the CD “load window” turbo work in [`LOAD_TIME_ZERO.md`](LOAD_TIME_ZERO.md). +That doc is about *in-game* disc loads. This one is about **user savestate +restore** (`.pst` / `boot_state_*`) feeling instant and staying in sync under +delay-netplay. + +--- + +## Symptoms that drove the work + +| Mode | Symptom | Host FPS | Root class | +|------|---------|----------|------------| +| Solo | Picture frozen ~1–several seconds after load; worse on 2nd/3rd load of same slot | ~60 | Giant `psx_advance_cycles` / IRQ blackout / present latch | +| Netplay | Host `applied, waiting for guest…`; guest stuck at `applying after transfer…` then disconnect | N/A | INPUT / admit deadlock across apply ↔ ready | +| Netplay spam | Hang when mashing load while holding directions | N/A | Same tip-starvation class; tip runway emptied by `hard_resync` | + +--- + +## Solo restore path + +Call chain (HLE scheduler required): + +``` +F-key / debug → savestate_request_load + → savestate_poll (block leader) + → boot_state_load + → psx_cycles_resync_after_restore + → interrupts_resync_after_restore + → cdrom_accelerate_after_savestate + → psx_frontend_on_savestate_loaded + → psx_scheduler_resume_at(pc) /* longjmp; does not return */ +``` + +### 1. Anchor host-only cycle deadlines (`psx_cycles_resync_after_restore`) + +**Bug:** `gte_ts_done` / `muldiv_ts_done` and load-absorb fields live on +`CPUState` but are **not** in the savestate wire format. After a warm load, +`psx_cycle_count` rewinds to the snapshot while those deadlines still sit on +the pre-load live timeline. The next GTE / muldiv stall then advances +`(live_ts − restored_cycle)` in one shot — tens of millions of cycles, many +nested presents, `chk(e=0)`, sticky VBlank, no draw. + +**Fix:** After restore, set `gte_ts_done` / `muldiv_ts_done` to the restored +`psx_cycle_count`, clear absorb/fudge state, and re-anchor device sync / +idle-skip latches. + +- `runtime/src/psx_cycles.c` — `psx_cycles_resync_after_restore` +- Called from `savestate_poll` immediately after a successful `boot_state_load` + +### 2. Clear absolute IRQ cooldowns (`interrupts_resync_after_restore`) + +**Bug:** `post_exception_cooldown_until` is an absolute guest-cycle stamp. +Leaving it in the future after a clock rewind blocks every IRQ (including +VBlank) until the restored clock “catches up” — freeze for however long the +user played past the save, while host FPS stays ~60. + +**Fix:** Zero the cooldown and related exception / VBlank phase bookkeeping. + +### 3. Cap CD second-response debt (`cdrom_accelerate_after_savestate`) + +Restored / imminent CD command delays (ReadTOC, Init, seeks) can freeze the +picture for ~1s after the restored frame presents. A short post-load boost +window clamps outstanding delays so the display recovers immediately. + +### 4. Frontend present / audio re-anchor (`psx_frontend_on_savestate_loaded`) + +After restore: + +- Force present (`s_force_present_after_load`) so a disabled-display blank + latch or smooth-60 duplicate does not hide the restored frame. +- Invalidate GL present-dirty early-out (`gl_renderer_invalidate_present`) — + critical on **2nd+ load of the same slot**, where the framebuffer can match + the last swap and skip `SwapWindow`. +- Reset frame pacer + FPS baseline (admit / hitch can leave deadlines in the past). +- Resync guest-cycle→audio sample budgeting. + +### 5. Smaller / faster `.pst` I/O (boot_state v4 zlib) + +Large sections may be zlib-compressed on save (`BS_SEC` pad bit0). Shrinks +disk and helps slow storage; older readers still accept uncompressed v3. + +### 6. No post-load request cooldown + +A former 12-frame (earlier 60-frame) “load ignored” debounce in +`request_load_inner` was removed. It was only key-repeat padding and could +break netplay: protocol path entered `LOAD_APPLYING` while the cooldown +silently refused to stage `s_load_pending`. + +Overlapping loads are gated elsewhere: + +- Solo: single `s_load_pending` slot (last request wins). +- Netplay: `np_xfer_busy()` until the barrier clears. + +### Diagnostics + +`PSX_POST_LOAD_PROBE=1` arms a short post-load window that logs advance size, +IRQ check outcomes, dirty/idle/horizon, and host ms (`main.cpp` + cycle +attribution). Use this when a freeze returns; do **not** pause the runtime to +measure — extend the probe / rings instead. + +--- + +## Netplay load path + +Host-only initiate (`psx_netplay_request_load`). Guest follows via STATE_* on +the same UDP/relay path as inputs. + +### High-level sequence + +``` +host: hash PROBE(op=LOAD, size, crc) +guest: REPLY match? + yes → both stage savestate_request_load_protocol + LOAD_APPLYING + no → host STATE_BEGIN/CHUNK → guest writes sandbox + → both stage load in np_apply_ready_state (transfer complete) +both: admit while savestate_pending (guest cycles → savestate_poll) +both: local LOADED → LOAD_READY +host: ready PROBE(op=LOAD, size=0, NP_LOAD_READY_CRC) +guest: ACK when applied +both: hard_resync + prime_delay_inputs (once) +both: stay in LOAD_READY until try_admit succeeds → resume lockstep +``` + +### Correctness / performance rules (do not regress) + +1. **Stage apply only when both peers have the bytes** + - Do **not** call `savestate_request_load_protocol` on the host at SEND + begin. Host would restore during transfer, enter ready early, and starve + the guest of tips needed for `savestate_poll`. + - Hash-miss: both stage in `np_apply_ready_state` after transfer. + - Hash-hit: both stage when the probe reply is handled. + +2. **Keep INPUT flowing until mutual ready** + - `LOAD_APPLYING` / enter `LOAD_READY`: do **not** set + `input_send_suppress`. + - Suppress only inside `np_commit_load_sync` for the + `hard_resync` → `prime_delay_inputs` window (prime clears suppress). + - App barrier (`psx_netplay_poll_admit`) freezes sim after apply / + during ready; that is separate from INPUT emission. + +3. **Ready probe must not stall INPUT (`recomp-net`)** + - `rnet_session_state_probe` with `LOAD` + `total_size == 0` sets + `state_stall_sim = 0` (same as SAVE coord). + - Previously `state_stall_sim = 1` blocked `send_input_bundle` on the host + as soon as the first peer finished apply — same deadlock as suppress, + worse under spam because `hard_resync` leaves only ~D tip frames of + runway. + - Hash probes (`total_size != 0`) still stall until finish / transfer. + +4. **`hard_resync` + prime once at mutual ready, not at apply** + - Clearing rings / `sim_tick → 0` at apply time lets the later peer wipe + the earlier peer’s tip and stall resume. + - After mutual ready: clear local **and** remote rings, prime neutral + delay prefix, wait for `try_admit` (fresh tip + INPUT_CONFIRM) before + dropping `LOAD_READY`. + +5. **Peer disconnect during barrier** + - `psx_netplay_peer_disconnected(0)` while `psx_netplay_in_load_barrier()` + so rx silence for a multi-second restore does not soft-exit to lobby. + BYE / `peer_gone` still honored. + +6. **Spam loads** + - Host ignores new requests while `np_xfer_busy()`. + - No savestate-layer frame cooldown (see solo §6). + +### Where the code lives + +| Piece | Location | +|-------|----------| +| App xfer / barrier | `runtime/src/psx_netplay.c` | +| Session stall / tip / hard_resync | `lib/recomp-net/src/session/rnet_session.c` | +| Protocol notes | `lib/recomp-net/docs/protocol.md` | +| Staging API | `savestate_request_load_protocol` (bypasses netplay user block) | + +--- + +## Expected log lines (healthy netplay load) + +``` +netplay load slot=N — hash probe (…) +netplay load slot=N — hashes match, applying… # or transferring / applying after transfer +savestate: LOADED slot N … +netplay load slot=N — applied, waiting for guest… # host +netplay guest load slot=N — applied, waiting for host… # or ready acked +netplay load slot=N — mutual ready, waiting lockstep… +netplay load slot=N — peer ready, resuming lockstep +``` + +Stuck on `waiting for guest` / guest never leaving `applying…` → tip +starvation (rules 1–3). Sticky `INPUT desync … stalled` → confirm/hash +disagreement after resume (inspect tip epoch / history collisions). + +--- + +## Related docs + +- [`LOAD_TIME_ZERO.md`](LOAD_TIME_ZERO.md) — in-game CD load wall-time (different problem). +- [`CYCLE_TIMING_ARCH.md`](CYCLE_TIMING_ARCH.md) — cycle / GTE stall model. +- `lib/recomp-net/docs/protocol.md` — STATE_PROBE / post-load ready rendezvous. +- `runtime/include/boot_state.h` — `.pst` section version / zlib flags. diff --git a/docs/SDL_BACKENDS.md b/docs/SDL_BACKENDS.md new file mode 100644 index 00000000..6324a3a6 --- /dev/null +++ b/docs/SDL_BACKENDS.md @@ -0,0 +1,50 @@ +# SDL backends + +PSXRecomp uses SDL3 by default. A compatible system SDL3 package is preferred; +otherwise CMake downloads the integrity-pinned SDL 3.4.10 release. To build the +compatibility backend explicitly: + +```sh +cmake -S . -B build-sdl2 -DPSX_SDL_BACKEND=SDL2 +``` + +`PSX_SDL_BACKEND` accepts only `SDL3` or `SDL2`; it never silently changes the +requested backend. `PSX_SDL3_FETCH=OFF` makes an SDL3 build system-package-only. + +## Initial Windows A/B exercise + +These measurements are smoke-level comparisons, not claims that one SDL +generation makes emulation faster. Both builds used MinGW GCC 15.2, Release, +static SDL, OpenBIOS, the OpenGL renderer, and a 165 Hz Windows host. Each row is +three warmed runs of the runtime's frame-window diagnostic: + +| Game and workload | SDL3 wall time | SDL2 wall time | SDL3 difference | +|---|---:|---:|---:| +| Tomba 2, frames 60–660 | 9506.5 ± 10.9 ms | 9511.3 ± 19.0 ms | -0.05% | +| Mega Man X6, frames 60–660 | 8174.0 ± 114.0 ms | 8199.2 ± 287.0 ms | -0.31% | + +Every Tomba 2 run executed exactly 928,243 dirty instructions and 309,407 dirty +dispatches. Every Mega Man X6 run executed 3,962,702 dirty instructions and +1,048,509 dirty dispatches. The paced wall-time differences are smaller than +the observed variance, so the useful result is parity rather than a speed win. + +The SDL3 build was additionally exercised through: + +- the shared recomp-ui launcher event loop; +- the SDL renderer presentation path (Tomba 2, 240 frames); and +- the Vulkan renderer on an RTX 3080 Ti, including SDL3 instance-extension and + surface creation APIs (Tomba 2, 240 frames). + +The repeatable diagnostic setup is: + +```sh +PSX_RUNTIME_PERF_DIAG=1 \ +PSX_BENCH_WINDOW=60:660 \ +PSX_DEV_INPUT=0 \ +./game-runtime --no-launcher --disc /path/to/game.cue --renderer opengl +``` + +Compare only runs with identical frame windows and dirty instruction/dispatch +counts. Warm each executable and disc image before collecting results; cold +filesystem and overlay-capture state caused much larger swings than the SDL +backend in the initial exercise. diff --git a/docs/STRING_TRANSLATION.md b/docs/STRING_TRANSLATION.md index e0d665cb..3fca3e85 100644 --- a/docs/STRING_TRANSLATION.md +++ b/docs/STRING_TRANSLATION.md @@ -1,7 +1,28 @@ # On-the-Fly String Translation / Localization for psxrecomp -**Status:** SPEC (design only — no implementation yet). Reusable framework -feature; **Tsumu Light (SLPS-02253)** is the first consumer. +**Status:** IMPLEMENTED and wired into the runtime. Reusable framework feature; +**Tsumu Light (SLPS-02253)** is the first consumer. + +> This document was left marked "SPEC (design only — no implementation yet)" +> long after the feature shipped. It now reads as the design rationale behind +> working code, not a proposal. Where the prose below says "will" or "should", +> read it as describing what was built. + +The implementation lives in `runtime/src/text_xlate.cpp` (~970 lines) behind +`runtime/include/text_xlate.h`: + +| Entry point | Role | +|---|---| +| `text_xlate_init(project_root, language)` | Loads `translations/*.toml` under the project root; called from `runtime/src/main.cpp` at startup | +| `text_xlate_set_language(language)` | Re-applies the resolved language (launcher/settings selection) | +| `text_xlate_on_dispatch(cpu, target)` | Per-dispatch capture/substitution hook | +| `text_xlate_vram_upload(x, y, w, h)` | VRAM-upload interception path | +| `text_xlate_debug_json(subcmd, out, cap)` | TCP debug-server introspection | + +Configuration is `[localization].language` (falling back to `[runtime]`), with +`[localization].languages` driving the launcher's language dropdown; see +`recompiler/src/config_loader.h`. Setting the language to `jp`, `off`, or empty +disables substitution while leaving capture running. **Goal.** Let a Japanese-only PSX title be played in English (or any target language) by **capturing the game's source strings from memory and substituting @@ -292,8 +313,10 @@ and is a Phase-0 deliverable.) ## 3. Framework design (PSX) -Shared module, proposed location: `runtime/src/text_xlate.c` + -`runtime/include/text_xlate.h` (peers of `fntrace.*` and `bios_hle.*`). +Shared module, shipped at `runtime/src/text_xlate.cpp` + +`runtime/include/text_xlate.h` (peers of `fntrace.*` and `bios_hle.*`). The +design below was written as a proposal and is described in the future tense in +places; it is built, so read "will" as "does". ### 3.1 The hook point — `psx_dispatch` @@ -684,7 +707,11 @@ slots, and screenshot the level-select / HUD. - `tools/pms_build_translations.py` — decode/build/todo authoring. - `translations.json` — table format. -**psxrecomp hook points** (`F:\Projects\psxrecomp\_wt-tsumu\psxrecomp\`): +**psxrecomp hook points** (paths relative to this repository; they were captured +from the since-removed `_wt-tsumu` worktree, but the files live on `master`): +- `runtime/src/text_xlate.cpp` + `runtime/include/text_xlate.h` — the shipped + module: `text_xlate_init` / `_set_language` / `_on_dispatch` / + `_vram_upload` / `_debug_json`. Loads every `translations/*.toml`. - `runtime/include/fntrace.h` + `runtime/src/fntrace.c:58` — always-on dispatch ring (capture analogue). - `runtime/include/bios_hle.h:54` (`g_psx_bios_hle_hook`) + `runtime/src/bios_hle.c:297` diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 00000000..36d2dccc --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,100 @@ +# Testing + +## Running the tests + +```sh +cmake -S recompiler -B recompiler/build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build recompiler/build +cd recompiler/build && ctest --output-on-failure +``` + +That is the whole thing. 29 tests, under 5 seconds, and it needs **no BIOS dump, +no disc image, and no generated code** — a plain recompiler build is enough. This +is the check to run before opening a PR. + +Until 2026-07-27 no document in this repository mentioned `ctest`, `pytest`, or +how to run a test at all, so the suite was effectively invisible. If you add a +test, add it to `ctest` in the same commit — an unregistered test cannot fail, +and a test that cannot fail is not a test. + +> On a memory-constrained machine, parallel builds of this tree can crash +> `cc1plus` while compiling the toml11-heavy `config_loader.cpp`. If +> `cmake --build` dies with no diagnostic, retry with `-j 2` or `-j 1`; the +> failure is resource exhaustion, not a code error. + +### Running one test + +```sh +ctest -R overlay_guard_codegen --output-on-failure # by name (regex) +ctest -N # list without running +``` + +Every test is also a plain executable or script, so you can run it directly: + +```sh +./recompiler/build/bios_address_model_test +python recompiler/tests/test_overlay_guard_codegen.py \ + --recompiler "$(pwd)/recompiler/build/psxrecomp-game.exe" +``` + +> Pass the recompiler as an **absolute** path. A relative path satisfies the +> test's own `os.path.isfile` check but then fails inside `subprocess.run` on +> Windows with `WinError 2`, which looks like a broken test rather than a bad +> argument. + +## What the suite covers + +| Group | Where | Needs | +|---|---|---| +| C/C++ unit tests | `recompiler/tests/`, `runtime/tests/` | recompiler build | +| Codegen contract tests | `recompiler/tests/test_*.py` | `psxrecomp-game` | +| Runtime source-invariant guards | `runtime/tests/test_*.py` | nothing — they read source | + +The source-invariant guards are the cheapest and most useful class here. They +assert structural properties of the runtime (an ordering holds, a fast path is +invalidated, a fallback exists) by reading the source, so they cost milliseconds +and catch whole regression classes without running a game. Registered from +`recompiler/CMakeLists.txt` rather than `runtime/CMakeLists.txt`, because the +runtime tree cannot configure until a BIOS has been generated, and these need +neither. + +## Known-failing tests (not registered) + +Three tests exist and are **deliberately left out of `ctest`** because they fail +today. They are not registered because a suite with a known-red test is a suite +people stop believing — the exact failure mode that took CI off pull requests in +the first place (see `.github/workflows/cli-release.yml`). Fix or retire them, +then wire them in. + +| Test | Status | +|---|---| +| `runtime/tests/test_interpreter_perf_guards.py` | Asserts `psx_devices_mmio_sync` invalidates the inline cycle limit. It does not: the function delegates to `psx_devices_service_to_now()` (which clears `g_psx_cycle_fast_limit`, `psx_cycles.c:161`) **or** to `psx_devices_recompute_deadline()` (`:153-157`), and that second branch never clears it. Needs a timing owner to decide whether the guard found a real hole or the invariant moved. The guard is also partly stale — it still names `s_next_service_cycle`, since renamed to `psx_next_service_cycle`. | +| `runtime/tests/test_runtime_perf_diag_guards.py` | Asserts a substring that is no longer present in the runtime source. Either the diagnostic was removed or it was renamed; the guard has not been updated either way. | +| `runtime/tests/test_overlay_pair_dedup_runtime.py` | Needs its companion harness (`overlay_pair_dedup_harness.c`) built. Unlike the other Python tests it is not source-only, so it needs a build target before it can be registered. | + +## Tests that are not in `ctest` and should not be + +`runtime/tests/` also holds fixtures and harnesses (`*_fixture.c`, `*_harness.c`) +that are inputs to other tests, not tests themselves. Do not register them. + +Several C tests under `runtime/tests/` require a **built runtime**, which +requires a generated BIOS (see [`BUILDING.md`](BUILDING.md)). Those are wired +into `runtime/CMakeLists.txt` and run from `runtime/build`: + +```sh +cd runtime/build && ctest --output-on-failure +``` + +## CI + +`.github/workflows/cli-release.yml` runs on `workflow_dispatch` and published +releases only. Its header explains why per-PR triggers were removed on +2026-07-25: the Windows job failed often enough that a red check stopped +carrying information, and a check nobody trusts costs attention without buying +confidence. + +That reasoning still holds. The gap it left was that no fast, trustworthy +alternative existed. The `ctest` suite above is a candidate: it is hermetic +(no BIOS, no disc, no network), takes under five seconds, and is currently +green. Restoring a per-PR check on top of it is a smaller decision than +restoring the old one. diff --git a/docs/assets/games/ape-escape.png b/docs/assets/games/ape-escape.png new file mode 100644 index 00000000..dc757e70 Binary files /dev/null and b/docs/assets/games/ape-escape.png differ diff --git a/docs/assets/games/mega-man-x4.png b/docs/assets/games/mega-man-x4.png new file mode 100644 index 00000000..3e245657 Binary files /dev/null and b/docs/assets/games/mega-man-x4.png differ diff --git a/docs/assets/games/mega-man-x5.png b/docs/assets/games/mega-man-x5.png new file mode 100644 index 00000000..99e8a8cd Binary files /dev/null and b/docs/assets/games/mega-man-x5.png differ diff --git a/docs/assets/games/mega-man-x6.png b/docs/assets/games/mega-man-x6.png new file mode 100644 index 00000000..9953c34e Binary files /dev/null and b/docs/assets/games/mega-man-x6.png differ diff --git a/docs/assets/games/tomba-2.png b/docs/assets/games/tomba-2.png new file mode 100644 index 00000000..0660aef7 Binary files /dev/null and b/docs/assets/games/tomba-2.png differ diff --git a/docs/assets/games/tomba.png b/docs/assets/games/tomba.png new file mode 100644 index 00000000..170c2d0d Binary files /dev/null and b/docs/assets/games/tomba.png differ diff --git a/docs/assets/ui/mod-launcher.png b/docs/assets/ui/mod-launcher.png new file mode 100644 index 00000000..95badb57 Binary files /dev/null and b/docs/assets/ui/mod-launcher.png differ diff --git a/docs/assets/widescreen/ape-escape-21x9.png b/docs/assets/widescreen/ape-escape-21x9.png new file mode 100644 index 00000000..d9db5796 Binary files /dev/null and b/docs/assets/widescreen/ape-escape-21x9.png differ diff --git a/docs/assets/widescreen/mmx6-16x9.png b/docs/assets/widescreen/mmx6-16x9.png new file mode 100644 index 00000000..4547644b Binary files /dev/null and b/docs/assets/widescreen/mmx6-16x9.png differ diff --git a/docs/assets/widescreen/tomba-2-adaptive.png b/docs/assets/widescreen/tomba-2-adaptive.png new file mode 100644 index 00000000..9f8e4d67 Binary files /dev/null and b/docs/assets/widescreen/tomba-2-adaptive.png differ diff --git a/docs/beetle-linux.md b/docs/beetle-linux.md index 3134315e..a1feb3b0 100644 --- a/docs/beetle-linux.md +++ b/docs/beetle-linux.md @@ -38,7 +38,7 @@ 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 +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_RECOMP_UI=OFF -DPSX_DEBUG_TOOLS=ON ninja -C build psx-beetle # Headless run (picks the next free port from 4380 if taken; watch stderr) diff --git a/docs/config_schema.md b/docs/config_schema.md index 2db957e8..0d41756e 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -26,10 +26,18 @@ psx-runtime # boots BIOS discless psx-runtime games/tomba/game.toml # boots BIOS, then loads game ``` -When both are loaded, keys merge: +How the two configs relate: -- **Scalar keys (`debug_port`, `window_title`, `memcard_dir`, ...)**: game - wins if set, inherit from bios.toml otherwise. Shallow override. +- **Scalar keys (`debug_port`, `window_title`, `memcard_dir`, ...)**: these come + from `game.toml` only. There is **no BIOS→game inheritance.** An earlier + version of this document described a shallow override where the game won and + otherwise inherited from `bios.toml`; that merge was never implemented. + `load_bios_config` (`recompiler/src/config_loader.cpp`) is called only from the + recompiler front-ends — `main_bios.cpp`, and `main_psx.cpp` purely to build the + `BiosAddressModel` — and never from `runtime/src/main.cpp`. Setting a + `[runtime]` scalar in a BIOS toml has no effect on a game run. + Runtime precedence is: environment > CLI > `settings.toml` > `game.toml` > + compiled-in default. - **`[program]` (BIOS) and `[game]` blocks**: NOT merged — they describe different programs. Both are visible to the loader. - **Generated dispatch tables and C output**: ADDITIVE. BIOS contributes @@ -83,6 +91,10 @@ respective files. seeds = "recompiler/seeds/phase2_ghidra_seeds.json" # BIOS seeds = "seeds/ghidra_funcs.txt" # game (note: game seeds aren't json today) bios_thunks = "seeds/tomba_bios_thunks.txt" # game-only +bios_config = "psxrecomp/bios/SCPH1001.toml" # game-only: BIOS profile whose address + # model game codegen folds RAM aliases + # through (see "BIOS profiles" below); + # defaults to the SCPH1001 profile out_dir = "generated" # both strict = true # both — currently always true discovery = "whole-image" # game-only: "whole-image" or "reachable" @@ -140,6 +152,121 @@ Patches are build-time inputs, not runtime memory writes or live toggles. Regenerate the affected main executable or captured overlays after changing them. +### Guarded widescreen participation comparisons + +Games may disable a proven object/model cull verdict in widened world views +without changing true 4:3 behavior: + +```toml +[[widescreen.cull.keep]] +address = "0x8002B310" +expected = "0x28A21C01" +result = 1 +``` + +- `expected` must encode `SLT`, `SLTU`, `SLTI`, or `SLTIU`. +- `result` must be 0 or 1. +- The site identity is the normalized physical address plus the complete + 32-bit instruction. A nonmatching overlay variant at the same VA is left + unchanged. +- At true 4:3 the original comparison is evaluated. The configured result is + forced only when `psx_ws_x_margin() > 0`. +- Native generated code and the dirty-RAM interpreter implement the same + semantics. +- Regenerate main/overlay native code after changing the list. + +Prefer an aspect-derived cone over `keep` when the original predicate is a +camera-frustum test. `keep` has no queue policy and is appropriate only for a +separately proven binary verdict. + +### Aspect-aware terrain and model participation + +Exact terrain-frustum angle loads can follow the live horizontal field: + +```toml +[[widescreen.cull.angle]] +address = "0x8013F138" +expected = "0x24020155" +``` + +`expected` must be `ADDI`/`ADDIU rt,zero,imm`, with a positive 12-bit angular +half-extent below one quarter-turn. The helper widens `tan(angle)` by the live +per-side horizontal extent. It is exact at 4:3 and full-word guarded against +same-address overlay variants. + +A model-list or per-child cosine rejection can use a horizontal-only envelope: + +```toml +[widescreen.cull.aspect_cone] +forward_addr = "0x1F8000E8" # signed Q12 X/Z/Y halfwords +object_type_offset = 12 +object_reg = 19 +x_reg = 16 +z_reg = 17 +y_reg = 18 +hysteresis_pixels = 24 +queue_reserve = 4 +queue_count_addrs = ["0x1F800144", "0x1F800150", "0x1F80015C"] +queue_capacities = [24, 40, 28] +queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] + +[[widescreen.cull.aspect_cone.sites]] +address = "0x80077368" +expected = "0x28620358" # signed SLTI reject predicate + +[[widescreen.cull.aspect_cone.sites]] +address = "0x8002B368" +expected = "0x0082202A" # signed SLT reject predicate +cosine_threshold = 856 # required for SLT; Q10 +object_reg = 20 # optional per-site register overrides +x_reg = 19 +z_reg = 18 +y_reg = 17 +queue_guard = false # this lower-level predicate appends to no fixed queue +``` + +- Sites must be signed `SLTI` or `SLT` reject predicates: zero is the keep + path and one is rejection. +- An `SLTI` site derives its Q10 cosine threshold from the immediate unless + `cosine_threshold` is given. An `SLT` site requires it explicitly. +- A vanilla keep is always preserved. Only a vanilla rejection is retested. +- Horizontal reach follows the current client aspect. Vertical reach, + near/far checks, type dispatch, and the game’s queue-capacity branches are + unchanged. +- `guard_pixels` is the activation guard outside the visible field. + `hysteresis_pixels` moves deactivation farther out. +- For `queue_guard = true`, non-visible guard/hysteresis candidates are + rejected at `capacity - queue_reserve`, preserving headroom for candidates + intersecting the visible wide field. +- Use `queue_guard = false` only after proving that the exact predicate does + not append to those queues. +- Site address, instruction, threshold, registers, queue policy, guard size, + and enclosing cone/queue metadata all contribute to overlay cache identity. +- Generated/native overlay code and the dirty-RAM interpreter use the same + live helper. Dynamic resizing therefore needs no recompilation. + +The debug server’s `ws_aspect_cone_site` command accepts an `address` string +and reports exact-site identity/keep/reject counters. + +Explicit `bias_sites` / `range_sites` may opt into an additional resident +object lead without widening terrain or render queues: + +```toml +[widescreen.cull] +guard_pixels = 16 +activation_guard_pixels = 256 +bias_sites = ["0x80069BA8"] +range_sites = ["0x80069BB0"] +``` + +`activation_guard_pixels` is added only to the live margin emitted at those +two explicit site families, and only while widescreen reveals extra world. +At true 4:3 it is exactly zero. `guard_pixels` remains the shared +render/terrain participation guard; keep it small when terrain producers or +model queues have fixed capacity. Both values are restricted to `[0, 256]` +and contribute to native-overlay cache identity. Changing the activation +guard requires regenerating the game and overlay code. + ## Runtime block Consumed by the cmake macro `psxrecomp_v4_add_runtime_target` (eventually) @@ -186,6 +313,22 @@ turbo_audio_sink = true the guest SPU timeline advancing but discards accelerated samples before host playback, then fades normal output back in. +## Audio Block + +Game projects may choose the host playback cushion after validating their +audio production cadence: + +```toml +[audio] +buffer_ms = 60 +``` + +`buffer_ms` accepts 30–500 milliseconds and defaults to 180. Lower values +reduce audible input-to-sound delay, but leave less reserve for frames where a +game temporarily produces no audio and can therefore crackle on affected +titles. This is deliberately a per-game developer choice; it is not read from +the player's `settings.toml`. + ## Video Block Runtime video defaults live in `[video]`: @@ -194,6 +337,8 @@ Runtime video defaults live in `[video]`: [video] renderer = "opengl" # "software", "opengl", or "vulkan" offer_vulkan = false # show Vulkan in the launcher only after game validation +auto_skip_fmv = false # legacy Settings/runtime default +offer_skip_fmv = true # false when the game exposes this through Mods ``` `renderer = "vulkan"` remains an experimental runtime choice and still requires @@ -201,6 +346,11 @@ a build compiled with Vulkan support. `offer_vulkan` controls launcher visibility only; it defaults to false so game projects must explicitly expose Vulkan after validating their visuals and stability. +`offer_skip_fmv` defaults to true for compatibility with the shared PSX +Settings surface. A game migrating Skip FMVs into its built-in mod catalog sets +it to false. The runtime then hides the Settings row, ignores stale persisted +values, and leaves activation to the selected trusted plugin. + Reserved future fields: - `default_disc_path` — game runtimes can pre-mount a disc - `default_game_root` — for sibling-junction setups @@ -240,3 +390,45 @@ These are noted here so future work knows where to slot them: Phase A might allow multiple). - `[program] type` explicit discriminator (currently inferred from `rom` vs `exe` field presence). + +## BIOS profiles (`bios/<STEM>.toml`) + +One profile per BIOS image; the profile is the single source of truth for the +image identity, the relocation windows, and the runtime anchors. Two ship: +`bios/SCPH1001.toml` (retail; user supplies the dump) and `bios/OpenBIOS.toml` +(MIT, redistributable, shipped with the build). Normal runtimes link both +generated backends (`PSXRECOMP_BIOS_STEMS=OpenBIOS;SCPH1001`) and select one at +launch. The recompiler's `[recompiler] bios_config` identifies the profile used +for game code generation; it does not choose the player's runtime BIOS. + +```toml +[program.image] # identity; recompiler refuses a mismatched ROM +sha256 = "..." # pins the exact image (empty = unchecked) +redistributable = false # true: BIOS ships with the game; runtime hides + # any requirement to provide that image + +[recompiler.address_model] # boot-time bulk code copies out of ROM +normalize_mask = "0x1FFFFFFF" +[[recompiler.address_model.copy]] +name = "Kernel Part 2" # comment label in the emitted C +rom_lo = "0x1FC10000" # [lo, hi) physical, hi EXCLUSIVE +rom_hi = "0x1FC18000" +ram_lo = "0x00000500" # physical RAM destination +runtime_base = "0x00000500" # vaddr the CPU executes the copy at +dispatch_key = "ram" # "ram": functions keyed by RAM address; + # "rom": RAM alias folds back to ROM +kernel_bless = true # runtime may byte-verify + run native + +[[recompiler.install_slots]] # kernel-RAM PCs the BIOS patches at runtime +ram_addr = "0x00000CF0" + +[recompiler.runtime_exports] # per-image HLE anchors (omit = unavailable) +shell_entry_phys = "0x00030000" +deliver_event_ret = "0x80001720" +``` + +Every `copy` entry is a claim that the boot copy is byte-verbatim; the +runtime kernel-bless memcmp enforces it. A BIOS with no copies (runs +entirely from ROM) is valid: normalization degenerates to the KSEG mask. +Semantic invariants (disjoint windows, no fold-output/input intersection, +single bless window) are enforced at load; violations refuse to build. diff --git a/docs/internal/ACCURACY_BURNDOWN.md b/docs/internal/ACCURACY_BURNDOWN.md index fb8ae9ad..6e8aaa07 100644 --- a/docs/internal/ACCURACY_BURNDOWN.md +++ b/docs/internal/ACCURACY_BURNDOWN.md @@ -159,9 +159,11 @@ bug**, not timing. now arms the cycle-paced ack scheduler, BAUD+ACK=1258 cyc, like the card path). This was the cause of the load=4 Tomba 2 boot wedge (accurate CPU exposed access-paced pad timing). See WEDGE_load4_shell_rootcause.md. + - DONE 2026-07-26: Tomba now offers explicit Analog / D-Pad modes; the + Tomba-only legacy pad-config fork and its debug/config surface were removed. + Every title now uses the modern DualShock config state machine. - TODO (axis5 Fix-6 / "1.0e-e2"): fully remove the pad fast-path so pad+card - share the unified shifter path; D4/D8 config-mode `0x43`/`0x45` semantics - (hybrid-pad fix, retire g_pad_legacy_cfg) still open. + share the unified shifter path. - [ ] **GPU**: GP0/GP1 command set, rasterization rules (top-left fill, dithering, semi-transparency modes, mask bit, texture windows, blending), VRAM-as-texture — cross-ref DuckStation gpu_*, Beetle gpu.cpp, GPU test ROMs; validate by VRAM diff. @@ -224,8 +226,9 @@ overlap the cycle axis or each other (different files), so they parallelize. must transmit the LIVE button frame (like `0x42`) + use the trailing byte to toggle config; we send all-zeros → active-low → "all buttons pressed" phantom input every `0x43` frame. P0: `0x45` status must report the LIVE analog/digital mode, not always - analog-ON (→ frame-length misparse after a flip). Fixing both lets the - `g_pad_legacy_cfg` Tomba hack be DELETED. P1: analog-mode lock (`0x44`); P2: `0x4D` + analog-ON (→ frame-length misparse after a flip). Both were fixed, and the + Tomba legacy fork was deleted 2026-07-26 when Tomba moved to explicit + Analog / D-Pad modes. P1: analog-mode lock (`0x44`); P2: `0x4D` rumble echo + cycle-paced pad ACK. Validate: `sio_trace` diff on `0x43`/`0x45`. - **`accuracy/axis5_gpu.md`.** P1: dithering ENTIRELY MISSING in both renderers (decoded but never forwarded) — largest systematic banding divergence. P2: SW rasterizer uses diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index 6c06956b..737fca4a 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,54 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- **2026-07-28 (per-game host audio cushion — implemented, parser validated):** + Added `[audio] buffer_ms` as a runtime-only developer setting with a guarded + 30–500 ms range. The compatibility default remains 180 ms, preserving the + reserve required by titles with long streamed-stage production gaps; a game + may opt into a lower target after validation to reduce audible latency. + `audio_stats` and the opt-in runtime cadence report now expose both actual + fill and configured target. This changes only the host playback bridge and + does not alter guest SPU state, instruction timing, or code generation. + Recompiler build and all 33 registered tests pass. + +- **2026-07-27 (BIOS boot-skip parity across BIOS images — FIXED, validated):** + `bios_hle`'s boot-skip did nothing under the bundled OpenBIOS. Root cause was + ordering, not policy: `main.cpp` correctly forced the *kernel-call* tier off + when an image exports no `deliver_event_ret`, by assigning `bios_hle = false`, + and then derived `boot_skip` from that already-mutated flag. OpenBIOS omits + `deliver_event_ret` (B0 semantics unvalidated) but DOES export + `shell_entry_phys`, so the skip was structurally available and got cancelled + anyway — one player-facing flag meaning two different things depending on a + BIOS detail no player can see. Enhancement-phase (load-time) defect; no + timing-core change. + Fix: the two axes are now decided by one pure, dependency-free function, + `psx_bios_hle_plan()` (`runtime/src/bios_hle_plan.c`), which reads the + REQUESTED `bios_hle` for the boot decision and gates each axis on only the + anchor it actually needs; refusals are reported at startup instead of silently + downgrading. `psx_bios_hle_configure()` additionally clamps `boot_skip` to + `shell_entry_phys != 0` so the banner and `hle_dump` cannot overstate what can + fire. New `bios_hle_plan_test` (16/16 runtime ctest green) pins the matrix, + including an exhaustive 64-case sweep asserting the boot decision is + independent of `deliver_event_ret` and the call decision independent of + `shell_entry_phys`. + Validated on Ape Escape (SCUS-94423) built against this framework, both BIOS + backends linked, headless: OpenBIOS → `bios_boot=skip to game (shell + skipped)`, `hle_dump route=2` shows the one-shot fire at `vec 0x30000`, + `ra=0xBFC06EA8` (the exact continuation `bios/OpenBIOS.toml` documents), + game running on screen. Retail SCPH-1001 → same banner, same `vec 0x30000` + fire at `ra=0xBFC0702C`, game running; differs only in + `bios_backend=HLE (LLE fallback)` vs `LLE (recompiled BIOS)`. Negative control + `PSX_BIOS_HLE=0` on OpenBIOS → `bios_boot=real intro`, ring total 0 (hook not + installed), display 640x478 shell mode: the flag still turns the skip off. + Also in this pass: a BIOS profile's `[runtime]` block was parsed into + `BiosConfig::runtime` and read by NOTHING, and `bios/OpenBIOS.toml` carried an + inert `bios_hle = false` there that read as the reason HLE was off. + `load_bios_config` now REJECTS `[runtime]` in a BIOS profile. And + `psx_icache_fastpath_test` had been unbuildable ("redefinition of + `psx_advance_cycles`" — the test's counting stub vs the header's `static + inline`), which blocked every runtime test registered after it; it now builds + with `PSX_OVERLAY_DLL_BUILD=1`, the seam `psx_cycles.h` already provides. + - **2026-07-21 (VLC load-charge batching — shipped; dual still ~22 ms):** Runtime-only batch: under `psx_next_service_cycle`, `psx_cyc_charge` accumulates into `g_psx_cyc_batch` (no per-insn `psx_cycle_count` store); @@ -304,9 +352,9 @@ on a fixed region -> next. re-enumerates gamecontrollers, keeps selection by GUID. Offline + netplay. - **2026-07-20 (launcher: lobby lock emoji via symbol fallback font):** - Password lobbies showed □ for 🔒 — LatoLatin has no emoji. Load bundled - `NotoSansSymbols2-Regular.ttf` (system Segoe UI Symbol on Windows) with - RmlUi `fallback_face=true` so missing glyphs resolve. + Password lobbies showed □ for 🔒 because the primary face has no emoji. + Load a symbol fallback face through the shared Dear ImGui font atlas so + missing glyphs resolve. - **2026-07-20 (launcher lobbies: button order + dblclick join):** Lobbies actions: Return to Launcher → Change Player Name → Host Game → @@ -559,7 +607,7 @@ on a fixed region -> next. - **2026-07-19 (netplay lobby server + launcher menus):** Lobby WS+JSON owned by proprietary `recomp-net-server` (was C `servers/lobby/` under open recomp-net); - `psx_lobby_client` + RmlUi home → Offline / Netplay → lobbies table + `psx_lobby_client` + shared launcher home → Offline / Netplay → lobbies table (host/join/password). Launch hands `PsxNetplayConfig` to `psx_netplay_start` (LAN endpoints from lobby). ICE relay stubbed. diff --git a/docs/internal/README.md b/docs/internal/README.md index 5ab8d0de..91857f24 100644 --- a/docs/internal/README.md +++ b/docs/internal/README.md @@ -40,3 +40,7 @@ behind a specific subsystem. ## Overlay cache internals - [`SLJIT_PERSIST_CACHE.md`](SLJIT_PERSIST_CACHE.md) — persisted overlay-shard cache design. + +## Savestate / netplay +- [`../SAVESTATE_LOAD_PERFORMANCE.md`](../SAVESTATE_LOAD_PERFORMANCE.md) — + solo restore hitch fixes and netplay load barrier / INPUT rules. diff --git a/docs/internal/upstream/pr13-fps-telemetry.md b/docs/internal/upstream/pr13-fps-telemetry.md index 2ae437d1..e7c3f587 100644 --- a/docs/internal/upstream/pr13-fps-telemetry.md +++ b/docs/internal/upstream/pr13-fps-telemetry.md @@ -3,7 +3,9 @@ Parked from NyperYuhgard's PSXrecomp PR #13, commit [`b49548e7b8f494c9584eb09039a8c325d7375b46`](https://github.com/mstan/psxrecomp/commit/b49548e7b8f494c9584eb09039a8c325d7375b46). -This branch retains only the title-neutral simulated-vblank FPS and realtime-speed -telemetry. It was adapted to current master and guards window-title access in -headless/windowless operation. The overlay-cache inventory from the same upstream -commit is parked separately. +This branch retains only the simulated-vblank FPS and realtime-speed telemetry. +It was adapted to current master, guards window-title access in +headless/windowless operation, and is disabled by default. Set +`PSX_FPS_TELEMETRY=1` before launch to opt in to the title-bar counter and +matching stderr line. The overlay-cache inventory from the same upstream commit +is parked separately. diff --git a/recomp-ui b/recomp-ui new file mode 160000 index 00000000..3db3d604 --- /dev/null +++ b/recomp-ui @@ -0,0 +1 @@ +Subproject commit 3db3d6042b2db8034775506bd8c1080fad2e5317 diff --git a/recompiler/include/bios_rom_alias.h b/recompiler/include/bios_rom_alias.h new file mode 100644 index 00000000..249a093a --- /dev/null +++ b/recompiler/include/bios_rom_alias.h @@ -0,0 +1,95 @@ +// bios_rom_alias.h — accept both BIOS ROM filename conventions. +// +// The multi-BIOS work introduced region-qualified ROM filenames +// ("US-PSX-SCPH1001.BIN", "US-PSOne-SCPH101.BIN", "EUR-PSX-SCPH5552.bin") +// alongside the historical bare model name ("SCPH1001.BIN"). Both conventions +// are in the wild: every existing dump folder, bios.cfg, game.toml and +// tools/regen_bios.sh default uses the bare name, while master's SCPH*.toml +// files now name the qualified one. Whichever a config asks for, the other +// must still be found — otherwise a config and a dump folder that disagree +// fatal out ("cannot open BIOS file") even though the right ROM is sitting +// right there. +// +// Matching is on the MODEL TOKEN: the last '-'-separated component of the +// filename stem, compared case-insensitively. +// +// "US-PSX-SCPH1001.BIN" -> SCPH1001 +// "SCPH1001.BIN" -> SCPH1001 +// "EUR-PSX-SCPH5552.bin" -> SCPH5552 +// +// That is convention-agnostic rather than a hardcoded alias table, so a ROM +// named with a region prefix nobody has thought of yet still resolves. +// +// Header-only, and deliberately placed in recompiler/include because both +// build trees already put that directory on the include path (see +// runtime/runtime.cmake) — the recompiler resolves the [program] rom field +// with it, the runtime resolves its BIOS search rungs with it, and neither can +// drift from the other. + +#pragma once + +#include <cctype> +#include <filesystem> +#include <string> +#include <system_error> + +namespace PSXRecompV4 { + +// The model token of a BIOS filename: the last '-'-separated piece of the +// stem, uppercased. "" for an empty stem. +inline std::string bios_model_token(const std::filesystem::path& p) { + std::string stem = p.stem().string(); + const size_t dash = stem.find_last_of('-'); + if (dash != std::string::npos) stem = stem.substr(dash + 1); + for (char& c : stem) c = (char)std::toupper((unsigned char)c); + return stem; +} + +// Does `candidate` name the same BIOS model as `wanted_token`, with a +// BIOS-ish extension? Extension is compared case-insensitively (.BIN/.bin). +inline bool bios_name_matches(const std::filesystem::path& candidate, + const std::string& wanted_token) { + if (wanted_token.empty()) return false; + std::string ext = candidate.extension().string(); + for (char& c : ext) c = (char)std::tolower((unsigned char)c); + if (ext != ".bin" && ext != ".rom") return false; + return bios_model_token(candidate) == wanted_token; +} + +// Resolve a configured BIOS ROM path to something that exists, tolerating +// either filename convention. Returns `configured` unchanged when nothing +// better is found, so the caller's own "cannot open BIOS file" diagnostic +// still fires and still names what was asked for. +inline std::filesystem::path resolve_bios_rom(const std::filesystem::path& configured) { + namespace fs = std::filesystem; + std::error_code ec; + + if (configured.empty()) return configured; + if (fs::exists(configured, ec) && !fs::is_directory(configured, ec)) return configured; + + const std::string want = bios_model_token(configured); + + // Same directory, other naming convention (either direction). + auto scan = [&](const fs::path& dir) -> fs::path { + std::error_code dec; + if (dir.empty() || !fs::is_directory(dir, dec)) return {}; + dec.clear(); + for (fs::directory_iterator it(dir, dec), end; !dec && it != end; it.increment(dec)) { + if (!it->is_regular_file(dec)) continue; + if (bios_name_matches(it->path(), want)) return it->path(); + } + return {}; + }; + + if (fs::path hit = scan(configured.parent_path()); !hit.empty()) return hit; + + // A config that names the ROM without its "bios/" directory (master's + // SCPH5552.toml does exactly this) still resolves against the conventional + // location. + const fs::path bios_dir = configured.parent_path() / "bios"; + if (fs::path hit = scan(bios_dir); !hit.empty()) return hit; + + return configured; +} + +} // namespace PSXRecompV4 diff --git a/recompiler/seeds/openbios_dispatch_miss.json b/recompiler/seeds/openbios_dispatch_miss.json new file mode 100644 index 00000000..b0a04092 --- /dev/null +++ b/recompiler/seeds/openbios_dispatch_miss.json @@ -0,0 +1,32 @@ +{ + "schema": "psxrecomp phase2 seeds", + "source": "empirical dispatch-miss loop for the OpenBIOS profile - addresses observed as FAIL-FAST unknown dispatch at runtime, merged by gen_bios_seeds_from_elf.py --extra. Same loop as SCPH1001's dispatch_miss_seeds.json.", + "seed_count": 5, + "seeds": [ + { + "address": "0xBFC0B410", + "label": "miss_BFC0B410", + "rationale": "empirical FAIL-FAST unknown dispatch during OpenBIOS bring-up boot loop" + }, + { + "address": "0xBFC0D08C", + "label": "miss_BFC0D08C", + "rationale": "empirical FAIL-FAST unknown dispatch during OpenBIOS bring-up boot loop" + }, + { + "address": "0xBFC0D070", + "label": "miss_BFC0D070", + "rationale": "empirical FAIL-FAST unknown dispatch during OpenBIOS bring-up boot loop" + }, + { + "address": "0xBFC0B3E0", + "label": "miss_BFC0B3E0", + "rationale": "empirical FAIL-FAST unknown dispatch during OpenBIOS bring-up boot loop" + }, + { + "address": "0xBFC0B39C", + "label": "miss_BFC0B39C", + "rationale": "empirical FAIL-FAST unknown dispatch during OpenBIOS bring-up boot loop" + } + ] +} diff --git a/recompiler/seeds/openbios_elf_seeds.json b/recompiler/seeds/openbios_elf_seeds.json new file mode 100644 index 00000000..32c77f16 --- /dev/null +++ b/recompiler/seeds/openbios_elf_seeds.json @@ -0,0 +1,3237 @@ +{ + "schema": "psxrecomp phase2 seeds", + "source": "gen_bios_seeds_from_elf.py over openbios.elf, shell.elf + 5 empirical seeds from openbios_dispatch_miss.json", + "seed_count": 646, + "seeds": [ + { + "address": "0xBFC00000", + "label": "elf_entry_openbios_elf", + "rationale": "ELF entry point (e_entry) of openbios.elf" + }, + { + "address": "0xBFC00084", + "label": "exit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00144", + "label": "main", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0019C", + "label": "init_main_thread", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC001AC", + "label": "_boot", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0039C", + "label": "buComputeSectorChecksum", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC003C4", + "label": "buVerifySectorChecksum", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC003F8", + "label": "buValidateEntryAndCorrect", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00514", + "label": "filterInner", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00520", + "label": "filterFirst", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0052C", + "label": "buReallocateBrokenSectorAndRetry", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC006C8", + "label": "buFormat", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00894", + "label": "buInit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00C48", + "label": "initBackupUnit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00CFC", + "label": "cardInfo", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00D60", + "label": "buWriteTOCInternal.isra.0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00E98", + "label": "buFinishAndTrigger", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00EE0", + "label": "buLowLevelOpCompleted", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC00F8C", + "label": "code_ptr_BFC00F8C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC00FB0", + "label": "code_ptr_BFC00FB0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC00FB8", + "label": "code_ptr_BFC00FB8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC010C4", + "label": "code_ptr_BFC010C4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC010D0", + "label": "code_ptr_BFC010D0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC01124", + "label": "code_ptr_BFC01124", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC011E0", + "label": "code_ptr_BFC011E0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC0153C", + "label": "code_ptr_BFC0153C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC01570", + "label": "buLowLevelOpError1", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC015E0", + "label": "buLowLevelOpError2", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC01650", + "label": "buLowLevelOpError3", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC016C0", + "label": "buReadTOC", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0175C", + "label": "buWriteTOC", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC017C0", + "label": "buSetAutoFormat", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC017D0", + "label": "dev_bu_close", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC01830", + "label": "buDevInit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC019B4", + "label": "dev_bu_format", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC01A44", + "label": "dev_bu_undelete", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC01A84", + "label": "buNextFileInternal", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC01BC4", + "label": "dev_bu_open", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC01EEC", + "label": "dev_bu_erase", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02144", + "label": "dev_bu_nextFile", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02258", + "label": "dev_bu_firstFile", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC023C8", + "label": "dev_bu_rename", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02554", + "label": "dev_bu_deinit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02594", + "label": "buRelativeToAbsoluteSector", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC025EC", + "label": "buGetReallocated", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02640", + "label": "dev_bu_read", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC029A4", + "label": "dev_bu_write", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02DD8", + "label": "addMemoryCardDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02DEC", + "label": "deinitCDRom", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02E70", + "label": "initializeCDRomHandlersAndEvents", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC02FB8", + "label": "initCDRom", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03000", + "label": "cdromBlockGetStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC030BC", + "label": "addCDRomDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC030D0", + "label": "strcat.isra.0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0310C", + "label": "findDirectoryEntryForFilename", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03404", + "label": "cdromReadPathTable", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC035B4", + "label": "dev_cd_open", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC036E0", + "label": "dev_cd_read", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC037F0", + "label": "dev_cd_nextFile", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC038FC", + "label": "dev_cd_firstFile", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03A30", + "label": "dev_cd_chdir", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03A78", + "label": "cdromBlockReading", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03CEC", + "label": "getLastCDRomError", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03D0C", + "label": "setCDRomIRQAutoAck", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03D28", + "label": "enqueueCDRomHandlers", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03DA0", + "label": "dequeueCDRomHandlers", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03DE0", + "label": "Krom2Offset", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03E70", + "label": "Krom2RawAdd", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03F00", + "label": "firstFile", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03FB0", + "label": "nextFile", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC03FD4", + "label": "format", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04080", + "label": "setupFileIO", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0414C", + "label": "getDeviceStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0415C", + "label": "setDeviceStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04168", + "label": "printInstalledDevices", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC041D0", + "label": "findDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04270", + "label": "removeDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04304", + "label": "getFileFromHandle", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04330", + "label": "ioabortraw", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04348", + "label": "ioAbortWithMsg", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04374", + "label": "findEmptyFile", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC043D0", + "label": "splitFilepathAndFindDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC044D0", + "label": "cdevscan", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04538", + "label": "psxexit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0459C", + "label": "isFileConsole", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC045E0", + "label": "addDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04668", + "label": "xprintfcallback", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC046C0", + "label": "psxopen", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04790", + "label": "psxlseek", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04840", + "label": "psxread", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04974", + "label": "psxwrite", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04AA8", + "label": "psxclose", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04B2C", + "label": "reopenStdio", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04B84", + "label": "installStdIo", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04C0C", + "label": "psxioctl", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04CBC", + "label": "psxgetc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04CF0", + "label": "psxputc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04D1C", + "label": "psxputchar", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04DA0", + "label": "psxgetchar", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04DCC", + "label": "psxgets", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04F1C", + "label": "psxputs", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04F64", + "label": "psxprintf", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC04FA4", + "label": "psxrename", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC050DC", + "label": "psxerase", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05190", + "label": "GPU_send", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC051A0", + "label": "GPU_abort", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC051CC", + "label": "GPU_timeout", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC051F8", + "label": "GPU_dw", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC052E4", + "label": "GPU_getStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC052F4", + "label": "GPU_sync", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC053C4", + "label": "GPU_mem2vram", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05474", + "label": "GPU_cw", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC054A0", + "label": "GPU_cwb", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC054F4", + "label": "GPU_sendPackets", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05578", + "label": "initEvents", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05600", + "label": "getFreeEvCBSlot", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05648", + "label": "openEvent", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC056B4", + "label": "enableEvent", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC056EC", + "label": "disableEvent", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05724", + "label": "closeEvent", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0574C", + "label": "testEvent", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0578C", + "label": "waitEvent", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC057F0", + "label": "flushCache", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05858", + "label": "toupper", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05878", + "label": "tolower", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05894", + "label": "memchr", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC058C8", + "label": "strcat", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0590C", + "label": "strcpy", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05930", + "label": "strncpy", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0596C", + "label": "strchr", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0599C", + "label": "strrchr", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC059CC", + "label": "strlen", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC059F4", + "label": "strncat", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05A44", + "label": "strcmp", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05A68", + "label": "strncmp", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05AA4", + "label": "strstr", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05AFC", + "label": "installHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05B24", + "label": "installKernelHandlers", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05B64", + "label": "installExceptionHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05B74", + "label": "setDefaultExceptionJmpBuf", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05B88", + "label": "setExceptionJmpBuf", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05B94", + "label": "clearFileError", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05B9C", + "label": "getOpenBiosApiVersionImpl", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05BA4", + "label": "getOpenBiosBuildIdImpl", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05BB0", + "label": "getB0table", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05BD8", + "label": "getC0table", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05C00", + "label": "atoi", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05C0C", + "label": "atol", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05C14", + "label": "patchA0table", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05C8C", + "label": "unimplemented", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05E10", + "label": "copyDataAndInitializeBSS", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05E54", + "label": "copyA0table", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05E6C", + "label": "psxdummy", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05E74", + "label": "psxtodigit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05EC4", + "label": "psxabs", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05ED4", + "label": "psxatob", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05F08", + "label": "psxstrpbrk", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05F48", + "label": "psxstrspn", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC05FA8", + "label": "psxstrcspn", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06008", + "label": "psxstrtok", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0604C", + "label": "psxbcopy", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06084", + "label": "psxbzero", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC060C4", + "label": "psxbcmp", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC060E4", + "label": "psxrand", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06110", + "label": "psxsrand", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0611C", + "label": "psxlsearch", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06180", + "label": "psxbsearch", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06220", + "label": "getSystemInfo", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06248", + "label": "code_ptr_BFC06248", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC06254", + "label": "code_ptr_BFC06254", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC06260", + "label": "code_ptr_BFC06260", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC06270", + "label": "code_ptr_BFC06270", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC06278", + "label": "code_ptr_BFC06278", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC06288", + "label": "code_ptr_BFC06288", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC06290", + "label": "code_ptr_BFC06290", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC06298", + "label": "code_ptr_BFC06298", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC062A0", + "label": "code_ptr_BFC062A0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC062A8", + "label": "setMemSize", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06324", + "label": "readHeader", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0637C", + "label": "loadExeHeader", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC063E4", + "label": "loadExe", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0648C", + "label": "loadAndExec", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06634", + "label": "exec", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC066B0", + "label": "psxsetjmp", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC066EC", + "label": "psxlongjmp", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06728", + "label": "initThreads", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06834", + "label": "getFreeTCBslot", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0688C", + "label": "openThread", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC068F8", + "label": "closeThread", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06924", + "label": "changeThread", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06950", + "label": "exceptionVector", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06960", + "label": "A0Vector", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06970", + "label": "B0Vector", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06980", + "label": "C0Vector", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06990", + "label": "romsyscall_printf", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0699C", + "label": "isspace", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC069B8", + "label": "strncmp", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC069F4", + "label": "muteSpu", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06A10", + "label": "fatal", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06A38", + "label": "initHandlersArray.constprop.0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06A8C", + "label": "strcpy.isra.0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06AA8", + "label": "strcat.isra.0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06AE4", + "label": "findWordItem.constprop.0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06C54", + "label": "gameMainThunk", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC06C7C", + "label": "boot", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07390", + "label": "bootThunk", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC073E0", + "label": "setConfiguration", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC073F8", + "label": "getConfiguration", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07424", + "label": "drawSplashScreen", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0742C", + "label": "runExp1PreHook", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07484", + "label": "runExp1PostHook", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC074DC", + "label": "startShell", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07578", + "label": "mcResetStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07604", + "label": "mcWaitForStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0767C", + "label": "mcWaitForStatusAndReturnIndex", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07704", + "label": "patch_remove_ChgclrPAD", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07714", + "label": "patch_disable_slotChangeOnAbort", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07724", + "label": "patch_send_pad", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07734", + "label": "patch_startPad", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07744", + "label": "patch_stopPad", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07750", + "label": "mcAllowNewCard", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07760", + "label": "mcReadSector", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07808", + "label": "mcWriteSector", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC078B0", + "label": "cardInfoInternal", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07944", + "label": "mcGetLastDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07968", + "label": "dev_tty_init", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC079C8", + "label": "dev_tty_open", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07A24", + "label": "ttyGetChar", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07A74", + "label": "dev_tty_action", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07CA4", + "label": "dev_tty_ioctl", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07D94", + "label": "addConsoleDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07DA8", + "label": "addDummyConsoleDevice", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07DBC", + "label": "patch_hash", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC07E84", + "label": "patch_hook", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08060", + "label": "clear_card_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08068", + "label": "custom_handler_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08070", + "label": "initgun_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08078", + "label": "patch_card_info_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0809C", + "label": "patch_card_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC080A4", + "label": "patch_card_2_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC080AC", + "label": "patch_card2_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC080D0", + "label": "patch_card2_2_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC080F4", + "label": "patch_gte_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC080FC", + "label": "patch_gte_2_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08104", + "label": "patch_gte_3_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0810C", + "label": "patch_pad_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0817C", + "label": "patch_pad_2_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC081EC", + "label": "patch_pad_3_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08220", + "label": "remove_ChgclrPAD_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08254", + "label": "remove_ChgclrPAD_2_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08288", + "label": "send_pad_1_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC082DC", + "label": "send_pad_2_execute", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08330", + "label": "BoardConsoleInit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08338", + "label": "BoardConsolePuts", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08360", + "label": "BoardConsolePutc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08370", + "label": "BoardEarlyInit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08378", + "label": "BoardInit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08380", + "label": "BoardLateInit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08388", + "label": "BoardShutdown", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08390", + "label": "BoardExceptionHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08398", + "label": "cpu_early_init", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC083A0", + "label": "cpu_init", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC083A8", + "label": "cpu_late_init", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC083B0", + "label": "__sinit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC083B8", + "label": "libc_init", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08478", + "label": "qsort", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC085D8", + "label": "memcpy", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08604", + "label": "memmove", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08674", + "label": "memcmp", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC086C4", + "label": "memset", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC086E8", + "label": "strtol", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC088F0", + "label": "vxprintf", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC08C58", + "label": "code_ptr_BFC08C58", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC08C9C", + "label": "code_ptr_BFC08C9C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC08F38", + "label": "code_ptr_BFC08F38", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC08F4C", + "label": "code_ptr_BFC08F4C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC08F64", + "label": "code_ptr_BFC08F64", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC08F80", + "label": "code_ptr_BFC08F80", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC08FD4", + "label": "code_ptr_BFC08FD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC09028", + "label": "code_ptr_BFC09028", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC09108", + "label": "_exit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0912C", + "label": "_ucsdk_start", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC09198", + "label": "dbgput", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC091E0", + "label": "dbgput.constprop.0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC091EC", + "label": "osDbgPrintf", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC0A500", + "label": "elf_entry_shell_elf", + "rationale": "ELF entry point (e_entry) of shell.elf" + }, + { + "address": "0xBFC0A564", + "label": "main", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B160", + "label": "memset", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B184", + "label": "startDmaOneSector", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B218", + "label": "initCD", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B250", + "label": "isCDError", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B280", + "label": "isCDSuccess", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B298", + "label": "isCDAudio", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B2B0", + "label": "checkCD", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B378", + "label": "code_ptr_BFC0B378", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B39C", + "label": "miss_BFC0B39C", + "rationale": "empirical dispatch-miss seed merged from openbios_dispatch_miss.json" + }, + { + "address": "0xBFC0B3E0", + "label": "miss_BFC0B3E0", + "rationale": "empirical dispatch-miss seed merged from openbios_dispatch_miss.json" + }, + { + "address": "0xBFC0B410", + "label": "miss_BFC0B410", + "rationale": "empirical dispatch-miss seed merged from openbios_dispatch_miss.json" + }, + { + "address": "0xBFC0B484", + "label": "code_ptr_BFC0B484", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B4D0", + "label": "code_ptr_BFC0B4D0", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B514", + "label": "code_ptr_BFC0B514", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B538", + "label": "code_ptr_BFC0B538", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B558", + "label": "code_ptr_BFC0B558", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B594", + "label": "code_ptr_BFC0B594", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B760", + "label": "code_ptr_BFC0B760", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B7A0", + "label": "code_ptr_BFC0B7A0", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B7D4", + "label": "code_ptr_BFC0B7D4", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B7EC", + "label": "code_ptr_BFC0B7EC", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B810", + "label": "code_ptr_BFC0B810", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B844", + "label": "code_ptr_BFC0B844", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B868", + "label": "code_ptr_BFC0B868", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B880", + "label": "code_ptr_BFC0B880", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B888", + "label": "code_ptr_BFC0B888", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0B8CC", + "label": "generateCosTable", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B928", + "label": "waitGPU", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0B94C", + "label": "flip", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0BAA8", + "label": "waitVSync", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0BB94", + "label": "initGPU", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0BC24", + "label": "orientation", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0BC88", + "label": "compare", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0BD6C", + "label": "qsort", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0BEAC", + "label": "convexHull", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C0DC", + "label": "sendGPUVertex", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C110", + "label": "startLerp", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C2AC", + "label": "idle", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C2C8", + "label": "lerpC.isra.0", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C354", + "label": "dCos", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C3C0", + "label": "generateRotationMatrix3D", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C4A8", + "label": "multiplyMatrix3D", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C828", + "label": "scaleMatrix3D", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0C970", + "label": "matrixVertexMul3D", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CAB0", + "label": "matrixVertexMul3Dz", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CB20", + "label": "dDiv", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CC40", + "label": "initSPU", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CC98", + "label": "checkSPU", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CD74", + "label": "uninitSPU", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CD8C", + "label": "spuSchedulePing", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CDEC", + "label": "spuScheduleIdle", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CE20", + "label": "spuScheduleOutro", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CE78", + "label": "spuScheduleError", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CE9C", + "label": "SPUInit", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CF14", + "label": "SPUResetVoice", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CF50", + "label": "SPUSetVoiceVolume", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CF90", + "label": "SPUWaitIdle", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0CFC0", + "label": "MOD_SetBPM", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0D024", + "label": "MOD_UpdateEffect", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0D070", + "label": "miss_BFC0D070", + "rationale": "empirical dispatch-miss seed merged from openbios_dispatch_miss.json" + }, + { + "address": "0xBFC0D08C", + "label": "miss_BFC0D08C", + "rationale": "empirical dispatch-miss seed merged from openbios_dispatch_miss.json" + }, + { + "address": "0xBFC0D0A4", + "label": "code_ptr_BFC0D0A4", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D11C", + "label": "code_ptr_BFC0D11C", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D174", + "label": "code_ptr_BFC0D174", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D198", + "label": "code_ptr_BFC0D198", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D1B8", + "label": "code_ptr_BFC0D1B8", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D214", + "label": "code_ptr_BFC0D214", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D29C", + "label": "code_ptr_BFC0D29C", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D2F8", + "label": "code_ptr_BFC0D2F8", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D3A8", + "label": "code_ptr_BFC0D3A8", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D4A0", + "label": "code_ptr_BFC0D4A0", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D4DC", + "label": "code_ptr_BFC0D4DC", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0D588", + "label": "SPUUploadInstruments.constprop.0", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0D624", + "label": "MOD_Check", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0D698", + "label": "loadInternal", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0D884", + "label": "MOD_Load", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0D88C", + "label": "MOD_Silence", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0D8C4", + "label": "MOD_Poll", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC0DD00", + "label": "code_ptr_BFC0DD00", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DD34", + "label": "code_ptr_BFC0DD34", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DD48", + "label": "code_ptr_BFC0DD48", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DD84", + "label": "code_ptr_BFC0DD84", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DDC0", + "label": "code_ptr_BFC0DDC0", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DDD0", + "label": "code_ptr_BFC0DDD0", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DDF0", + "label": "code_ptr_BFC0DDF0", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DE50", + "label": "code_ptr_BFC0DE50", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DE70", + "label": "code_ptr_BFC0DE70", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DEA0", + "label": "code_ptr_BFC0DEA0", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DEF4", + "label": "code_ptr_BFC0DEF4", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DF00", + "label": "code_ptr_BFC0DF00", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DF18", + "label": "code_ptr_BFC0DF18", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DF30", + "label": "code_ptr_BFC0DF30", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DF68", + "label": "code_ptr_BFC0DF68", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DF80", + "label": "code_ptr_BFC0DF80", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DFAC", + "label": "code_ptr_BFC0DFAC", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DFD4", + "label": "code_ptr_BFC0DFD4", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0DFDC", + "label": "code_ptr_BFC0DFDC", + "rationale": "code pointer in a data section of shell.elf" + }, + { + "address": "0xBFC0E050", + "label": "ramsyscall_printf", + "rationale": "ELF STT_FUNC symbol from shell.elf" + }, + { + "address": "0xBFC1E514", + "label": "fastMemset", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1E6CC", + "label": "cdromUndeliverAllExceptAckAndRdy", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1E6E0", + "label": "code_ptr_BFC1E6E0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E748", + "label": "cdromUndeliverAll", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1E7D4", + "label": "code_ptr_BFC1E7D4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E7DC", + "label": "code_ptr_BFC1E7DC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E7E4", + "label": "initiateDMA", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1E7E8", + "label": "code_ptr_BFC1E7E8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E7F0", + "label": "code_ptr_BFC1E7F0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E7F4", + "label": "code_ptr_BFC1E7F4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E80C", + "label": "code_ptr_BFC1E80C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E810", + "label": "code_ptr_BFC1E810", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E814", + "label": "code_ptr_BFC1E814", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E834", + "label": "code_ptr_BFC1E834", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E848", + "label": "code_ptr_BFC1E848", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1E8CC", + "label": "genericErrorState", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1E91C", + "label": "demuteAck", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1E94C", + "label": "cdromDMAVerifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1E9E8", + "label": "cdromIOHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1EA24", + "label": "cdromDMAHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1EA60", + "label": "cdromIOVerifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1EB50", + "label": "code_ptr_BFC1EB50", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EBD4", + "label": "code_ptr_BFC1EBD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EBD8", + "label": "code_ptr_BFC1EBD8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EBDC", + "label": "code_ptr_BFC1EBDC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EBE8", + "label": "code_ptr_BFC1EBE8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EBEC", + "label": "code_ptr_BFC1EBEC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EBF0", + "label": "code_ptr_BFC1EBF0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EBF8", + "label": "code_ptr_BFC1EBF8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EC0C", + "label": "code_ptr_BFC1EC0C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EC10", + "label": "code_ptr_BFC1EC10", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EC14", + "label": "code_ptr_BFC1EC14", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EC20", + "label": "code_ptr_BFC1EC20", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EC30", + "label": "code_ptr_BFC1EC30", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EC34", + "label": "code_ptr_BFC1EC34", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EC44", + "label": "code_ptr_BFC1EC44", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EC94", + "label": "code_ptr_BFC1EC94", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1ECA0", + "label": "code_ptr_BFC1ECA0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1ECD4", + "label": "code_ptr_BFC1ECD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1ECDC", + "label": "code_ptr_BFC1ECDC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EDC4", + "label": "code_ptr_BFC1EDC4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EED4", + "label": "code_ptr_BFC1EED4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EEE4", + "label": "code_ptr_BFC1EEE4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EEFC", + "label": "code_ptr_BFC1EEFC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EF04", + "label": "code_ptr_BFC1EF04", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EF14", + "label": "code_ptr_BFC1EF14", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EF24", + "label": "code_ptr_BFC1EF24", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EF34", + "label": "code_ptr_BFC1EF34", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFC4", + "label": "code_ptr_BFC1EFC4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFCC", + "label": "code_ptr_BFC1EFCC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFD4", + "label": "code_ptr_BFC1EFD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFD8", + "label": "code_ptr_BFC1EFD8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFDC", + "label": "code_ptr_BFC1EFDC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFE4", + "label": "code_ptr_BFC1EFE4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFEC", + "label": "code_ptr_BFC1EFEC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFF0", + "label": "code_ptr_BFC1EFF0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1EFF4", + "label": "code_ptr_BFC1EFF4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F004", + "label": "code_ptr_BFC1F004", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F010", + "label": "code_ptr_BFC1F010", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F014", + "label": "code_ptr_BFC1F014", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F034", + "label": "code_ptr_BFC1F034", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F0C4", + "label": "code_ptr_BFC1F0C4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F0FC", + "label": "code_ptr_BFC1F0FC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F1F0", + "label": "code_ptr_BFC1F1F0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F290", + "label": "cdromSeekL", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1F36C", + "label": "cdromGetStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1F3C8", + "label": "cdromRead", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1F3F0", + "label": "code_ptr_BFC1F3F0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F408", + "label": "code_ptr_BFC1F408", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F49C", + "label": "cdromSetMode", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1F510", + "label": "resetAllCDRomIRQs", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1F54C", + "label": "enableAllCDRomIRQs", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1F568", + "label": "cdromInnerInit", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1F6B8", + "label": "IRQVerifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1F7D4", + "label": "code_ptr_BFC1F7D4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F7D8", + "label": "code_ptr_BFC1F7D8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F7DC", + "label": "code_ptr_BFC1F7DC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F7E0", + "label": "code_ptr_BFC1F7E0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F7E4", + "label": "code_ptr_BFC1F7E4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F7E8", + "label": "code_ptr_BFC1F7E8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F7EC", + "label": "code_ptr_BFC1F7EC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F7F4", + "label": "code_ptr_BFC1F7F4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F804", + "label": "code_ptr_BFC1F804", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F808", + "label": "code_ptr_BFC1F808", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F80C", + "label": "code_ptr_BFC1F80C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F814", + "label": "code_ptr_BFC1F814", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F818", + "label": "code_ptr_BFC1F818", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F81C", + "label": "code_ptr_BFC1F81C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F824", + "label": "code_ptr_BFC1F824", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F834", + "label": "code_ptr_BFC1F834", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F844", + "label": "code_ptr_BFC1F844", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F84C", + "label": "code_ptr_BFC1F84C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1F854", + "label": "code_ptr_BFC1F854", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FA48", + "label": "T3verifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FAA0", + "label": "T2verifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FAF8", + "label": "T1verifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FB50", + "label": "T0verifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FBA4", + "label": "T3handler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FBD4", + "label": "code_ptr_BFC1FBD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FBD8", + "label": "T2handler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FBE8", + "label": "code_ptr_BFC1FBE8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FBEC", + "label": "code_ptr_BFC1FBEC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FBF0", + "label": "code_ptr_BFC1FBF0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FC08", + "label": "code_ptr_BFC1FC08", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FC0C", + "label": "T1handler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FC18", + "label": "code_ptr_BFC1FC18", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FC1C", + "label": "code_ptr_BFC1FC1C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FC20", + "label": "code_ptr_BFC1FC20", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FC2C", + "label": "code_ptr_BFC1FC2C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FC40", + "label": "T0handler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FC44", + "label": "code_ptr_BFC1FC44", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FC4C", + "label": "code_ptr_BFC1FC4C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FC74", + "label": "enqueueIrqHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FC9C", + "label": "setIrqAutoAck", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FCB4", + "label": "enqueueRCntIrqs", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FD58", + "label": "setTimerAutoAck", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FD74", + "label": "getTimer", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FD98", + "label": "initTimer", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FDF8", + "label": "enableTimerIRQ", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FE2C", + "label": "disableTimerIRQ", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FE60", + "label": "restartTimer", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FE8C", + "label": "sysEnqIntRP", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FEB0", + "label": "sysDeqIntRP", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FF40", + "label": "syscallVerifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC1FFD4", + "label": "code_ptr_BFC1FFD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FFD8", + "label": "code_ptr_BFC1FFD8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FFDC", + "label": "code_ptr_BFC1FFDC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FFE0", + "label": "code_ptr_BFC1FFE0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FFE4", + "label": "code_ptr_BFC1FFE4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FFE8", + "label": "code_ptr_BFC1FFE8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC1FFF4", + "label": "code_ptr_BFC1FFF4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20004", + "label": "code_ptr_BFC20004", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20010", + "label": "code_ptr_BFC20010", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20014", + "label": "code_ptr_BFC20014", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20018", + "label": "code_ptr_BFC20018", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC2002C", + "label": "code_ptr_BFC2002C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20034", + "label": "code_ptr_BFC20034", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20044", + "label": "code_ptr_BFC20044", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20050", + "label": "enqueueSyscallHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2005C", + "label": "multi_malloc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20184", + "label": "multi_free", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC202DC", + "label": "multi_realloc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC204F4", + "label": "code_ptr_BFC204F4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20554", + "label": "user_malloc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2055C", + "label": "user_free", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20564", + "label": "user_realloc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2056C", + "label": "user_initheap", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2059C", + "label": "kern_malloc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC205A4", + "label": "kern_free", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC205AC", + "label": "kern_realloc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC205B4", + "label": "kern_initheap", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC205E4", + "label": "deliverEvent", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC206B8", + "label": "undeliverEvent", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20730", + "label": "wrapper_calloc", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20770", + "label": "getCop0CauseAndEPC", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20780", + "label": "exceptionHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC207E4", + "label": "code_ptr_BFC207E4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20940", + "label": "code_ptr_BFC20940", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC209A0", + "label": "returnFromException", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20A54", + "label": "A0Handler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20A74", + "label": "B0Handler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20A94", + "label": "C0Handler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20AB4", + "label": "OBHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20AD0", + "label": "unimplementedThunk", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20AE8", + "label": "ramsyscall_printf", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20AF4", + "label": "busyloop", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20B30", + "label": "mcReadHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20B80", + "label": "code_ptr_BFC20B80", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20BEC", + "label": "code_ptr_BFC20BEC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20C20", + "label": "code_ptr_BFC20C20", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20CCC", + "label": "code_ptr_BFC20CCC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20D00", + "label": "code_ptr_BFC20D00", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20D34", + "label": "code_ptr_BFC20D34", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20D48", + "label": "code_ptr_BFC20D48", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20D5C", + "label": "code_ptr_BFC20D5C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20D8C", + "label": "code_ptr_BFC20D8C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20DA0", + "label": "code_ptr_BFC20DA0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20DFC", + "label": "code_ptr_BFC20DFC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20E48", + "label": "code_ptr_BFC20E48", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20EA4", + "label": "code_ptr_BFC20EA4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20F18", + "label": "mcWriteHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC20F70", + "label": "code_ptr_BFC20F70", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20FD4", + "label": "code_ptr_BFC20FD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20FD8", + "label": "code_ptr_BFC20FD8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20FDC", + "label": "code_ptr_BFC20FDC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20FE0", + "label": "code_ptr_BFC20FE0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20FE4", + "label": "code_ptr_BFC20FE4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20FE8", + "label": "code_ptr_BFC20FE8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC20FEC", + "label": "code_ptr_BFC20FEC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21004", + "label": "code_ptr_BFC21004", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21014", + "label": "code_ptr_BFC21014", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC2101C", + "label": "code_ptr_BFC2101C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21020", + "label": "code_ptr_BFC21020", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC2102C", + "label": "code_ptr_BFC2102C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21034", + "label": "code_ptr_BFC21034", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21044", + "label": "code_ptr_BFC21044", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC2104C", + "label": "code_ptr_BFC2104C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC210C0", + "label": "code_ptr_BFC210C0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC210F4", + "label": "code_ptr_BFC210F4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21104", + "label": "code_ptr_BFC21104", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC2113C", + "label": "code_ptr_BFC2113C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21190", + "label": "code_ptr_BFC21190", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC211B8", + "label": "code_ptr_BFC211B8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC211CC", + "label": "code_ptr_BFC211CC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC211FC", + "label": "code_ptr_BFC211FC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC212E0", + "label": "mcInfoHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC214E0", + "label": "getCardStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC214F8", + "label": "waitCardStatus", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC21528", + "label": "exceptionHandlerCardFastTrack", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC216AC", + "label": "mcVerifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC216F0", + "label": "sio0Verifier", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2171C", + "label": "padAbort", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC21794", + "label": "setupSIO0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC217E4", + "label": "code_ptr_BFC217E4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21808", + "label": "mcHandler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2180C", + "label": "code_ptr_BFC2180C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21844", + "label": "code_ptr_BFC21844", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC2190C", + "label": "code_ptr_BFC2190C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21A04", + "label": "code_ptr_BFC21A04", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21A20", + "label": "readPad.isra.0", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC21BD4", + "label": "code_ptr_BFC21BD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21BDC", + "label": "code_ptr_BFC21BDC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21BE4", + "label": "code_ptr_BFC21BE4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21BEC", + "label": "code_ptr_BFC21BEC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21BFC", + "label": "code_ptr_BFC21BFC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21C04", + "label": "code_ptr_BFC21C04", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21C0C", + "label": "code_ptr_BFC21C0C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21C14", + "label": "code_ptr_BFC21C14", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21C1C", + "label": "code_ptr_BFC21C1C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21C20", + "label": "code_ptr_BFC21C20", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21C34", + "label": "code_ptr_BFC21C34", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21C44", + "label": "code_ptr_BFC21C44", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21E20", + "label": "code_ptr_BFC21E20", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21E40", + "label": "sio0Handler", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC21FD4", + "label": "code_ptr_BFC21FD4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21FD8", + "label": "code_ptr_BFC21FD8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21FDC", + "label": "code_ptr_BFC21FDC", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21FE0", + "label": "code_ptr_BFC21FE0", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21FE4", + "label": "code_ptr_BFC21FE4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC21FF4", + "label": "code_ptr_BFC21FF4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC22004", + "label": "code_ptr_BFC22004", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC22014", + "label": "code_ptr_BFC22014", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC22020", + "label": "code_ptr_BFC22020", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC22034", + "label": "code_ptr_BFC22034", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC22074", + "label": "setSIO0AutoAck", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC22084", + "label": "patch_setPadOutputData", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC22098", + "label": "initPad", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2218C", + "label": "startPad", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC22208", + "label": "stopPad", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2224C", + "label": "initCard", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC22330", + "label": "startCard", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC223B0", + "label": "stopCard", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC223E8", + "label": "code_ptr_BFC223E8", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC223FC", + "label": "alterUserPadData", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC2240C", + "label": "code_ptr_BFC2240C", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC22414", + "label": "code_ptr_BFC22414", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC2249C", + "label": "initPadHighLevel", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC22578", + "label": "readPadHighLevel", + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { + "address": "0xBFC22674", + "label": "code_ptr_BFC22674", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC227D4", + "label": "code_ptr_BFC227D4", + "rationale": "code pointer in a data section of openbios.elf" + }, + { + "address": "0xBFC227E4", + "label": "code_ptr_BFC227E4", + "rationale": "code pointer in a data section of openbios.elf" + } + ] +} diff --git a/recompiler/seeds/phase2_ghidra_seeds_SCPH101.json b/recompiler/seeds/phase2_ghidra_seeds_SCPH101.json new file mode 100644 index 00000000..aa5b1311 --- /dev/null +++ b/recompiler/seeds/phase2_ghidra_seeds_SCPH101.json @@ -0,0 +1,2654 @@ +{ + "schema": "psxrecomp phase2 seeds", + "source": "phase2_ghidra_seeds.json filtered vs SCPH101 ROM (64-byte window)", + "seed_count": 534, + "seeds": [ + { + "address": "0xBFC00000", + "label": "reset_vector", + "rationale": "MIPS R3000A reset vector" + }, + { + "address": "0xBFC00180", + "label": "general_exception_bev1", + "rationale": "BEV=1 general exception vector" + }, + { + "address": "0xBFC00420", + "label": "ghidra_BFC00420", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00678", + "label": "ghidra_BFC00678", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC006A8", + "label": "ghidra_BFC006A8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC006D8", + "label": "ghidra_BFC006D8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00778", + "label": "ghidra_BFC00778", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00810", + "label": "ghidra_BFC00810", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC008A0", + "label": "ghidra_BFC008A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00944", + "label": "ghidra_BFC00944", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00B7C", + "label": "ghidra_BFC00B7C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00DA0", + "label": "ghidra_BFC00DA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC018E0", + "label": "ghidra_BFC018E0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01920", + "label": "ghidra_BFC01920", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01A60", + "label": "ghidra_BFC01A60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01A90", + "label": "ghidra_BFC01A90", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01ACC", + "label": "ghidra_BFC01ACC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01B80", + "label": "ghidra_BFC01B80", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01C8C", + "label": "ghidra_BFC01C8C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01E5C", + "label": "ghidra_BFC01E5C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC020F0", + "label": "ghidra_BFC020F0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02104", + "label": "ghidra_BFC02104", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02240", + "label": "ghidra_BFC02240", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0227C", + "label": "ghidra_BFC0227C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC022C0", + "label": "ghidra_BFC022C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02324", + "label": "ghidra_BFC02324", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02590", + "label": "ghidra_BFC02590", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC025B0", + "label": "ghidra_BFC025B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02738", + "label": "ghidra_BFC02738", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02950", + "label": "ghidra_BFC02950", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02AF0", + "label": "ghidra_BFC02AF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02B10", + "label": "ghidra_BFC02B10", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02B50", + "label": "ghidra_BFC02B50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02CC0", + "label": "ghidra_BFC02CC0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02D20", + "label": "ghidra_BFC02D20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02DAC", + "label": "ghidra_BFC02DAC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02EA0", + "label": "ghidra_BFC02EA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02EDC", + "label": "ghidra_BFC02EDC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02F20", + "label": "ghidra_BFC02F20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03098", + "label": "ghidra_BFC03098", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03104", + "label": "ghidra_BFC03104", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03190", + "label": "ghidra_BFC03190", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03288", + "label": "ghidra_BFC03288", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03310", + "label": "ghidra_BFC03310", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC033C8", + "label": "ghidra_BFC033C8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03418", + "label": "ghidra_BFC03418", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03494", + "label": "ghidra_BFC03494", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03764", + "label": "ghidra_BFC03764", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03910", + "label": "ghidra_BFC03910", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03968", + "label": "ghidra_BFC03968", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03978", + "label": "ghidra_BFC03978", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03990", + "label": "ghidra_BFC03990", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC039B0", + "label": "ghidra_BFC039B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03A18", + "label": "ghidra_BFC03A18", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03AA4", + "label": "ghidra_BFC03AA4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03C90", + "label": "ghidra_BFC03C90", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03CF0", + "label": "ghidra_BFC03CF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03D80", + "label": "ghidra_BFC03D80", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03EA0", + "label": "ghidra_BFC03EA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03F8C", + "label": "ghidra_BFC03F8C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03FAC", + "label": "ghidra_BFC03FAC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03FE0", + "label": "ghidra_BFC03FE0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0403C", + "label": "ghidra_BFC0403C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC040EC", + "label": "ghidra_BFC040EC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04138", + "label": "ghidra_BFC04138", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04260", + "label": "ghidra_BFC04260", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC042A0", + "label": "ghidra_BFC042A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC042D0", + "label": "ghidra_BFC042D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04610", + "label": "ghidra_BFC04610", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04678", + "label": "ghidra_BFC04678", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0472C", + "label": "ghidra_BFC0472C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC048D0", + "label": "ghidra_BFC048D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04ABC", + "label": "ghidra_BFC04ABC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04BC4", + "label": "ghidra_BFC04BC4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04C38", + "label": "ghidra_BFC04C38", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04D50", + "label": "ghidra_BFC04D50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04DEC", + "label": "ghidra_BFC04DEC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04FBC", + "label": "ghidra_BFC04FBC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0506C", + "label": "ghidra_BFC0506C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC050A4", + "label": "ghidra_BFC050A4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC050FC", + "label": "ghidra_BFC050FC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05120", + "label": "ghidra_BFC05120", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05194", + "label": "ghidra_BFC05194", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05558", + "label": "ghidra_BFC05558", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC057B0", + "label": "ghidra_BFC057B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0593C", + "label": "ghidra_BFC0593C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05A44", + "label": "ghidra_BFC05A44", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05C18", + "label": "ghidra_BFC05C18", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05C50", + "label": "ghidra_BFC05C50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05D04", + "label": "ghidra_BFC05D04", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05DB0", + "label": "ghidra_BFC05DB0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05DF4", + "label": "ghidra_BFC05DF4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05E58", + "label": "ghidra_BFC05E58", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05F08", + "label": "ghidra_BFC05F08", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06010", + "label": "ghidra_BFC06010", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06038", + "label": "ghidra_BFC06038", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC060DC", + "label": "ghidra_BFC060DC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0615C", + "label": "ghidra_BFC0615C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC061A0", + "label": "ghidra_BFC061A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC061D4", + "label": "ghidra_BFC061D4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06218", + "label": "ghidra_BFC06218", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06288", + "label": "ghidra_BFC06288", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC062CC", + "label": "ghidra_BFC062CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC063CC", + "label": "ghidra_BFC063CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC064D8", + "label": "ghidra_BFC064D8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06548", + "label": "ghidra_BFC06548", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC065C0", + "label": "ghidra_BFC065C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0661C", + "label": "ghidra_BFC0661C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06680", + "label": "ghidra_BFC06680", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06700", + "label": "ghidra_BFC06700", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06784", + "label": "ghidra_BFC06784", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC067E8", + "label": "ghidra_BFC067E8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06CA4", + "label": "ghidra_BFC06CA4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06EC4", + "label": "ghidra_BFC06EC4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06F28", + "label": "ghidra_BFC06F28", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06FA4", + "label": "ghidra_BFC06FA4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06FF0", + "label": "ghidra_BFC06FF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0703C", + "label": "ghidra_BFC0703C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC070AC", + "label": "ghidra_BFC070AC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0711C", + "label": "ghidra_BFC0711C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07148", + "label": "ghidra_BFC07148", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC071A0", + "label": "ghidra_BFC071A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC072B8", + "label": "ghidra_BFC072B8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07330", + "label": "ghidra_BFC07330", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07370", + "label": "ghidra_BFC07370", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC073A0", + "label": "ghidra_BFC073A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07410", + "label": "ghidra_BFC07410", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07664", + "label": "ghidra_BFC07664", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07700", + "label": "ghidra_BFC07700", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC078A4", + "label": "ghidra_BFC078A4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07A04", + "label": "ghidra_BFC07A04", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07B88", + "label": "ghidra_BFC07B88", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07C1C", + "label": "ghidra_BFC07C1C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07F28", + "label": "ghidra_BFC07F28", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08020", + "label": "ghidra_BFC08020", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08098", + "label": "ghidra_BFC08098", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC081FC", + "label": "ghidra_BFC081FC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08354", + "label": "ghidra_BFC08354", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC083CC", + "label": "ghidra_BFC083CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC085B0", + "label": "ghidra_BFC085B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC085D4", + "label": "ghidra_BFC085D4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC086B0", + "label": "ghidra_BFC086B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC086EC", + "label": "ghidra_BFC086EC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08720", + "label": "ghidra_BFC08720", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC087C4", + "label": "ghidra_BFC087C4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0884C", + "label": "ghidra_BFC0884C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC088A0", + "label": "ghidra_BFC088A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0895C", + "label": "ghidra_BFC0895C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC089C0", + "label": "ghidra_BFC089C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08B3C", + "label": "ghidra_BFC08B3C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08FFC", + "label": "ghidra_BFC08FFC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09144", + "label": "ghidra_BFC09144", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC091CC", + "label": "ghidra_BFC091CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC092EC", + "label": "ghidra_BFC092EC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09540", + "label": "ghidra_BFC09540", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09598", + "label": "ghidra_BFC09598", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09720", + "label": "ghidra_BFC09720", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC098A8", + "label": "ghidra_BFC098A8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09914", + "label": "ghidra_BFC09914", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0994C", + "label": "ghidra_BFC0994C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0996C", + "label": "ghidra_BFC0996C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09CDC", + "label": "ghidra_BFC09CDC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09D3C", + "label": "ghidra_BFC09D3C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A080", + "label": "ghidra_BFC0A080", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A3D4", + "label": "ghidra_BFC0A3D4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A43C", + "label": "ghidra_BFC0A43C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A4B4", + "label": "ghidra_BFC0A4B4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A5E4", + "label": "ghidra_BFC0A5E4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A754", + "label": "ghidra_BFC0A754", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A8BC", + "label": "ghidra_BFC0A8BC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0AB60", + "label": "ghidra_BFC0AB60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0AD08", + "label": "ghidra_BFC0AD08", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B0D0", + "label": "ghidra_BFC0B0D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B170", + "label": "ghidra_BFC0B170", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B394", + "label": "ghidra_BFC0B394", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B54C", + "label": "ghidra_BFC0B54C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B5A0", + "label": "ghidra_BFC0B5A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B5F4", + "label": "ghidra_BFC0B5F4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B648", + "label": "ghidra_BFC0B648", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0BFF0", + "label": "ghidra_BFC0BFF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C048", + "label": "ghidra_BFC0C048", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C0DC", + "label": "ghidra_BFC0C0DC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C140", + "label": "ghidra_BFC0C140", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C1FC", + "label": "ghidra_BFC0C1FC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C220", + "label": "ghidra_BFC0C220", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C248", + "label": "ghidra_BFC0C248", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C270", + "label": "ghidra_BFC0C270", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C298", + "label": "ghidra_BFC0C298", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C2C0", + "label": "ghidra_BFC0C2C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C2E8", + "label": "ghidra_BFC0C2E8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C318", + "label": "ghidra_BFC0C318", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C354", + "label": "ghidra_BFC0C354", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C580", + "label": "ghidra_BFC0C580", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C720", + "label": "ghidra_BFC0C720", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C830", + "label": "ghidra_BFC0C830", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C890", + "label": "ghidra_BFC0C890", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C9CC", + "label": "ghidra_BFC0C9CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0CAB4", + "label": "ghidra_BFC0CAB4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0CC68", + "label": "ghidra_BFC0CC68", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0CD80", + "label": "ghidra_BFC0CD80", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0CF84", + "label": "ghidra_BFC0CF84", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D0CC", + "label": "ghidra_BFC0D0CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D11C", + "label": "ghidra_BFC0D11C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D34C", + "label": "ghidra_BFC0D34C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D4CC", + "label": "ghidra_BFC0D4CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D570", + "label": "ghidra_BFC0D570", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D600", + "label": "ghidra_BFC0D600", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D640", + "label": "ghidra_BFC0D640", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D664", + "label": "ghidra_BFC0D664", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D688", + "label": "ghidra_BFC0D688", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D72C", + "label": "ghidra_BFC0D72C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D7BC", + "label": "ghidra_BFC0D7BC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D850", + "label": "ghidra_BFC0D850", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D880", + "label": "ghidra_BFC0D880", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D890", + "label": "ghidra_BFC0D890", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D8A0", + "label": "ghidra_BFC0D8A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D8B0", + "label": "ghidra_BFC0D8B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D8C0", + "label": "ghidra_BFC0D8C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D8D0", + "label": "ghidra_BFC0D8D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D950", + "label": "ghidra_BFC0D950", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D960", + "label": "ghidra_BFC0D960", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D970", + "label": "ghidra_BFC0D970", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D980", + "label": "ghidra_BFC0D980", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D990", + "label": "ghidra_BFC0D990", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9A0", + "label": "ghidra_BFC0D9A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9B0", + "label": "ghidra_BFC0D9B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9C0", + "label": "ghidra_BFC0D9C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9D0", + "label": "ghidra_BFC0D9D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9E0", + "label": "ghidra_BFC0D9E0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9F0", + "label": "ghidra_BFC0D9F0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA00", + "label": "ghidra_BFC0DA00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA10", + "label": "ghidra_BFC0DA10", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA20", + "label": "ghidra_BFC0DA20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA30", + "label": "ghidra_BFC0DA30", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA40", + "label": "ghidra_BFC0DA40", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA50", + "label": "ghidra_BFC0DA50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA60", + "label": "ghidra_BFC0DA60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAA0", + "label": "ghidra_BFC0DAA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAB0", + "label": "ghidra_BFC0DAB0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAC0", + "label": "ghidra_BFC0DAC0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAD0", + "label": "ghidra_BFC0DAD0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAE0", + "label": "ghidra_BFC0DAE0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAF0", + "label": "ghidra_BFC0DAF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB00", + "label": "ghidra_BFC0DB00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB10", + "label": "ghidra_BFC0DB10", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB20", + "label": "ghidra_BFC0DB20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB30", + "label": "ghidra_BFC0DB30", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB40", + "label": "ghidra_BFC0DB40", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB50", + "label": "ghidra_BFC0DB50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB60", + "label": "ghidra_BFC0DB60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB70", + "label": "ghidra_BFC0DB70", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB80", + "label": "ghidra_BFC0DB80", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB90", + "label": "ghidra_BFC0DB90", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBA0", + "label": "ghidra_BFC0DBA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBB0", + "label": "ghidra_BFC0DBB0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBC0", + "label": "ghidra_BFC0DBC0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBD0", + "label": "ghidra_BFC0DBD0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBE0", + "label": "ghidra_BFC0DBE0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBF0", + "label": "ghidra_BFC0DBF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC00", + "label": "ghidra_BFC0DC00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC10", + "label": "ghidra_BFC0DC10", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC20", + "label": "ghidra_BFC0DC20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC30", + "label": "ghidra_BFC0DC30", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC40", + "label": "ghidra_BFC0DC40", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC50", + "label": "ghidra_BFC0DC50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC10C74", + "label": "ghidra_BFC10C74", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11008", + "label": "ghidra_BFC11008", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11294", + "label": "ghidra_BFC11294", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC112F4", + "label": "ghidra_BFC112F4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11358", + "label": "ghidra_BFC11358", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC113BC", + "label": "ghidra_BFC113BC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11420", + "label": "ghidra_BFC11420", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11458", + "label": "ghidra_BFC11458", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11490", + "label": "ghidra_BFC11490", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC114C8", + "label": "ghidra_BFC114C8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11500", + "label": "ghidra_BFC11500", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11620", + "label": "ghidra_BFC11620", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC1188C", + "label": "ghidra_BFC1188C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11B18", + "label": "ghidra_BFC11B18", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11C8C", + "label": "ghidra_BFC11C8C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11F58", + "label": "ghidra_BFC11F58", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC122C0", + "label": "ghidra_BFC122C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12370", + "label": "ghidra_BFC12370", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12408", + "label": "ghidra_BFC12408", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12458", + "label": "ghidra_BFC12458", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12564", + "label": "ghidra_BFC12564", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12628", + "label": "ghidra_BFC12628", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12794", + "label": "ghidra_BFC12794", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12900", + "label": "ghidra_BFC12900", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12998", + "label": "ghidra_BFC12998", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC129FC", + "label": "ghidra_BFC129FC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12AC8", + "label": "ghidra_BFC12AC8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12B00", + "label": "ghidra_BFC12B00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12B60", + "label": "ghidra_BFC12B60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12C08", + "label": "ghidra_BFC12C08", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12CE8", + "label": "ghidra_BFC12CE8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12F6C", + "label": "ghidra_BFC12F6C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12FB4", + "label": "ghidra_BFC12FB4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13070", + "label": "ghidra_BFC13070", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC130AC", + "label": "ghidra_BFC130AC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13178", + "label": "ghidra_BFC13178", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13244", + "label": "ghidra_BFC13244", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13310", + "label": "ghidra_BFC13310", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC133D8", + "label": "ghidra_BFC133D8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC134A4", + "label": "ghidra_BFC134A4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC135AC", + "label": "ghidra_BFC135AC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC137E0", + "label": "ghidra_BFC137E0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13980", + "label": "ghidra_BFC13980", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13AF0", + "label": "ghidra_BFC13AF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13B24", + "label": "ghidra_BFC13B24", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13B6C", + "label": "ghidra_BFC13B6C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13C34", + "label": "ghidra_BFC13C34", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13C64", + "label": "ghidra_BFC13C64", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13E04", + "label": "ghidra_BFC13E04", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13E60", + "label": "ghidra_BFC13E60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13EE8", + "label": "ghidra_BFC13EE8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13F98", + "label": "ghidra_BFC13F98", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC144BC", + "label": "ghidra_BFC144BC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC145C8", + "label": "ghidra_BFC145C8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14690", + "label": "ghidra_BFC14690", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14710", + "label": "ghidra_BFC14710", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14770", + "label": "ghidra_BFC14770", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC147F4", + "label": "ghidra_BFC147F4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14A90", + "label": "ghidra_BFC14A90", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14B00", + "label": "ghidra_BFC14B00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14CF4", + "label": "ghidra_BFC14CF4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC158A8", + "label": "ghidra_BFC158A8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC15B9C", + "label": "ghidra_BFC15B9C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC16170", + "label": "ghidra_BFC16170", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11840", + "label": "dmiss_BFC11840", + "rationale": "dispatch miss: KP2 function, indirect call" + }, + { + "address": "0xBFC11524", + "label": "dmiss_BFC11524", + "rationale": "dispatch miss: KP2 secondary entry point for exception handler, mid-function split of func_00001A00, called during VBlank" + }, + { + "address": "0xBFC11610", + "label": "dmiss_BFC11610", + "rationale": "dispatch miss: KP2 function, tail-call target from 0xBFC11524 exception handler path" + }, + { + "address": "0xBFC14A54", + "label": "dmiss_BFC14A54", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain BUSY-writer at RAM 0x4F54; alias rule RAM=ROM-0xBFC10000+0x500. Reached via JAL inside FUN_bfc147f4; never emitted as standalone entry." + }, + { + "address": "0xBFC158D0", + "label": "dmiss_BFC158D0", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain init slot-0 BUSY writer at RAM 0x5DD0; alias RAM=ROM-0xBFC10000+0x500. Inside FUN_bfc158a8 body." + }, + { + "address": "0xBFC158D8", + "label": "dmiss_BFC158D8", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain init slot-1 BUSY writer at RAM 0x5DD8; alias RAM=ROM-0xBFC10000+0x500. Inside FUN_bfc158a8 body." + }, + { + "address": "0xBFC159F4", + "label": "dmiss_BFC159F4", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain step handler that writes 0x02 OPEN to [0x7568+slot] at RAM 0x5EF4. Indirect-jr target from RAM 0x6c98 jump table; alias RAM=ROM-0xBFC10000+0x500. KEY entry \u00e2\u20ac\u201d without this the gate never clears." + }, + { + "address": "0xBFC15AA8", + "label": "dmiss_BFC15AA8", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain step handler that writes 0x04 OPEN to [0x7568+slot] at RAM 0x5FA8. Indirect-jr target from RAM 0x6c98 jump table; alias RAM=ROM-0xBFC10000+0x500. KEY entry \u00e2\u20ac\u201d paired with 0xBFC159F4." + }, + { + "address": "0xBFC04910", + "label": "dmiss_BFC04910", + "rationale": "Tomba boot dispatch miss after licensed PlayStation screen. Verified in Ghidra/runtime disassembly as a standalone BIOS init routine between 0xBFC048D0 and 0xBFC04ABC, with return at 0xBFC04A98 and table data reference from 0xBFC04554." + }, + { + "address": "0xBFC04850", + "label": "dmiss_BFC04850", + "rationale": "Tomba boot dispatch miss after seeding 0xBFC04910. Verified in Ghidra/runtime disassembly as a standalone BIOS callback-list init routine between 0xBFC0472C and 0xBFC048D0, with return at 0xBFC048C8 and table data reference from 0xBFC04588." + }, + { + "address": "0xBFC079F8", + "label": "dmiss_BFC079F8", + "rationale": "Tomba boot dispatch miss after game callback coverage fixes. Verified in Ghidra as a tiny BIOS table leaf routine (sw a0,0(sp); jr ra; clear v0) referenced by data at 0xBFC04484 and 0xBFC0E30C." + }, + { + "address": "0xBFC15F1C", + "label": "dmiss_BFC15F1C", + "rationale": "dirty_ram hot: SIO/PAD interrupt handler entry point (RAM 0x641C), called via jalr. 10852+ hits/frame across internal branch targets (0x641C, 0x6430, 0x6444, 0x6458, 0x650C, 0x6514, 0x658C, 0x6594). Seeding entry only; CFG discovers internal labels." + }, + { + "address": "0xBFC0193C", + "label": "cache_flush_continuation", + "rationale": "Jump target of cache-flush jr k0 at BFC01938" + }, + { + "address": "0xBFC01E24", + "label": "SysInitMemory", + "rationale": "Dispatch miss during boot \u00e2\u20ac\u201d kernel init function" + }, + { + "address": "0xBFC02200", + "label": "jal_BFC02200", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC02918", + "label": "card_copy_dispatch_miss_2026_05_05", + "rationale": "Runtime dispatch miss: 8 hits during YES/NO COPY confirm execution. BANDAID per user permission." + }, + { + "address": "0xBFC03FC0", + "label": "jal_BFC03FC0", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC06FDC", + "label": "indirect_BFC06FDC", + "rationale": "Reached via indirect dispatch during boot" + }, + { + "address": "0xBFC09890", + "label": "card_cmd_is_not_read", + "rationale": "3-instruction predicate function: xori $v0,$a0,0x52; jr $ra; sltu $v0,$0,$v0. Returns ($a0 != 0x52 READ). Sibling of 0xBFC0989C." + }, + { + "address": "0xBFC0989C", + "label": "card_cmd_is_not_write_2026_05_09", + "rationale": "3-instruction predicate function: xori $v0,$a0,0x51; jr $ra; sltu $v0,$0,$v0. Returns ($a0 != 0x51 WRITE). Sibling of 0xBFC09890. Discovery missed it because it sits between 0xBFC09898 (end of 0xBFC09890) and 0xBFC098A8 (next discovered fn). Called via JALR at 0xBFC09788 in func_1FC09598 \u00e2\u20ac\u201d without this seed, JALR dispatches to unknown, write predicate returns junk via uninitialized v0, BIOS COPY silently no-ops the SIO write protocol (60 misses per copy, no 0x57 ever sent). Earlier bisect (2026-05-05) said this crashed; that was before the predicate-init fix and dispatch-miss class fixes. Retry 2026-05-09." + }, + { + "address": "0xBFC0BA8C", + "label": "bios_BA8C", + "rationale": "BIOS boot code function. Dispatch miss from shell input path." + }, + { + "address": "0xBFC0C1DC", + "label": "bios_C1DC", + "rationale": "BIOS boot code function. Dispatch miss from shell input path." + }, + { + "address": "0xBFC0DA70", + "label": "jal_BFC0DA70", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC0DA80", + "label": "jal_BFC0DA80", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC0DA90", + "label": "jal_BFC0DA90", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC10000", + "label": "kernel_trampoline_0", + "rationale": "Trampoline to FileOpen (0x598)" + }, + { + "address": "0xBFC10010", + "label": "kernel_trampoline_1", + "rationale": "Trampoline to FileRead (0x5C4)" + }, + { + "address": "0xBFC10020", + "label": "kernel_trampoline_2", + "rationale": "Trampoline to FileClose (0x5E0)" + }, + { + "address": "0xBFC10030", + "label": "kernel_trampoline_3", + "rationale": "Trampoline to 0x600" + }, + { + "address": "0xBFC10040", + "label": "kernel_memcpy", + "rationale": "ROM-to-RAM and RAM clear routine" + }, + { + "address": "0xBFC10098", + "label": "kernel_bzero", + "rationale": "RAM clear routine (to 0xBFC100BC)" + }, + { + "address": "0xBFC100C4", + "label": "a0_vector_dispatch", + "rationale": "A0 function table dispatch" + }, + { + "address": "0xBFC100E0", + "label": "b0_vector_dispatch", + "rationale": "B0 function table dispatch" + }, + { + "address": "0xBFC10100", + "label": "c0_vector_dispatch", + "rationale": "C0 function table dispatch" + }, + { + "address": "0xBFC10120", + "label": "kernel_get_cause", + "rationale": "mfc0 v0,Cause; jr ra" + }, + { + "address": "0xBFC10130", + "label": "kernel_get_sr", + "rationale": "mfc0 v0,SR; jr ra" + }, + { + "address": "0xBFC10140", + "label": "kernel_set_sr", + "rationale": "mtc0 a0,SR; jr ra" + }, + { + "address": "0xBFC10150", + "label": "kernel_syscall", + "rationale": "syscall; jr ra" + }, + { + "address": "0xBFC1015C", + "label": "kernel_get_c0_table", + "rationale": "Returns C0 table pointer" + }, + { + "address": "0xBFC10168", + "label": "kernel_get_b0_table", + "rationale": "Returns B0 table pointer" + }, + { + "address": "0xBFC10174", + "label": "kernel_BFC10174", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10780", + "label": "rmiss_BFC10780", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC109A0", + "label": "kernel_BFC109A0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC109B0", + "label": "kernel_BFC109B0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10A20", + "label": "kernel_BFC10A20", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10A2C", + "label": "kernel_BFC10A2C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10A40", + "label": "kernel_BFC10A40", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10B30", + "label": "rmiss_BFC10B30", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC10BB8", + "label": "kernel_BFC10BB8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10BE0", + "label": "kernel_BFC10BE0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10BE4", + "label": "rmiss_BFC10BE4", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC10C3C", + "label": "kernel_BFC10C3C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10F08", + "label": "kernel_BFC10F08", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10F20", + "label": "kernel_BFC10F20", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10F44", + "label": "kernel_BFC10F44", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC110D8", + "label": "kernel_BFC110D8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC110F8", + "label": "kernel_BFC110F8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11114", + "label": "kernel_BFC11114", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC111AC", + "label": "kernel_BFC111AC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC111C4", + "label": "kernel_BFC111C4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC111E4", + "label": "kernel_BFC111E4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1121C", + "label": "kernel_BFC1121C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11228", + "label": "kernel_BFC11228", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1125C", + "label": "kernel_BFC1125C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11274", + "label": "kernel_BFC11274", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1152C", + "label": "rmiss_BFC1152C", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC11560", + "label": "rmiss_BFC11560", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC115E8", + "label": "rmiss_BFC115E8", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC115FC", + "label": "rmiss_BFC115FC", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC11644", + "label": "kernel_BFC11644", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1175C", + "label": "kernel_BFC1175C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11800", + "label": "kernel_BFC11800", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11870", + "label": "kernel_BFC11870", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1191C", + "label": "kernel_BFC1191C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11944", + "label": "kernel_BFC11944", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11980", + "label": "kernel_BFC11980", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11998", + "label": "kernel_BFC11998", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC119C8", + "label": "kernel_BFC119C8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11A04", + "label": "kernel_BFC11A04", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11A10", + "label": "kernel_BFC11A10", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11A4C", + "label": "kernel_BFC11A4C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11A88", + "label": "kernel_BFC11A88", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11AFC", + "label": "kernel_BFC11AFC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11BA8", + "label": "kernel_BFC11BA8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11BD4", + "label": "kernel_BFC11BD4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11C14", + "label": "kernel_BFC11C14", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11C24", + "label": "kernel_BFC11C24", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11C50", + "label": "kernel_BFC11C50", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11C68", + "label": "kernel_BFC11C68", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC12224", + "label": "kernel_BFC12224", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1228C", + "label": "kernel_BFC1228C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC122A0", + "label": "kernel_BFC122A0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC129EC", + "label": "kernel_BFC129EC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC12BC8", + "label": "kernel_BFC12BC8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC12BE0", + "label": "kernel_BFC12BE0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC12BE4", + "label": "rmiss_BFC12BE4", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC12EA4", + "label": "kernel_BFC12EA4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1302C", + "label": "kernel_BFC1302C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC130A4", + "label": "kernel_BFC130A4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13570", + "label": "kernel_BFC13570", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1372C", + "label": "kernel_BFC1372C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1376C", + "label": "rmiss_BFC1376C", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC137B4", + "label": "indirect_BFC137B4", + "rationale": "Reached via indirect dispatch during boot (kernel relocated)" + }, + { + "address": "0xBFC137BC", + "label": "rmiss_BFC137BC", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC137D0", + "label": "kernel_ram_function", + "rationale": "Dispatch miss at 0xBFC137D0 (phys 0x00003CD0) \u00e2\u20ac\u201d 3 misses. Kernel RAM function." + }, + { + "address": "0xBFC138AC", + "label": "kernel_BFC138AC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1395C", + "label": "kernel_BFC1395C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13968", + "label": "kernel_BFC13968", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13A04", + "label": "kernel_BFC13A04", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13ED0", + "label": "kernel_BFC13ED0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13F5C", + "label": "kernel_BFC13F5C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1454C", + "label": "kernel_BFC1454C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14588", + "label": "kernel_BFC14588", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14594", + "label": "kernel_BFC14594", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14670", + "label": "kernel_BFC14670", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14754", + "label": "kernel_BFC14754", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14764", + "label": "kernel_BFC14764", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1483C", + "label": "kernel_BFC1483C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1484C", + "label": "kernel_BFC1484C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14864", + "label": "kernel_BFC14864", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1486C", + "label": "kernel_BFC1486C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15188", + "label": "kernel_BFC15188", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC151E8", + "label": "kernel_56E8", + "rationale": "Kernel function at RAM 0x56E8. Dispatch miss from shell input path." + }, + { + "address": "0xBFC15268", + "label": "kernel_5768", + "rationale": "Kernel function at RAM 0x5768. Cascade from input path seeds." + }, + { + "address": "0xBFC1529C", + "label": "kernel_579C", + "rationale": "Kernel function at RAM 0x579C. Cascade from input path." + }, + { + "address": "0xBFC15334", + "label": "kernel_5834", + "rationale": "Kernel function at RAM 0x5834. Cascade." + }, + { + "address": "0xBFC15370", + "label": "kernel_5870", + "rationale": "Kernel (CARD driver) function. Cascade." + }, + { + "address": "0xBFC153B4", + "label": "kernel_58B4", + "rationale": "Kernel (CARD driver) function at RAM 0x58B4. Cascade." + }, + { + "address": "0xBFC153E8", + "label": "kernel_58E8", + "rationale": "Kernel (CARD driver) function at RAM 0x58E8. Cascade." + }, + { + "address": "0xBFC15418", + "label": "kernel_5918", + "rationale": "Kernel (CARD driver) leaf function at RAM 0x5918. Cascade." + }, + { + "address": "0xBFC15454", + "label": "kernel_5954", + "rationale": "CARD driver cascade." + }, + { + "address": "0xBFC15490", + "label": "kernel_5990", + "rationale": "CARD driver cascade." + }, + { + "address": "0xBFC154CC", + "label": "kernel_59CC", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15500", + "label": "kernel_5A00", + "rationale": "CARD driver cascade." + }, + { + "address": "0xBFC15508", + "label": "kernel_5A08", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15544", + "label": "kernel_5A44", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15558", + "label": "kernel_5A58", + "rationale": "CARD read chain state 12 (data-byte-store): SIO_RX -> a1[0x7F], XOR-updates checksum at 0x7560+slot*4, returns v0=0. Targeted by jump table at RAM 0x6c98 entry 12." + }, + { + "address": "0xBFC15580", + "label": "kernel_5A80", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC155B0", + "label": "kernel_5AB0", + "rationale": "CARD read chain state 13 (data-loop completion): reads byte vs checksum at 0x7560+slot*4, polls SIO_STAT for RX_RDY, reads END byte; v0=1 if 'G' else v0=-1. Targeted by jump table at RAM 0x6c98 entry 13." + }, + { + "address": "0xBFC155BC", + "label": "kernel_5ABC", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC155F8", + "label": "kernel_5AF8", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15634", + "label": "kernel_5B34", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15650", + "label": "kernel_5B50", + "rationale": "Kernel function. Cascade from input path." + }, + { + "address": "0xBFC15654", + "label": "kernel_5B54", + "rationale": "Kernel function. Cascade from input path." + }, + { + "address": "0xBFC15664", + "label": "kernel_BFC15664", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC156A4", + "label": "kernel_5BA4", + "rationale": "Kernel function at RAM 0x5BA4. Dispatch miss from shell input path." + }, + { + "address": "0xBFC15724", + "label": "kernel_5C24", + "rationale": "Kernel function at RAM 0x5C24. Cascade from input path seeds." + }, + { + "address": "0xBFC15758", + "label": "kernel_5C58", + "rationale": "Kernel function at RAM 0x5C58. Cascade." + }, + { + "address": "0xBFC15848", + "label": "kernel_5D48", + "rationale": "Kernel (CARD driver) function. Cascade." + }, + { + "address": "0xBFC15894", + "label": "kernel_5D94", + "rationale": "Kernel function. Cascade from input path." + }, + { + "address": "0xBFC15898", + "label": "kernel_5D98", + "rationale": "Kernel (CARD driver) function. Cascade." + }, + { + "address": "0xBFC15930", + "label": "kernel_BFC15930", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15950", + "label": "kernel_BFC15950", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15998", + "label": "kernel_BFC15998", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15A04", + "label": "kernel_BFC15A04", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15A4C", + "label": "kernel_BFC15A4C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15AB8", + "label": "kernel_BFC15AB8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15AF0", + "label": "kernel_BFC15AF0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15B5C", + "label": "kernel_BFC15B5C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15B70", + "label": "kernel_BFC15B70", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15CC4", + "label": "kernel_BFC15CC4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15E90", + "label": "kernel_BFC15E90", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15EBC", + "label": "kernel_BFC15EBC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15F08", + "label": "kernel_BFC15F08", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1609C", + "label": "kernel_BFC1609C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC16690", + "label": "kernel_BFC16690", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166A0", + "label": "kernel_BFC166A0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166B0", + "label": "kernel_BFC166B0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166C0", + "label": "kernel_BFC166C0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166D0", + "label": "kernel_BFC166D0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166E0", + "label": "kernel_BFC166E0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166F0", + "label": "kernel_BFC166F0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC16700", + "label": "kernel_BFC16700", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC16710", + "label": "kernel_BFC16710", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC16720", + "label": "kernel_BFC16720", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC02934", + "rationale": "kernel jump-table target: A0:0F (unresolved (see psx-spx A0 table)) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03200", + "rationale": "kernel jump-table target: A0:16 (strncat) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC034D0", + "rationale": "kernel jump-table target: A0:1C (atob helper) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03514", + "rationale": "kernel jump-table target: A0:1D (strtol-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0357C", + "rationale": "kernel jump-table target: A0:1E (strtol-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC035C0", + "rationale": "kernel jump-table target: A0:1F (abs) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03628", + "rationale": "kernel jump-table target: A0:20 (labs) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03694", + "rationale": "kernel jump-table target: A0:21 (atoi) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC036FC", + "rationale": "kernel jump-table target: A0:22 (atol) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03894", + "rationale": "kernel jump-table target: A0:24 (strtol-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC01B08", + "rationale": "kernel jump-table target: A0:29 (bcmp) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC02B8C", + "rationale": "kernel jump-table target: A0:2B (memset) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC02BC8", + "rationale": "kernel jump-table target: A0:2C (memmove) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC02C50", + "rationale": "kernel jump-table target: A0:2D (memcmp) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC02230", + "rationale": "kernel jump-table target: A0:30 (rand-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC021A0", + "rationale": "kernel jump-table target: A0:37 (rand-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D8E0", + "rationale": "kernel jump-table target: A0:40 (SystemErrorUnresolvedException) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC04120", + "rationale": "kernel jump-table target: A0:4D (ChangeClearRCnt-adjacent) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D8F0", + "rationale": "kernel jump-table target: A0:4F (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D900", + "rationale": "kernel jump-table target: A0:50 (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D910", + "rationale": "kernel jump-table target: A0:52 (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D920", + "rationale": "kernel jump-table target: A0:53 (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0B0C4", + "rationale": "kernel jump-table target: A0:6F (card/dev service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC04AA0", + "rationale": "kernel jump-table target: A0:94 (card-adjacent service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D930", + "rationale": "kernel jump-table target: A0:9A (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D940", + "rationale": "kernel jump-table target: A0:9B (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC06750", + "rationale": "kernel jump-table target: A0:9C (kernel service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC06728", + "rationale": "kernel jump-table target: A0:9D (kernel service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC050DC", + "rationale": "kernel jump-table target: A0:9E (kernel service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D4F0", + "rationale": "kernel jump-table target: A0:B4 (GetSystemInfo-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + } + ], + "excluded": [ + { + "address": "0xBFC00100", + "label": "utlb_miss_bev1", + "rationale": "R3000 UTLB-miss vector; PS1 has no TLB; never executed" + } + ] +} \ No newline at end of file diff --git a/recompiler/seeds/phase2_ghidra_seeds_SCPH5552.json b/recompiler/seeds/phase2_ghidra_seeds_SCPH5552.json new file mode 100644 index 00000000..35f7ba8b --- /dev/null +++ b/recompiler/seeds/phase2_ghidra_seeds_SCPH5552.json @@ -0,0 +1,2654 @@ +{ + "schema": "psxrecomp phase2 seeds", + "source": "phase2_ghidra_seeds.json filtered vs SCPH5552 ROM (64-byte window)", + "seed_count": 534, + "seeds": [ + { + "address": "0xBFC00000", + "label": "reset_vector", + "rationale": "MIPS R3000A reset vector" + }, + { + "address": "0xBFC00180", + "label": "general_exception_bev1", + "rationale": "BEV=1 general exception vector" + }, + { + "address": "0xBFC00420", + "label": "ghidra_BFC00420", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00678", + "label": "ghidra_BFC00678", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC006A8", + "label": "ghidra_BFC006A8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC006D8", + "label": "ghidra_BFC006D8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00778", + "label": "ghidra_BFC00778", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00810", + "label": "ghidra_BFC00810", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC008A0", + "label": "ghidra_BFC008A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00944", + "label": "ghidra_BFC00944", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00B7C", + "label": "ghidra_BFC00B7C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC00DA0", + "label": "ghidra_BFC00DA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC018E0", + "label": "ghidra_BFC018E0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01920", + "label": "ghidra_BFC01920", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01A60", + "label": "ghidra_BFC01A60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01A90", + "label": "ghidra_BFC01A90", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01ACC", + "label": "ghidra_BFC01ACC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01B80", + "label": "ghidra_BFC01B80", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01C8C", + "label": "ghidra_BFC01C8C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC01E5C", + "label": "ghidra_BFC01E5C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC020F0", + "label": "ghidra_BFC020F0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02104", + "label": "ghidra_BFC02104", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02240", + "label": "ghidra_BFC02240", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0227C", + "label": "ghidra_BFC0227C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC022C0", + "label": "ghidra_BFC022C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02324", + "label": "ghidra_BFC02324", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02590", + "label": "ghidra_BFC02590", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC025B0", + "label": "ghidra_BFC025B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02738", + "label": "ghidra_BFC02738", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02950", + "label": "ghidra_BFC02950", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02AF0", + "label": "ghidra_BFC02AF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02B10", + "label": "ghidra_BFC02B10", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02B50", + "label": "ghidra_BFC02B50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02CC0", + "label": "ghidra_BFC02CC0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02D20", + "label": "ghidra_BFC02D20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02DAC", + "label": "ghidra_BFC02DAC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02EA0", + "label": "ghidra_BFC02EA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02EDC", + "label": "ghidra_BFC02EDC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC02F20", + "label": "ghidra_BFC02F20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03098", + "label": "ghidra_BFC03098", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03104", + "label": "ghidra_BFC03104", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03190", + "label": "ghidra_BFC03190", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03288", + "label": "ghidra_BFC03288", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03310", + "label": "ghidra_BFC03310", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC033C8", + "label": "ghidra_BFC033C8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03418", + "label": "ghidra_BFC03418", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03494", + "label": "ghidra_BFC03494", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03764", + "label": "ghidra_BFC03764", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03910", + "label": "ghidra_BFC03910", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03968", + "label": "ghidra_BFC03968", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03978", + "label": "ghidra_BFC03978", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03990", + "label": "ghidra_BFC03990", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC039B0", + "label": "ghidra_BFC039B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03A18", + "label": "ghidra_BFC03A18", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03AA4", + "label": "ghidra_BFC03AA4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03C90", + "label": "ghidra_BFC03C90", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03CF0", + "label": "ghidra_BFC03CF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03D80", + "label": "ghidra_BFC03D80", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03EA0", + "label": "ghidra_BFC03EA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03F8C", + "label": "ghidra_BFC03F8C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03FAC", + "label": "ghidra_BFC03FAC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC03FE0", + "label": "ghidra_BFC03FE0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0403C", + "label": "ghidra_BFC0403C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC040EC", + "label": "ghidra_BFC040EC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04138", + "label": "ghidra_BFC04138", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04260", + "label": "ghidra_BFC04260", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC042A0", + "label": "ghidra_BFC042A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC042D0", + "label": "ghidra_BFC042D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04610", + "label": "ghidra_BFC04610", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04678", + "label": "ghidra_BFC04678", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0472C", + "label": "ghidra_BFC0472C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC048D0", + "label": "ghidra_BFC048D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04ABC", + "label": "ghidra_BFC04ABC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04BC4", + "label": "ghidra_BFC04BC4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04C38", + "label": "ghidra_BFC04C38", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04D50", + "label": "ghidra_BFC04D50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04DEC", + "label": "ghidra_BFC04DEC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC04FBC", + "label": "ghidra_BFC04FBC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0506C", + "label": "ghidra_BFC0506C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC050A4", + "label": "ghidra_BFC050A4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC050FC", + "label": "ghidra_BFC050FC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05120", + "label": "ghidra_BFC05120", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05194", + "label": "ghidra_BFC05194", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05558", + "label": "ghidra_BFC05558", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC057B0", + "label": "ghidra_BFC057B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0593C", + "label": "ghidra_BFC0593C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05A44", + "label": "ghidra_BFC05A44", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05C18", + "label": "ghidra_BFC05C18", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05C50", + "label": "ghidra_BFC05C50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05D04", + "label": "ghidra_BFC05D04", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05DB0", + "label": "ghidra_BFC05DB0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05DF4", + "label": "ghidra_BFC05DF4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05E58", + "label": "ghidra_BFC05E58", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC05F08", + "label": "ghidra_BFC05F08", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06010", + "label": "ghidra_BFC06010", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06038", + "label": "ghidra_BFC06038", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC060DC", + "label": "ghidra_BFC060DC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0615C", + "label": "ghidra_BFC0615C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC061A0", + "label": "ghidra_BFC061A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC061D4", + "label": "ghidra_BFC061D4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06218", + "label": "ghidra_BFC06218", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06288", + "label": "ghidra_BFC06288", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC062CC", + "label": "ghidra_BFC062CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC063CC", + "label": "ghidra_BFC063CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC064D8", + "label": "ghidra_BFC064D8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06548", + "label": "ghidra_BFC06548", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC065C0", + "label": "ghidra_BFC065C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0661C", + "label": "ghidra_BFC0661C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06680", + "label": "ghidra_BFC06680", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06700", + "label": "ghidra_BFC06700", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06784", + "label": "ghidra_BFC06784", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC067E8", + "label": "ghidra_BFC067E8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06CA4", + "label": "ghidra_BFC06CA4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06EC4", + "label": "ghidra_BFC06EC4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06F28", + "label": "ghidra_BFC06F28", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06FA4", + "label": "ghidra_BFC06FA4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC06FF0", + "label": "ghidra_BFC06FF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0703C", + "label": "ghidra_BFC0703C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC070AC", + "label": "ghidra_BFC070AC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0711C", + "label": "ghidra_BFC0711C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07148", + "label": "ghidra_BFC07148", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC071A0", + "label": "ghidra_BFC071A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC072B8", + "label": "ghidra_BFC072B8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07330", + "label": "ghidra_BFC07330", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07370", + "label": "ghidra_BFC07370", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC073A0", + "label": "ghidra_BFC073A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07410", + "label": "ghidra_BFC07410", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07664", + "label": "ghidra_BFC07664", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07700", + "label": "ghidra_BFC07700", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC078A4", + "label": "ghidra_BFC078A4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07A04", + "label": "ghidra_BFC07A04", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07B88", + "label": "ghidra_BFC07B88", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07C1C", + "label": "ghidra_BFC07C1C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC07F28", + "label": "ghidra_BFC07F28", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08020", + "label": "ghidra_BFC08020", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08098", + "label": "ghidra_BFC08098", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC081FC", + "label": "ghidra_BFC081FC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08354", + "label": "ghidra_BFC08354", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC083CC", + "label": "ghidra_BFC083CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC085B0", + "label": "ghidra_BFC085B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC085D4", + "label": "ghidra_BFC085D4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC086B0", + "label": "ghidra_BFC086B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC086EC", + "label": "ghidra_BFC086EC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08720", + "label": "ghidra_BFC08720", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC087C4", + "label": "ghidra_BFC087C4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0884C", + "label": "ghidra_BFC0884C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC088A0", + "label": "ghidra_BFC088A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0895C", + "label": "ghidra_BFC0895C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC089C0", + "label": "ghidra_BFC089C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08B3C", + "label": "ghidra_BFC08B3C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC08FFC", + "label": "ghidra_BFC08FFC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09144", + "label": "ghidra_BFC09144", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC091CC", + "label": "ghidra_BFC091CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC092EC", + "label": "ghidra_BFC092EC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09540", + "label": "ghidra_BFC09540", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09598", + "label": "ghidra_BFC09598", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09720", + "label": "ghidra_BFC09720", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC098A8", + "label": "ghidra_BFC098A8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09914", + "label": "ghidra_BFC09914", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0994C", + "label": "ghidra_BFC0994C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0996C", + "label": "ghidra_BFC0996C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09CDC", + "label": "ghidra_BFC09CDC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC09D3C", + "label": "ghidra_BFC09D3C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A080", + "label": "ghidra_BFC0A080", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A3D4", + "label": "ghidra_BFC0A3D4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A43C", + "label": "ghidra_BFC0A43C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A4B4", + "label": "ghidra_BFC0A4B4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A5E4", + "label": "ghidra_BFC0A5E4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A754", + "label": "ghidra_BFC0A754", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0A8BC", + "label": "ghidra_BFC0A8BC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0AB60", + "label": "ghidra_BFC0AB60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0AD08", + "label": "ghidra_BFC0AD08", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B0D0", + "label": "ghidra_BFC0B0D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B170", + "label": "ghidra_BFC0B170", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B394", + "label": "ghidra_BFC0B394", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B54C", + "label": "ghidra_BFC0B54C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B5A0", + "label": "ghidra_BFC0B5A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B5F4", + "label": "ghidra_BFC0B5F4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0B648", + "label": "ghidra_BFC0B648", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0BFF0", + "label": "ghidra_BFC0BFF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C048", + "label": "ghidra_BFC0C048", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C0DC", + "label": "ghidra_BFC0C0DC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C140", + "label": "ghidra_BFC0C140", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C1FC", + "label": "ghidra_BFC0C1FC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C220", + "label": "ghidra_BFC0C220", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C248", + "label": "ghidra_BFC0C248", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C270", + "label": "ghidra_BFC0C270", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C298", + "label": "ghidra_BFC0C298", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C2C0", + "label": "ghidra_BFC0C2C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C2E8", + "label": "ghidra_BFC0C2E8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C318", + "label": "ghidra_BFC0C318", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C354", + "label": "ghidra_BFC0C354", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C580", + "label": "ghidra_BFC0C580", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C720", + "label": "ghidra_BFC0C720", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C830", + "label": "ghidra_BFC0C830", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C890", + "label": "ghidra_BFC0C890", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0C9CC", + "label": "ghidra_BFC0C9CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0CAB4", + "label": "ghidra_BFC0CAB4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0CC68", + "label": "ghidra_BFC0CC68", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0CD80", + "label": "ghidra_BFC0CD80", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0CF84", + "label": "ghidra_BFC0CF84", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D0CC", + "label": "ghidra_BFC0D0CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D11C", + "label": "ghidra_BFC0D11C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D34C", + "label": "ghidra_BFC0D34C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D4CC", + "label": "ghidra_BFC0D4CC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D570", + "label": "ghidra_BFC0D570", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D600", + "label": "ghidra_BFC0D600", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D640", + "label": "ghidra_BFC0D640", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D664", + "label": "ghidra_BFC0D664", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D688", + "label": "ghidra_BFC0D688", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D72C", + "label": "ghidra_BFC0D72C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D7BC", + "label": "ghidra_BFC0D7BC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D850", + "label": "ghidra_BFC0D850", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D880", + "label": "ghidra_BFC0D880", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D890", + "label": "ghidra_BFC0D890", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D8A0", + "label": "ghidra_BFC0D8A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D8B0", + "label": "ghidra_BFC0D8B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D8C0", + "label": "ghidra_BFC0D8C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D8D0", + "label": "ghidra_BFC0D8D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D950", + "label": "ghidra_BFC0D950", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D960", + "label": "ghidra_BFC0D960", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D970", + "label": "ghidra_BFC0D970", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D980", + "label": "ghidra_BFC0D980", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D990", + "label": "ghidra_BFC0D990", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9A0", + "label": "ghidra_BFC0D9A0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9B0", + "label": "ghidra_BFC0D9B0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9C0", + "label": "ghidra_BFC0D9C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9D0", + "label": "ghidra_BFC0D9D0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9E0", + "label": "ghidra_BFC0D9E0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0D9F0", + "label": "ghidra_BFC0D9F0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA00", + "label": "ghidra_BFC0DA00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA10", + "label": "ghidra_BFC0DA10", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA20", + "label": "ghidra_BFC0DA20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA30", + "label": "ghidra_BFC0DA30", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA40", + "label": "ghidra_BFC0DA40", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA50", + "label": "ghidra_BFC0DA50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DA60", + "label": "ghidra_BFC0DA60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAA0", + "label": "ghidra_BFC0DAA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAB0", + "label": "ghidra_BFC0DAB0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAC0", + "label": "ghidra_BFC0DAC0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAD0", + "label": "ghidra_BFC0DAD0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAE0", + "label": "ghidra_BFC0DAE0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DAF0", + "label": "ghidra_BFC0DAF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB00", + "label": "ghidra_BFC0DB00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB10", + "label": "ghidra_BFC0DB10", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB20", + "label": "ghidra_BFC0DB20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB30", + "label": "ghidra_BFC0DB30", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB40", + "label": "ghidra_BFC0DB40", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB50", + "label": "ghidra_BFC0DB50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB60", + "label": "ghidra_BFC0DB60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB70", + "label": "ghidra_BFC0DB70", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB80", + "label": "ghidra_BFC0DB80", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DB90", + "label": "ghidra_BFC0DB90", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBA0", + "label": "ghidra_BFC0DBA0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBB0", + "label": "ghidra_BFC0DBB0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBC0", + "label": "ghidra_BFC0DBC0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBD0", + "label": "ghidra_BFC0DBD0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBE0", + "label": "ghidra_BFC0DBE0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DBF0", + "label": "ghidra_BFC0DBF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC00", + "label": "ghidra_BFC0DC00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC10", + "label": "ghidra_BFC0DC10", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC20", + "label": "ghidra_BFC0DC20", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC30", + "label": "ghidra_BFC0DC30", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC40", + "label": "ghidra_BFC0DC40", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC0DC50", + "label": "ghidra_BFC0DC50", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC10C74", + "label": "ghidra_BFC10C74", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11008", + "label": "ghidra_BFC11008", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11294", + "label": "ghidra_BFC11294", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC112F4", + "label": "ghidra_BFC112F4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11358", + "label": "ghidra_BFC11358", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC113BC", + "label": "ghidra_BFC113BC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11420", + "label": "ghidra_BFC11420", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11458", + "label": "ghidra_BFC11458", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11490", + "label": "ghidra_BFC11490", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC114C8", + "label": "ghidra_BFC114C8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11500", + "label": "ghidra_BFC11500", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11620", + "label": "ghidra_BFC11620", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC1188C", + "label": "ghidra_BFC1188C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11B18", + "label": "ghidra_BFC11B18", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11C8C", + "label": "ghidra_BFC11C8C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11F58", + "label": "ghidra_BFC11F58", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC122C0", + "label": "ghidra_BFC122C0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12370", + "label": "ghidra_BFC12370", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12408", + "label": "ghidra_BFC12408", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12458", + "label": "ghidra_BFC12458", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12564", + "label": "ghidra_BFC12564", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12628", + "label": "ghidra_BFC12628", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12794", + "label": "ghidra_BFC12794", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12900", + "label": "ghidra_BFC12900", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12998", + "label": "ghidra_BFC12998", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC129FC", + "label": "ghidra_BFC129FC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12AC8", + "label": "ghidra_BFC12AC8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12B00", + "label": "ghidra_BFC12B00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12B60", + "label": "ghidra_BFC12B60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12C08", + "label": "ghidra_BFC12C08", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12CE8", + "label": "ghidra_BFC12CE8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12F6C", + "label": "ghidra_BFC12F6C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC12FB4", + "label": "ghidra_BFC12FB4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13070", + "label": "ghidra_BFC13070", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC130AC", + "label": "ghidra_BFC130AC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13178", + "label": "ghidra_BFC13178", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13244", + "label": "ghidra_BFC13244", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13310", + "label": "ghidra_BFC13310", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC133D8", + "label": "ghidra_BFC133D8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC134A4", + "label": "ghidra_BFC134A4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC135AC", + "label": "ghidra_BFC135AC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC137E0", + "label": "ghidra_BFC137E0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13980", + "label": "ghidra_BFC13980", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13AF0", + "label": "ghidra_BFC13AF0", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13B24", + "label": "ghidra_BFC13B24", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13B6C", + "label": "ghidra_BFC13B6C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13C34", + "label": "ghidra_BFC13C34", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13C64", + "label": "ghidra_BFC13C64", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13E04", + "label": "ghidra_BFC13E04", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13E60", + "label": "ghidra_BFC13E60", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13EE8", + "label": "ghidra_BFC13EE8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC13F98", + "label": "ghidra_BFC13F98", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC144BC", + "label": "ghidra_BFC144BC", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC145C8", + "label": "ghidra_BFC145C8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14690", + "label": "ghidra_BFC14690", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14710", + "label": "ghidra_BFC14710", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14770", + "label": "ghidra_BFC14770", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC147F4", + "label": "ghidra_BFC147F4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14A90", + "label": "ghidra_BFC14A90", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14B00", + "label": "ghidra_BFC14B00", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC14CF4", + "label": "ghidra_BFC14CF4", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC158A8", + "label": "ghidra_BFC158A8", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC15B9C", + "label": "ghidra_BFC15B9C", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC16170", + "label": "ghidra_BFC16170", + "rationale": "Ghidra function start" + }, + { + "address": "0xBFC11840", + "label": "dmiss_BFC11840", + "rationale": "dispatch miss: KP2 function, indirect call" + }, + { + "address": "0xBFC11524", + "label": "dmiss_BFC11524", + "rationale": "dispatch miss: KP2 secondary entry point for exception handler, mid-function split of func_00001A00, called during VBlank" + }, + { + "address": "0xBFC11610", + "label": "dmiss_BFC11610", + "rationale": "dispatch miss: KP2 function, tail-call target from 0xBFC11524 exception handler path" + }, + { + "address": "0xBFC14A54", + "label": "dmiss_BFC14A54", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain BUSY-writer at RAM 0x4F54; alias rule RAM=ROM-0xBFC10000+0x500. Reached via JAL inside FUN_bfc147f4; never emitted as standalone entry." + }, + { + "address": "0xBFC158D0", + "label": "dmiss_BFC158D0", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain init slot-0 BUSY writer at RAM 0x5DD0; alias RAM=ROM-0xBFC10000+0x500. Inside FUN_bfc158a8 body." + }, + { + "address": "0xBFC158D8", + "label": "dmiss_BFC158D8", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain init slot-1 BUSY writer at RAM 0x5DD8; alias RAM=ROM-0xBFC10000+0x500. Inside FUN_bfc158a8 body." + }, + { + "address": "0xBFC159F4", + "label": "dmiss_BFC159F4", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain step handler that writes 0x02 OPEN to [0x7568+slot] at RAM 0x5EF4. Indirect-jr target from RAM 0x6c98 jump table; alias RAM=ROM-0xBFC10000+0x500. KEY entry \u00e2\u20ac\u201d without this the gate never clears." + }, + { + "address": "0xBFC15AA8", + "label": "dmiss_BFC15AA8", + "rationale": "dispatch miss (Beetle wtrace, docs/CASE_A_AOT_GAP.md): chain step handler that writes 0x04 OPEN to [0x7568+slot] at RAM 0x5FA8. Indirect-jr target from RAM 0x6c98 jump table; alias RAM=ROM-0xBFC10000+0x500. KEY entry \u00e2\u20ac\u201d paired with 0xBFC159F4." + }, + { + "address": "0xBFC04910", + "label": "dmiss_BFC04910", + "rationale": "Tomba boot dispatch miss after licensed PlayStation screen. Verified in Ghidra/runtime disassembly as a standalone BIOS init routine between 0xBFC048D0 and 0xBFC04ABC, with return at 0xBFC04A98 and table data reference from 0xBFC04554." + }, + { + "address": "0xBFC04850", + "label": "dmiss_BFC04850", + "rationale": "Tomba boot dispatch miss after seeding 0xBFC04910. Verified in Ghidra/runtime disassembly as a standalone BIOS callback-list init routine between 0xBFC0472C and 0xBFC048D0, with return at 0xBFC048C8 and table data reference from 0xBFC04588." + }, + { + "address": "0xBFC079F8", + "label": "dmiss_BFC079F8", + "rationale": "Tomba boot dispatch miss after game callback coverage fixes. Verified in Ghidra as a tiny BIOS table leaf routine (sw a0,0(sp); jr ra; clear v0) referenced by data at 0xBFC04484 and 0xBFC0E30C." + }, + { + "address": "0xBFC15F1C", + "label": "dmiss_BFC15F1C", + "rationale": "dirty_ram hot: SIO/PAD interrupt handler entry point (RAM 0x641C), called via jalr. 10852+ hits/frame across internal branch targets (0x641C, 0x6430, 0x6444, 0x6458, 0x650C, 0x6514, 0x658C, 0x6594). Seeding entry only; CFG discovers internal labels." + }, + { + "address": "0xBFC0193C", + "label": "cache_flush_continuation", + "rationale": "Jump target of cache-flush jr k0 at BFC01938" + }, + { + "address": "0xBFC01E24", + "label": "SysInitMemory", + "rationale": "Dispatch miss during boot \u00e2\u20ac\u201d kernel init function" + }, + { + "address": "0xBFC02200", + "label": "jal_BFC02200", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC02918", + "label": "card_copy_dispatch_miss_2026_05_05", + "rationale": "Runtime dispatch miss: 8 hits during YES/NO COPY confirm execution. BANDAID per user permission." + }, + { + "address": "0xBFC03FC0", + "label": "jal_BFC03FC0", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC06FDC", + "label": "indirect_BFC06FDC", + "rationale": "Reached via indirect dispatch during boot" + }, + { + "address": "0xBFC09890", + "label": "card_cmd_is_not_read", + "rationale": "3-instruction predicate function: xori $v0,$a0,0x52; jr $ra; sltu $v0,$0,$v0. Returns ($a0 != 0x52 READ). Sibling of 0xBFC0989C." + }, + { + "address": "0xBFC0989C", + "label": "card_cmd_is_not_write_2026_05_09", + "rationale": "3-instruction predicate function: xori $v0,$a0,0x51; jr $ra; sltu $v0,$0,$v0. Returns ($a0 != 0x51 WRITE). Sibling of 0xBFC09890. Discovery missed it because it sits between 0xBFC09898 (end of 0xBFC09890) and 0xBFC098A8 (next discovered fn). Called via JALR at 0xBFC09788 in func_1FC09598 \u00e2\u20ac\u201d without this seed, JALR dispatches to unknown, write predicate returns junk via uninitialized v0, BIOS COPY silently no-ops the SIO write protocol (60 misses per copy, no 0x57 ever sent). Earlier bisect (2026-05-05) said this crashed; that was before the predicate-init fix and dispatch-miss class fixes. Retry 2026-05-09." + }, + { + "address": "0xBFC0BA8C", + "label": "bios_BA8C", + "rationale": "BIOS boot code function. Dispatch miss from shell input path." + }, + { + "address": "0xBFC0C1DC", + "label": "bios_C1DC", + "rationale": "BIOS boot code function. Dispatch miss from shell input path." + }, + { + "address": "0xBFC0DA70", + "label": "jal_BFC0DA70", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC0DA80", + "label": "jal_BFC0DA80", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC0DA90", + "label": "jal_BFC0DA90", + "rationale": "JAL target from code region" + }, + { + "address": "0xBFC10000", + "label": "kernel_trampoline_0", + "rationale": "Trampoline to FileOpen (0x598)" + }, + { + "address": "0xBFC10010", + "label": "kernel_trampoline_1", + "rationale": "Trampoline to FileRead (0x5C4)" + }, + { + "address": "0xBFC10020", + "label": "kernel_trampoline_2", + "rationale": "Trampoline to FileClose (0x5E0)" + }, + { + "address": "0xBFC10030", + "label": "kernel_trampoline_3", + "rationale": "Trampoline to 0x600" + }, + { + "address": "0xBFC10040", + "label": "kernel_memcpy", + "rationale": "ROM-to-RAM and RAM clear routine" + }, + { + "address": "0xBFC10098", + "label": "kernel_bzero", + "rationale": "RAM clear routine (to 0xBFC100BC)" + }, + { + "address": "0xBFC100C4", + "label": "a0_vector_dispatch", + "rationale": "A0 function table dispatch" + }, + { + "address": "0xBFC100E0", + "label": "b0_vector_dispatch", + "rationale": "B0 function table dispatch" + }, + { + "address": "0xBFC10100", + "label": "c0_vector_dispatch", + "rationale": "C0 function table dispatch" + }, + { + "address": "0xBFC10120", + "label": "kernel_get_cause", + "rationale": "mfc0 v0,Cause; jr ra" + }, + { + "address": "0xBFC10130", + "label": "kernel_get_sr", + "rationale": "mfc0 v0,SR; jr ra" + }, + { + "address": "0xBFC10140", + "label": "kernel_set_sr", + "rationale": "mtc0 a0,SR; jr ra" + }, + { + "address": "0xBFC10150", + "label": "kernel_syscall", + "rationale": "syscall; jr ra" + }, + { + "address": "0xBFC1015C", + "label": "kernel_get_c0_table", + "rationale": "Returns C0 table pointer" + }, + { + "address": "0xBFC10168", + "label": "kernel_get_b0_table", + "rationale": "Returns B0 table pointer" + }, + { + "address": "0xBFC10174", + "label": "kernel_BFC10174", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10780", + "label": "rmiss_BFC10780", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC109A0", + "label": "kernel_BFC109A0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC109B0", + "label": "kernel_BFC109B0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10A20", + "label": "kernel_BFC10A20", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10A2C", + "label": "kernel_BFC10A2C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10A40", + "label": "kernel_BFC10A40", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10B30", + "label": "rmiss_BFC10B30", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC10BB8", + "label": "kernel_BFC10BB8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10BE0", + "label": "kernel_BFC10BE0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10BE4", + "label": "rmiss_BFC10BE4", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC10C3C", + "label": "kernel_BFC10C3C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10F08", + "label": "kernel_BFC10F08", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10F20", + "label": "kernel_BFC10F20", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC10F44", + "label": "kernel_BFC10F44", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC110D8", + "label": "kernel_BFC110D8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC110F8", + "label": "kernel_BFC110F8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11114", + "label": "kernel_BFC11114", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC111AC", + "label": "kernel_BFC111AC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC111C4", + "label": "kernel_BFC111C4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC111E4", + "label": "kernel_BFC111E4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1121C", + "label": "kernel_BFC1121C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11228", + "label": "kernel_BFC11228", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1125C", + "label": "kernel_BFC1125C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11274", + "label": "kernel_BFC11274", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1152C", + "label": "rmiss_BFC1152C", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC11560", + "label": "rmiss_BFC11560", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC115E8", + "label": "rmiss_BFC115E8", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC115FC", + "label": "rmiss_BFC115FC", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC11644", + "label": "kernel_BFC11644", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1175C", + "label": "kernel_BFC1175C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11800", + "label": "kernel_BFC11800", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11870", + "label": "kernel_BFC11870", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1191C", + "label": "kernel_BFC1191C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11944", + "label": "kernel_BFC11944", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11980", + "label": "kernel_BFC11980", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11998", + "label": "kernel_BFC11998", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC119C8", + "label": "kernel_BFC119C8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11A04", + "label": "kernel_BFC11A04", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11A10", + "label": "kernel_BFC11A10", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11A4C", + "label": "kernel_BFC11A4C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11A88", + "label": "kernel_BFC11A88", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11AFC", + "label": "kernel_BFC11AFC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11BA8", + "label": "kernel_BFC11BA8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11BD4", + "label": "kernel_BFC11BD4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11C14", + "label": "kernel_BFC11C14", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11C24", + "label": "kernel_BFC11C24", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11C50", + "label": "kernel_BFC11C50", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC11C68", + "label": "kernel_BFC11C68", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC12224", + "label": "kernel_BFC12224", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1228C", + "label": "kernel_BFC1228C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC122A0", + "label": "kernel_BFC122A0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC129EC", + "label": "kernel_BFC129EC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC12BC8", + "label": "kernel_BFC12BC8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC12BE0", + "label": "kernel_BFC12BE0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC12BE4", + "label": "rmiss_BFC12BE4", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC12EA4", + "label": "kernel_BFC12EA4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1302C", + "label": "kernel_BFC1302C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC130A4", + "label": "kernel_BFC130A4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13570", + "label": "kernel_BFC13570", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1372C", + "label": "kernel_BFC1372C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1376C", + "label": "rmiss_BFC1376C", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC137B4", + "label": "indirect_BFC137B4", + "rationale": "Reached via indirect dispatch during boot (kernel relocated)" + }, + { + "address": "0xBFC137BC", + "label": "rmiss_BFC137BC", + "rationale": "runtime dispatch miss" + }, + { + "address": "0xBFC137D0", + "label": "kernel_ram_function", + "rationale": "Dispatch miss at 0xBFC137D0 (phys 0x00003CD0) \u00e2\u20ac\u201d 3 misses. Kernel RAM function." + }, + { + "address": "0xBFC138AC", + "label": "kernel_BFC138AC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1395C", + "label": "kernel_BFC1395C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13968", + "label": "kernel_BFC13968", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13A04", + "label": "kernel_BFC13A04", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13ED0", + "label": "kernel_BFC13ED0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC13F5C", + "label": "kernel_BFC13F5C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1454C", + "label": "kernel_BFC1454C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14588", + "label": "kernel_BFC14588", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14594", + "label": "kernel_BFC14594", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14670", + "label": "kernel_BFC14670", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14754", + "label": "kernel_BFC14754", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14764", + "label": "kernel_BFC14764", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1483C", + "label": "kernel_BFC1483C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1484C", + "label": "kernel_BFC1484C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC14864", + "label": "kernel_BFC14864", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1486C", + "label": "kernel_BFC1486C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15188", + "label": "kernel_BFC15188", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC151E8", + "label": "kernel_56E8", + "rationale": "Kernel function at RAM 0x56E8. Dispatch miss from shell input path." + }, + { + "address": "0xBFC15268", + "label": "kernel_5768", + "rationale": "Kernel function at RAM 0x5768. Cascade from input path seeds." + }, + { + "address": "0xBFC1529C", + "label": "kernel_579C", + "rationale": "Kernel function at RAM 0x579C. Cascade from input path." + }, + { + "address": "0xBFC15334", + "label": "kernel_5834", + "rationale": "Kernel function at RAM 0x5834. Cascade." + }, + { + "address": "0xBFC15370", + "label": "kernel_5870", + "rationale": "Kernel (CARD driver) function. Cascade." + }, + { + "address": "0xBFC153B4", + "label": "kernel_58B4", + "rationale": "Kernel (CARD driver) function at RAM 0x58B4. Cascade." + }, + { + "address": "0xBFC153E8", + "label": "kernel_58E8", + "rationale": "Kernel (CARD driver) function at RAM 0x58E8. Cascade." + }, + { + "address": "0xBFC15418", + "label": "kernel_5918", + "rationale": "Kernel (CARD driver) leaf function at RAM 0x5918. Cascade." + }, + { + "address": "0xBFC15454", + "label": "kernel_5954", + "rationale": "CARD driver cascade." + }, + { + "address": "0xBFC15490", + "label": "kernel_5990", + "rationale": "CARD driver cascade." + }, + { + "address": "0xBFC154CC", + "label": "kernel_59CC", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15500", + "label": "kernel_5A00", + "rationale": "CARD driver cascade." + }, + { + "address": "0xBFC15508", + "label": "kernel_5A08", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15544", + "label": "kernel_5A44", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15558", + "label": "kernel_5A58", + "rationale": "CARD read chain state 12 (data-byte-store): SIO_RX -> a1[0x7F], XOR-updates checksum at 0x7560+slot*4, returns v0=0. Targeted by jump table at RAM 0x6c98 entry 12." + }, + { + "address": "0xBFC15580", + "label": "kernel_5A80", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC155B0", + "label": "kernel_5AB0", + "rationale": "CARD read chain state 13 (data-loop completion): reads byte vs checksum at 0x7560+slot*4, polls SIO_STAT for RX_RDY, reads END byte; v0=1 if 'G' else v0=-1. Targeted by jump table at RAM 0x6c98 entry 13." + }, + { + "address": "0xBFC155BC", + "label": "kernel_5ABC", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC155F8", + "label": "kernel_5AF8", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15634", + "label": "kernel_5B34", + "rationale": "CARD driver cascade (predicted)." + }, + { + "address": "0xBFC15650", + "label": "kernel_5B50", + "rationale": "Kernel function. Cascade from input path." + }, + { + "address": "0xBFC15654", + "label": "kernel_5B54", + "rationale": "Kernel function. Cascade from input path." + }, + { + "address": "0xBFC15664", + "label": "kernel_BFC15664", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC156A4", + "label": "kernel_5BA4", + "rationale": "Kernel function at RAM 0x5BA4. Dispatch miss from shell input path." + }, + { + "address": "0xBFC15724", + "label": "kernel_5C24", + "rationale": "Kernel function at RAM 0x5C24. Cascade from input path seeds." + }, + { + "address": "0xBFC15758", + "label": "kernel_5C58", + "rationale": "Kernel function at RAM 0x5C58. Cascade." + }, + { + "address": "0xBFC15848", + "label": "kernel_5D48", + "rationale": "Kernel (CARD driver) function. Cascade." + }, + { + "address": "0xBFC15894", + "label": "kernel_5D94", + "rationale": "Kernel function. Cascade from input path." + }, + { + "address": "0xBFC15898", + "label": "kernel_5D98", + "rationale": "Kernel (CARD driver) function. Cascade." + }, + { + "address": "0xBFC15930", + "label": "kernel_BFC15930", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15950", + "label": "kernel_BFC15950", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15998", + "label": "kernel_BFC15998", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15A04", + "label": "kernel_BFC15A04", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15A4C", + "label": "kernel_BFC15A4C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15AB8", + "label": "kernel_BFC15AB8", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15AF0", + "label": "kernel_BFC15AF0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15B5C", + "label": "kernel_BFC15B5C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15B70", + "label": "kernel_BFC15B70", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15CC4", + "label": "kernel_BFC15CC4", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15E90", + "label": "kernel_BFC15E90", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15EBC", + "label": "kernel_BFC15EBC", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC15F08", + "label": "kernel_BFC15F08", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC1609C", + "label": "kernel_BFC1609C", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC16690", + "label": "kernel_BFC16690", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166A0", + "label": "kernel_BFC166A0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166B0", + "label": "kernel_BFC166B0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166C0", + "label": "kernel_BFC166C0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166D0", + "label": "kernel_BFC166D0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166E0", + "label": "kernel_BFC166E0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC166F0", + "label": "kernel_BFC166F0", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC16700", + "label": "kernel_BFC16700", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC16710", + "label": "kernel_BFC16710", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC16720", + "label": "kernel_BFC16720", + "rationale": "Kernel ROM function (auto-discovered from tables/boundaries)" + }, + { + "address": "0xBFC02934", + "rationale": "kernel jump-table target: A0:0F (unresolved (see psx-spx A0 table)) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03200", + "rationale": "kernel jump-table target: A0:16 (strncat) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC034D0", + "rationale": "kernel jump-table target: A0:1C (atob helper) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03514", + "rationale": "kernel jump-table target: A0:1D (strtol-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0357C", + "rationale": "kernel jump-table target: A0:1E (strtol-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC035C0", + "rationale": "kernel jump-table target: A0:1F (abs) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03628", + "rationale": "kernel jump-table target: A0:20 (labs) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03694", + "rationale": "kernel jump-table target: A0:21 (atoi) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC036FC", + "rationale": "kernel jump-table target: A0:22 (atol) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC03894", + "rationale": "kernel jump-table target: A0:24 (strtol-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC01B08", + "rationale": "kernel jump-table target: A0:29 (bcmp) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC02B8C", + "rationale": "kernel jump-table target: A0:2B (memset) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC02BC8", + "rationale": "kernel jump-table target: A0:2C (memmove) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC02C50", + "rationale": "kernel jump-table target: A0:2D (memcmp) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC02230", + "rationale": "kernel jump-table target: A0:30 (rand-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC021A0", + "rationale": "kernel jump-table target: A0:37 (rand-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D8E0", + "rationale": "kernel jump-table target: A0:40 (SystemErrorUnresolvedException) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC04120", + "rationale": "kernel jump-table target: A0:4D (ChangeClearRCnt-adjacent) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D8F0", + "rationale": "kernel jump-table target: A0:4F (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D900", + "rationale": "kernel jump-table target: A0:50 (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D910", + "rationale": "kernel jump-table target: A0:52 (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D920", + "rationale": "kernel jump-table target: A0:53 (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0B0C4", + "rationale": "kernel jump-table target: A0:6F (card/dev service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC04AA0", + "rationale": "kernel jump-table target: A0:94 (card-adjacent service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D930", + "rationale": "kernel jump-table target: A0:9A (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D940", + "rationale": "kernel jump-table target: A0:9B (stub/ret) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC06750", + "rationale": "kernel jump-table target: A0:9C (kernel service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC06728", + "rationale": "kernel jump-table target: A0:9D (kernel service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC050DC", + "rationale": "kernel jump-table target: A0:9E (kernel service) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + }, + { + "address": "0xBFC0D4F0", + "rationale": "kernel jump-table target: A0:B4 (GetSystemInfo-family) \u2014 ROM-resident entry of the post-init A0/B0/C0 tables (read from live kernel RAM 0x200/0x874/0x674); reachable only through the vector table, invisible to transitive discovery. Class found via MMX6 card-check: A0:2B memset dispatch-missed silently, killing the save-scan (2026-07-02)." + } + ], + "excluded": [ + { + "address": "0xBFC00100", + "label": "utlb_miss_bev1", + "rationale": "R3000 UTLB-miss vector; PS1 has no TLB; never executed" + } + ] +} \ No newline at end of file diff --git a/recompiler/src/bios_address_model.cpp b/recompiler/src/bios_address_model.cpp new file mode 100644 index 00000000..d2d8cb0a --- /dev/null +++ b/recompiler/src/bios_address_model.cpp @@ -0,0 +1,242 @@ +// bios_address_model.cpp — see bios_address_model.h for the design contract. + +#include "bios_address_model.h" + +#include <stdexcept> + +#include "fmt/format.h" + +#include "config_loader.h" + +namespace PSXRecompV4 { + +// --------------------------------------------------------------------------- +// Construction + invariants +// --------------------------------------------------------------------------- + +BiosAddressModel BiosAddressModel::from_config(const BiosConfig& cfg) { + BiosAddressModel m; + m.copies_ = cfg.address_copies; + m.install_slots_ = cfg.install_slots; + m.rom_base_phys_ = cfg.load_address & 0x1FFFFFFFu; + m.rom_size_ = cfg.text_size; + + const std::string where = cfg.config_path.string(); + + auto fail = [&](const std::string& msg) { + throw std::runtime_error(fmt::format( + "{}: [recompiler.address_model] {}", where, msg)); + }; + + for (size_t i = 0; i < m.copies_.size(); ++i) { + const BiosAddrCopy& c = m.copies_[i]; + if (c.rom_lo >= c.rom_hi) + fail(fmt::format("copy '{}': rom_lo >= rom_hi", c.name)); + if ((c.rom_lo | c.rom_hi | c.ram_lo | c.runtime_base) & 3u) + fail(fmt::format("copy '{}': bounds must be 4-aligned", c.name)); + if (c.rom_lo < m.rom_base_phys_ || + c.rom_hi > m.rom_base_phys_ + m.rom_size_) + fail(fmt::format("copy '{}': ROM window outside the image " + "[0x{:08X},0x{:08X})", + c.name, m.rom_base_phys_, + m.rom_base_phys_ + m.rom_size_)); + if ((c.runtime_base & 0x1FFFFFFFu) != c.ram_lo) + fail(fmt::format("copy '{}': runtime_base must be a vaddr of " + "ram_lo (phys mismatch)", c.name)); + + // Disjoint input windows: a ROM byte belongs to at most one copy, and + // a RAM byte to at most one destination ("duplicate logical function + // -> refuse to build", docs/RELOCATION_MANIFEST_FORMAT.md). + for (size_t j = 0; j < i; ++j) { + const BiosAddrCopy& p = m.copies_[j]; + if (c.rom_lo < p.rom_hi && p.rom_lo < c.rom_hi) + fail(fmt::format("copies '{}' and '{}': ROM windows overlap", + p.name, c.name)); + if (c.ram_lo < p.ram_hi() && p.ram_lo < c.ram_hi()) + fail(fmt::format("copies '{}' and '{}': RAM windows overlap", + p.name, c.name)); + } + } + + // normalize() is emitted as SEQUENTIAL ifs with no else; that is only + // equivalent to first-match-wins if no copy's fold OUTPUT can land inside + // a later copy's fold INPUT. Check every pair, both key directions. + for (const BiosAddrCopy& a : m.copies_) { + const uint32_t out_lo = a.key_is_ram ? a.ram_lo : a.rom_lo; + const uint32_t out_hi = a.key_is_ram ? a.ram_hi() : a.rom_hi; + for (const BiosAddrCopy& b : m.copies_) { + if (&a == &b) continue; + const uint32_t in_lo = b.key_is_ram ? b.rom_lo : b.ram_lo; + const uint32_t in_hi = b.key_is_ram ? b.rom_hi : b.ram_hi(); + if (out_lo < in_hi && in_lo < out_hi) + fail(fmt::format( + "copy '{}' fold output [0x{:08X},0x{:08X}) intersects " + "copy '{}' fold input [0x{:08X},0x{:08X}) — sequential " + "normalize() would double-fold", + a.name, out_lo, out_hi, b.name, in_lo, in_hi)); + } + } + + for (size_t i = 0; i < m.copies_.size(); ++i) { + if (m.copies_[i].kernel_bless) { + if (m.kbless_idx_ >= 0) + fail("at most one copy may set kernel_bless = true (the " + "runtime models a single bless window)"); + if (!m.copies_[i].key_is_ram) + fail(fmt::format("copy '{}': kernel_bless requires " + "dispatch_key = \"ram\"", m.copies_[i].name)); + m.kbless_idx_ = static_cast<int>(i); + } + if (!m.copies_[i].key_is_ram && m.rom_keyed_idx_ < 0) + m.rom_keyed_idx_ = static_cast<int>(i); + } + + for (uint32_t slot : m.install_slots_) { + if (slot & 3u) + fail(fmt::format("install slot 0x{:08X} must be 4-aligned", slot)); + } + + return m; +} + +// --------------------------------------------------------------------------- +// Address mapping +// --------------------------------------------------------------------------- + +uint32_t BiosAddressModel::normalize(uint32_t addr) const { + uint32_t phys = addr & 0x1FFFFFFFu; + // Sequential per-copy folds, same shape as the emitted normalize() — + // equivalence to first-match-wins is enforced by from_config. + for (const BiosAddrCopy& c : copies_) { + if (c.key_is_ram) { + if (phys >= c.rom_lo && phys < c.rom_hi) + phys = phys - c.rom_lo + c.ram_lo; + } else { + if (phys >= c.ram_lo && phys < c.ram_hi()) + phys = phys - c.ram_lo + c.rom_lo; + } + } + return phys; +} + +uint32_t BiosAddressModel::ram_alias_to_rom(uint32_t addr) const { + uint32_t phys = addr & 0x1FFFFFFFu; + for (const BiosAddrCopy& c : copies_) { + if (phys >= c.ram_lo && phys < c.ram_hi()) + return (0xA0000000u | c.rom_lo) + (phys - c.ram_lo); + } + return addr; +} + +uint32_t BiosAddressModel::runtime_pc(uint32_t rom_pc) const { + uint32_t phys = rom_pc & 0x1FFFFFFFu; + for (const BiosAddrCopy& c : copies_) { + if (phys >= c.rom_lo && phys < c.rom_hi) + return c.runtime_base + (phys - c.rom_lo); + } + return rom_pc; +} + +uint32_t BiosAddressModel::relocate_j_target(uint32_t rom_addr, uint32_t target) const { + uint32_t phys = rom_addr & 0x1FFFFFFFu; + for (const BiosAddrCopy& c : copies_) { + if (phys >= c.rom_lo && phys < c.rom_hi) { + uint32_t runtime_addr = c.runtime_base + (phys - c.rom_lo); + return (runtime_addr & 0xF0000000u) | (target & 0x0FFFFFFFu); + } + } + return target; +} + +uint32_t BiosAddressModel::rom_to_ram_phys(uint32_t rom_addr) const { + uint32_t phys = rom_addr & 0x1FFFFFFFu; + for (const BiosAddrCopy& c : copies_) { + if (c.key_is_ram && phys >= c.rom_lo && phys < c.rom_hi) + return phys - c.rom_lo + c.ram_lo; + } + return phys; +} + +uint32_t BiosAddressModel::remap_relocated_j_target(uint32_t target, + uint32_t source_rom_addr) const { + uint32_t src_phys = source_rom_addr & 0x1FFFFFFFu; + for (const BiosAddrCopy& c : copies_) { + if (src_phys < c.rom_lo || src_phys >= c.rom_hi) continue; + + // The 26-bit target inherits the RUNTIME region of the source. + uint32_t runtime_addr = c.runtime_base + (src_phys - c.rom_lo); + uint32_t ram_target = (runtime_addr & 0xF0000000u) | (target & 0x0FFFFFFFu); + uint32_t ram_phys = ram_target & 0x1FFFFFFFu; + + // Fold back to a followable KSEG1 ROM address through any copy's RAM + // window. + for (const BiosAddrCopy& c2 : copies_) { + if (ram_phys >= c2.ram_lo && ram_phys < c2.ram_hi()) + return (0xA0000000u | c2.rom_lo) + (ram_phys - c2.ram_lo); + } + // Target already inside the ROM image: return it in KSEG1 form. + if (ram_phys >= rom_base_phys_ && ram_phys < rom_base_phys_ + rom_size_) + return 0xA0000000u | ram_phys; + return target; + } + return target; +} + +// --------------------------------------------------------------------------- +// kbless / install slots / rom-keyed window accessors +// --------------------------------------------------------------------------- + +uint32_t BiosAddressModel::kbless_ram_lo() const { return copies_[kbless_idx_].ram_lo; } +uint32_t BiosAddressModel::kbless_ram_hi() const { return copies_[kbless_idx_].ram_hi(); } +uint32_t BiosAddressModel::kbless_rom_off() const { + return copies_[kbless_idx_].rom_lo - rom_base_phys_; +} +bool BiosAddressModel::in_kbless(uint32_t norm) const { + return has_kbless() && norm >= kbless_ram_lo() && norm < kbless_ram_hi(); +} + +bool BiosAddressModel::is_install_slot(uint32_t ram_pc) const { + for (uint32_t slot : install_slots_) { + if (ram_pc == slot) return true; + } + return false; +} + +uint32_t BiosAddressModel::rom_keyed_ram_lo() const { + return copies_[rom_keyed_idx_].ram_lo; +} +uint32_t BiosAddressModel::rom_keyed_ram_hi_incl() const { + return copies_[rom_keyed_idx_].ram_hi() - 1u; +} + +// --------------------------------------------------------------------------- +// Emitted-C text +// --------------------------------------------------------------------------- + +std::string BiosAddressModel::emit_normalize_c() const { + std::string out; + out += "static uint32_t normalize(uint32_t addr) {\n"; + out += " uint32_t phys = addr & 0x1FFFFFFFu;\n"; + for (const BiosAddrCopy& c : copies_) { + if (c.key_is_ram) { + out += fmt::format(" /* {}: ROM 0x{:X}+ -> RAM 0x{:X}+ */\n", + c.name, c.rom_lo, c.ram_lo); + out += fmt::format(" if (phys >= 0x{:08X}u && phys <= 0x{:08X}u)\n", + c.rom_lo, c.rom_hi - 1u); + out += fmt::format(" phys = phys - 0x{:08X}u + 0x{:08X}u;\n", + c.rom_lo, c.ram_lo); + } else { + out += fmt::format(" /* {}: RAM 0x{:X}+ -> ROM physical 0x{:X}+ */\n", + c.name, c.ram_lo, c.rom_lo); + out += fmt::format(" if (phys >= 0x{:08X}u && phys <= 0x{:08X}u)\n", + c.ram_lo, c.ram_hi() - 1u); + out += fmt::format(" phys = phys - 0x{:08X}u + 0x{:08X}u;\n", + c.ram_lo, c.rom_lo); + } + } + out += " return phys;\n"; + out += "}\n\n"; + return out; +} + +} // namespace PSXRecompV4 diff --git a/recompiler/src/bios_address_model.h b/recompiler/src/bios_address_model.h new file mode 100644 index 00000000..b21bcb65 --- /dev/null +++ b/recompiler/src/bios_address_model.h @@ -0,0 +1,126 @@ +// bios_address_model.h — the BIOS profile's address model, as one value type. +// ---------------------------------------------------------------------------- +// A PS1 BIOS relocates code out of ROM into RAM at boot (SCPH1001: the Kernel +// Part 2 copy to RAM 0x500 and the shell copy to RAM 0x80030000). Before this +// existed, those window constants were copy-pasted across ~9 sites in +// full_function_emitter.cpp / function_discovery.cpp — including as literal C +// text emitted into the generated dispatch — and the two files had drifted on +// the shell window's upper bound. This class is the single source of truth, +// built from the profile's [recompiler.address_model] tables; both the C++ +// helpers and the emitted-C text generators derive from the same table, so +// they cannot disagree. +// +// A profile with NO copy entries (a BIOS that runs entirely from ROM) is a +// valid, first-class model: normalize() degenerates to the KSEG mask, there is +// no kernel-bless window, and every generator emits nothing. +// +// Every [[recompiler.address_model.copy]] entry is a CLAIM that the BIOS's +// boot-time copy is byte-verbatim (lw/sw loop or memcpy, no patching or +// decompression). The runtime's kernel-bless memcmp is the enforcement of +// that claim for the kernel_bless window. + +#pragma once + +#include <cstdint> +#include <string> +#include <vector> + +namespace PSXRecompV4 { + +struct BiosConfig; + +// One [[recompiler.address_model.copy]]: a boot-time bulk copy of code from +// ROM [rom_lo, rom_hi) to RAM ram_lo+, executed by the CPU at runtime_base+. +struct BiosAddrCopy { + std::string name; // comment label in emitted C, e.g. "Kernel Part 2" + uint32_t rom_lo = 0; // physical ROM window [lo, hi) — hi EXCLUSIVE + uint32_t rom_hi = 0; + uint32_t ram_lo = 0; // physical RAM destination of rom_lo + uint32_t runtime_base = 0; // vaddr the CPU executes the copy at (KUSEG/KSEG0) + bool key_is_ram = false; // dispatch_key = "ram": functions in this + // window are keyed by RAM address (normalize + // folds ROM->RAM). "rom": keyed by ROM + // (normalize folds RAM->ROM). + bool kernel_bless = false; // runtime may byte-verify live RAM vs ROM + // and run native (memory.c kbless) + + uint32_t len() const { return rom_hi - rom_lo; } + uint32_t ram_hi() const { return ram_lo + len(); } // exclusive +}; + +class BiosAddressModel { +public: + // Empty model: pure KSEG-mask normalization, no copies, no install slots. + BiosAddressModel() = default; + + // Build from a loaded profile, enforcing the semantic invariants + // (disjoint input windows, no output/input intersection, single bless + // window, alignment, in-image bounds). Throws std::runtime_error with the + // profile path in the message on violation. + static BiosAddressModel from_config(const BiosConfig& cfg); + + // --- C++-side address mapping (used while analyzing/emitting) --------- + + // Dispatch-key normalization: KSEG strip, then per-copy key fold. + uint32_t normalize(uint32_t addr) const; + + // RAM alias -> KSEG1 ROM virtual address (for reading jump tables etc. + // out of the ROM image when a pointer references the relocated RAM copy). + // Returns addr unchanged when it is not inside any copy's RAM window. + uint32_t ram_alias_to_rom(uint32_t addr) const; + + // ROM PC -> the vaddr the CPU actually executes it from (runtime_base+). + // Identity for ROM outside every copy window. + uint32_t runtime_pc(uint32_t rom_pc) const; + + // Fix a J/JAL target encoded relative to the RUNTIME region of relocated + // code (the 26-bit target inherits (runtime PC & 0xF0000000), not the ROM + // region). + uint32_t relocate_j_target(uint32_t rom_addr, uint32_t target) const; + + // ROM PC -> physical RAM address, for RAM-keyed copies only (install-slot + // detection in kernel RAM). Non-RAM-keyed windows and plain ROM return + // the physical address unchanged. + uint32_t rom_to_ram_phys(uint32_t rom_addr) const; + + // Discovery-side J/JAL remap: compute the runtime-region target, then fold + // it back to a followable KSEG1 ROM address through any copy's RAM window. + // Returns the original target when the source is not in a copy window or + // the computed target is not resolvable to ROM. + uint32_t remap_relocated_j_target(uint32_t target, uint32_t source_rom_addr) const; + + // --- kernel-bless window --------------------------------------------- + + bool has_kbless() const { return kbless_idx_ >= 0; } + uint32_t kbless_ram_lo() const; // inclusive + uint32_t kbless_ram_hi() const; // exclusive + uint32_t kbless_rom_off() const; // ROM file offset of kbless_ram_lo + bool in_kbless(uint32_t norm) const; + + // --- install slots ---------------------------------------------------- + + bool is_install_slot(uint32_t ram_pc) const; + + // --- ROM-keyed RAM window (shell-style), for the game-overlap text ---- + + bool has_rom_keyed_ram_window() const { return rom_keyed_idx_ >= 0; } + uint32_t rom_keyed_ram_lo() const; // inclusive + uint32_t rom_keyed_ram_hi_incl() const; // INCLUSIVE (matches emitted text) + + // --- emitted-C text generators (same table as the C++ methods) -------- + + // The generated dispatch's `static uint32_t normalize(uint32_t)`. + std::string emit_normalize_c() const; + + const std::vector<BiosAddrCopy>& copies() const { return copies_; } + +private: + std::vector<BiosAddrCopy> copies_; + std::vector<uint32_t> install_slots_; + uint32_t rom_base_phys_ = 0x1FC00000u; + uint32_t rom_size_ = 0x80000u; + int kbless_idx_ = -1; // index into copies_ + int rom_keyed_idx_ = -1; // first dispatch_key="rom" copy +}; + +} // namespace PSXRecompV4 diff --git a/recompiler/src/cli_boot_path.cpp b/recompiler/src/cli_boot_path.cpp new file mode 100644 index 00000000..3dda5843 --- /dev/null +++ b/recompiler/src/cli_boot_path.cpp @@ -0,0 +1,36 @@ +#include "cli_boot_path.h" + +#include <cctype> +#include <filesystem> + +namespace PSXRecompCLI { + +std::string normalize_boot_path(const std::string& boot) { + std::string normalized; + normalized.reserve(boot.size()); + for (char c : boot) normalized += (c == '\\') ? '/' : c; + return normalized; +} + +std::string boot_filename(const std::string& boot) { + return std::filesystem::path(normalize_boot_path(boot)).filename().string(); +} + +std::string serial_from_boot(const std::string& boot) { + std::string base = boot_filename(boot); + std::string clean; + for (char c : base) { + if (std::isalnum(static_cast<unsigned char>(c))) { + clean += static_cast<char>(std::toupper(static_cast<unsigned char>(c))); + } + } + size_t split = 0; + while (split < clean.size() && + std::isalpha(static_cast<unsigned char>(clean[split]))) { + split++; + } + if (split && split < clean.size()) clean.insert(split, "-"); + return clean.empty() ? "PSX-GAME" : clean; +} + +} // namespace PSXRecompCLI diff --git a/recompiler/src/cli_boot_path.h b/recompiler/src/cli_boot_path.h new file mode 100644 index 00000000..ced50d42 --- /dev/null +++ b/recompiler/src/cli_boot_path.h @@ -0,0 +1,14 @@ +#pragma once + +#include <string> + +namespace PSXRecompCLI { + +// SYSTEM.CNF uses backslashes for disc-internal paths. Canonicalize them +// before passing the path to host filesystem utilities. +std::string normalize_boot_path(const std::string& boot); + +std::string boot_filename(const std::string& boot); +std::string serial_from_boot(const std::string& boot); + +} // namespace PSXRecompCLI diff --git a/recompiler/tests/bios_address_model_test.cpp b/recompiler/tests/bios_address_model_test.cpp new file mode 100644 index 00000000..4e11395a --- /dev/null +++ b/recompiler/tests/bios_address_model_test.cpp @@ -0,0 +1,238 @@ +// bios_address_model_test.cpp — proves the profile-driven BiosAddressModel is +// function-level equivalent to the hardcoded SCPH1001 helpers it replaced. +// +// The byte-identity check on the regenerated SCPH1001_*.c is artifact-level +// evidence; this test is the function-level version: every 4-byte address in +// the RAM and ROM ranges is swept through the model built from +// bios/SCPH1001.toml and compared against verbatim copies of the pre-refactor +// implementations (full_function_emitter.cpp / function_discovery.cpp as of +// the commit this refactor replaced). It also exercises the from_config +// invariants. +// +// Usage: bios_address_model_test [path/to/SCPH1001.toml] +// default: bios/SCPH1001.toml relative to the CWD (run from framework root) + +#include <cstdint> +#include <cstdio> +#include <stdexcept> +#include <string> + +#include "../src/bios_address_model.h" +#include "../src/config_loader.h" + +using PSXRecompV4::BiosAddressModel; +using PSXRecompV4::BiosAddrCopy; +using PSXRecompV4::BiosConfig; + +// --------------------------------------------------------------------------- +// Reference implementations: verbatim pre-refactor SCPH1001 hardcodes. +// --------------------------------------------------------------------------- + +static uint32_t ref_normalize(uint32_t addr) { + uint32_t phys = addr & 0x1FFFFFFFu; + if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) { + phys = phys - 0x1FC10000u + 0x00000500u; + } + if (phys >= 0x00030000u && phys <= 0x0005AFFFu) { + phys = phys - 0x00030000u + 0x1FC18000u; + } + return phys; +} + +static uint32_t ref_ram_alias_to_rom(uint32_t addr) { + uint32_t phys = addr & 0x1FFFFFFFu; + if (phys >= 0x00000500u && phys < 0x00008500u) { + return 0xBFC10000u + (phys - 0x00000500u); + } + if (phys >= 0x00030000u && phys <= 0x0005AFFFu) { + return 0xBFC18000u + (phys - 0x00030000u); + } + return addr; +} + +static uint32_t ref_runtime_pc(uint32_t rom_pc) { + uint32_t phys = rom_pc & 0x1FFFFFFFu; + if (phys >= 0x1FC18000u && phys <= 0x1FC42FFFu) { + return 0x80030000u + (phys - 0x1FC18000u); + } + if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) { + return phys - 0x1FC10000u + 0x00000500u; + } + return rom_pc; +} + +static uint32_t ref_relocate_j_target(uint32_t rom_addr, uint32_t target) { + uint32_t phys = rom_addr & 0x1FFFFFFFu; + if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) { + uint32_t runtime_addr = phys - 0x1FC10000u + 0x00000500u; + return (runtime_addr & 0xF0000000u) | (target & 0x0FFFFFFFu); + } + if (phys >= 0x1FC18000u && phys <= 0x1FC42FFFu) { + uint32_t runtime_addr = 0x80030000u + (phys - 0x1FC18000u); + return (runtime_addr & 0xF0000000u) | (target & 0x0FFFFFFFu); + } + return target; +} + +static uint32_t ref_rom_to_ram_phys(uint32_t rom_addr) { + uint32_t phys = rom_addr & 0x1FFFFFFFu; + if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) { + return phys - 0x1FC10000u + 0x00000500u; + } + return phys; +} + +// --------------------------------------------------------------------------- + +static int g_failures = 0; + +static void expect_eq(uint32_t got, uint32_t want, const char* what, uint32_t at) { + if (got != want) { + std::fprintf(stderr, "FAIL %s(0x%08X): got 0x%08X want 0x%08X\n", + what, at, got, want); + if (++g_failures > 20) { + std::fprintf(stderr, "too many failures, aborting\n"); + std::exit(1); + } + } +} + +static void expect_throw(const char* what, void (*fn)(BiosConfig&), BiosConfig base) { + try { + fn(base); + BiosAddressModel::from_config(base); + std::fprintf(stderr, "FAIL invariant '%s': expected from_config to throw\n", what); + g_failures++; + } catch (const std::runtime_error&) { + /* expected */ + } +} + +static BiosConfig minimal_cfg() { + BiosConfig cfg{}; + cfg.config_path = "test://synthetic"; + cfg.load_address = 0xBFC00000u; + cfg.text_size = 0x80000u; + return cfg; +} + +static BiosAddrCopy mk(const char* name, uint32_t rom_lo, uint32_t rom_hi, + uint32_t ram_lo, uint32_t runtime_base, + bool key_is_ram, bool bless) { + BiosAddrCopy c; + c.name = name; c.rom_lo = rom_lo; c.rom_hi = rom_hi; c.ram_lo = ram_lo; + c.runtime_base = runtime_base; c.key_is_ram = key_is_ram; + c.kernel_bless = bless; + return c; +} + +int main(int argc, char** argv) { + const std::string profile = argc > 1 ? argv[1] : "bios/SCPH1001.toml"; + + BiosAddressModel model; + try { + const auto cfg = PSXRecompV4::load_bios_config(profile); + model = BiosAddressModel::from_config(cfg); + } catch (const std::exception& ex) { + std::fprintf(stderr, "cannot load %s: %s\n(run from the framework root)\n", + profile.c_str(), ex.what()); + return 2; + } + + // --- Sweep: RAM range + ROM range, every 4 bytes, all helpers --------- + auto sweep = [&](uint32_t lo, uint32_t hi) { + for (uint64_t a = lo; a < hi; a += 4) { + const uint32_t addr = static_cast<uint32_t>(a); + expect_eq(model.normalize(addr), ref_normalize(addr), "normalize", addr); + expect_eq(model.ram_alias_to_rom(addr), ref_ram_alias_to_rom(addr), "ram_alias_to_rom", addr); + expect_eq(model.runtime_pc(addr), ref_runtime_pc(addr), "runtime_pc", addr); + expect_eq(model.rom_to_ram_phys(addr), ref_rom_to_ram_phys(addr), "rom_to_ram_phys", addr); + // J-target relocation: source sweep with two representative + // 26-bit targets (one per runtime region shape). + expect_eq(model.relocate_j_target(addr, 0x0FC12344u), + ref_relocate_j_target(addr, 0x0FC12344u), "relocate_j(a)", addr); + expect_eq(model.relocate_j_target(addr, 0x00030010u), + ref_relocate_j_target(addr, 0x00030010u), "relocate_j(b)", addr); + } + }; + sweep(0x00000000u, 0x00200000u); // RAM (2 MiB) + sweep(0x1FC00000u, 0x1FC80000u); // BIOS ROM phys + sweep(0xBFC00000u, 0xBFC80000u); // BIOS ROM KSEG1 (mask path) + sweep(0x80000000u, 0x80200000u); // RAM KSEG0 (mask path) + + // --- kbless / rom-keyed accessors match the SCPH1001 constants -------- + if (!model.has_kbless()) { std::fprintf(stderr, "FAIL: no kbless window\n"); g_failures++; } + expect_eq(model.kbless_ram_lo(), 0x500u, "kbless_ram_lo", 0); + expect_eq(model.kbless_ram_hi(), 0x8500u, "kbless_ram_hi", 0); + expect_eq(model.kbless_rom_off(), 0x10000u, "kbless_rom_off", 0); + expect_eq(model.rom_keyed_ram_lo(), 0x30000u, "rom_keyed_ram_lo", 0); + expect_eq(model.rom_keyed_ram_hi_incl(), 0x5AFFFu, "rom_keyed_ram_hi_incl", 0); + if (!model.is_install_slot(0xCF0u)) { std::fprintf(stderr, "FAIL: 0xCF0 not an install slot\n"); g_failures++; } + if (model.is_install_slot(0xCF4u)) { std::fprintf(stderr, "FAIL: 0xCF4 claims install slot\n"); g_failures++; } + + // --- from_config invariants refuse bad profiles ----------------------- + expect_throw("overlapping ROM windows", [](BiosConfig& c) { + c.address_copies = { + mk("a", 0x1FC10000u, 0x1FC18000u, 0x500u, 0x500u, true, false), + mk("b", 0x1FC14000u, 0x1FC43000u, 0x30000u, 0x80030000u, false, false), + }; + }, minimal_cfg()); + expect_throw("two bless windows", [](BiosConfig& c) { + c.address_copies = { + mk("a", 0x1FC10000u, 0x1FC14000u, 0x500u, 0x500u, true, true), + mk("b", 0x1FC14000u, 0x1FC18000u, 0x9000u, 0x9000u, true, true), + }; + }, minimal_cfg()); + expect_throw("misaligned bounds", [](BiosConfig& c) { + c.address_copies = { + mk("a", 0x1FC10002u, 0x1FC18000u, 0x500u, 0x500u, true, false), + }; + }, minimal_cfg()); + expect_throw("ROM window outside image", [](BiosConfig& c) { + c.address_copies = { + mk("a", 0x1FB00000u, 0x1FB10000u, 0x500u, 0x500u, true, false), + }; + }, minimal_cfg()); + expect_throw("runtime_base phys mismatch", [](BiosConfig& c) { + c.address_copies = { + mk("a", 0x1FC10000u, 0x1FC18000u, 0x500u, 0x80000600u, true, false), + }; + }, minimal_cfg()); + expect_throw("bless on rom-keyed copy", [](BiosConfig& c) { + c.address_copies = { + mk("a", 0x1FC18000u, 0x1FC43000u, 0x30000u, 0x80030000u, false, true), + }; + }, minimal_cfg()); + expect_throw("overlapping RAM windows / double-fold", [](BiosConfig& c) { + // a folds ROM->RAM into [0x500,0x8500); b (rom-keyed) folds RAM + // [0x500,...) back out — sequential normalize() would double-fold + // a's output. Refused (as overlapping RAM destinations). + c.address_copies = { + mk("a", 0x1FC10000u, 0x1FC18000u, 0x500u, 0x500u, true, false), + mk("b", 0x1FC20000u, 0x1FC28000u, 0x500u, 0x500u, false, false), + }; + }, minimal_cfg()); + + // --- empty model degenerates to the KSEG mask ------------------------- + { + BiosAddressModel empty; + expect_eq(empty.normalize(0xBFC12345u), 0x1FC12345u, "empty.normalize", 0xBFC12345u); + expect_eq(empty.runtime_pc(0xBFC12344u), 0xBFC12344u, "empty.runtime_pc", 0xBFC12344u); + if (empty.has_kbless() || empty.has_rom_keyed_ram_window()) { + std::fprintf(stderr, "FAIL: empty model claims windows\n"); + g_failures++; + } + const std::string n = empty.emit_normalize_c(); + if (n.find("0x1FC1") != std::string::npos) { + std::fprintf(stderr, "FAIL: empty model emits copy folds\n"); + g_failures++; + } + } + + if (g_failures == 0) { + std::printf("bios_address_model_test: all checks passed\n"); + return 0; + } + std::fprintf(stderr, "bios_address_model_test: %d failure(s)\n", g_failures); + return 1; +} diff --git a/recompiler/tests/cli_boot_path_test.cpp b/recompiler/tests/cli_boot_path_test.cpp new file mode 100644 index 00000000..8d516f16 --- /dev/null +++ b/recompiler/tests/cli_boot_path_test.cpp @@ -0,0 +1,41 @@ +#include "cli_boot_path.h" + +#include <cstdio> +#include <string> + +namespace { + +int failures = 0; + +void check(bool condition, const char* message) { + if (condition) return; + std::fprintf(stderr, "FAIL: %s\n", message); + failures++; +} + +} // namespace + +int main() { + const std::string windows_style = "EXE\\SLUS_123.45"; + const std::string posix_style = "EXE/SLUS_123.45"; + + check(PSXRecompCLI::normalize_boot_path(windows_style) == posix_style, + "normalizes SYSTEM.CNF backslashes independent of the host OS"); + check(PSXRecompCLI::boot_filename(windows_style) == "SLUS_123.45", + "extracts a subdirectory boot filename from backslashes"); + check(PSXRecompCLI::boot_filename(posix_style) == "SLUS_123.45", + "extracts a subdirectory boot filename from forward slashes"); + check(PSXRecompCLI::serial_from_boot(windows_style) == "SLUS-12345", + "does not include the boot directory in the generated game ID"); + check(PSXRecompCLI::serial_from_boot(windows_style) == + PSXRecompCLI::serial_from_boot(posix_style), + "generates the same game ID for Linux and Windows path forms"); + check(PSXRecompCLI::boot_filename("SCUS_944.23") == "SCUS_944.23", + "preserves root-level boot filenames"); + check(PSXRecompCLI::serial_from_boot("SCUS_944.23") == "SCUS-94423", + "preserves root-level game ID generation"); + check(PSXRecompCLI::serial_from_boot("") == "PSX-GAME", + "preserves the fallback game ID for an empty boot path"); + + return failures == 0 ? 0 : 1; +} diff --git a/recompiler/tests/test_cli_project_packaging.py b/recompiler/tests/test_cli_project_packaging.py new file mode 100644 index 00000000..5b124889 --- /dev/null +++ b/recompiler/tests/test_cli_project_packaging.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Regression guards for the self-contained CLI project pipeline.""" + +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[2] + + +def require(source: str, needle: str, message: str) -> None: + if needle not in source: + raise AssertionError(message) + + +def main() -> int: + cli = (ROOT / "recompiler/src/main_cli.cpp").read_text(encoding="utf-8") + bios = (ROOT / "recompiler/src/main_bios.cpp").read_text(encoding="utf-8") + package = (ROOT / "tools/build_cli.py").read_text(encoding="utf-8") + launcher = (ROOT / "recomp-ui/src/recomp_launcher.h").read_text( + encoding="utf-8") + + require(cli, 'bios_config = \\"psxrecomp/bios/{}\\"', + "generated game.toml does not name its BIOS profile") + require(cli, '"--config", fs::absolute(profile_destination).string()', + "CLI BIOS recompilation does not use the selected profile") + require(cli, '"--rom", options.bios.string()', + "CLI BIOS recompilation does not pass the user-selected ROM") + require(cli, '"--out-dir",', + "CLI BIOS recompilation does not override the generated output") + require(cli, 'framework_destination / "generated"', + "BIOS sources are not emitted where runtime.cmake discovers them") + require(cli, 'set(PSXRECOMP_BIOS_STEMS \\"{}\\"', + "generated CMake does not select the emitted BIOS backend") + + copy_pos = cli.find('fmt::print("[1/4] Copying build framework') + game_pos = cli.find('fmt::print("[2/4] Recompiling game executable') + if copy_pos < 0 or game_pos < 0 or copy_pos >= game_pos: + raise AssertionError( + "framework/profile must be copied before game recompilation") + + for profile in ( + "OpenBIOS.toml", "SCPH1001.toml", + "SCPH101.toml", "SCPH5552.toml"): + require(package, f'"{profile}"', + f"CLI package omits BIOS profile {profile}") + require(package, 'shutil.copy2(ROOT / ".gitignore", framework)', + "packaged framework lacks the project-root marker used by profiles") + require(package, 'ROOT / "bios" / "openbios.bin"', + "packaged framework omits the redistributable OpenBIOS image") + require(package, 'ROOT / "bios" / "OpenBIOS.LICENSE"', + "packaged framework omits the OpenBIOS license") + + require(bios, 'a == "--rom"', + "psxrecomp-bios config mode lacks the ROM override") + require(bios, 'a == "--out-dir"', + "psxrecomp-bios config mode lacks the output override") + require(launcher, "RecompLauncherCModProvider", + "pinned recomp-ui predates the runtime Mods provider API") + require(launcher, "const RecompLauncherCModProvider* mods;", + "pinned recomp-ui GameInfo lacks the Mods provider field") + + print("PASS: CLI profiles, stage order, BIOS overrides, and launcher API agree") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except AssertionError as exc: + print(f"FAIL: {exc}") + sys.exit(1) diff --git a/recompiler/tests/test_fmv_cycle_batch_codegen.py b/recompiler/tests/test_fmv_cycle_batch_codegen.py new file mode 100644 index 00000000..6eb16c12 --- /dev/null +++ b/recompiler/tests/test_fmv_cycle_batch_codegen.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Verify generated FMV cycle batching has portable scope and IRQ flushes.""" + +import argparse +import os +import struct +import subprocess +import sys +import tempfile + + +LOAD = 0x80010000 + + +def generate_source(recompiler): + header = bytearray(2048) + header[0:8] = b"PS-X EXE" + struct.pack_into("<I", header, 0x10, LOAD) + struct.pack_into("<I", header, 0x18, LOAD) + words = (0x24020001, 0x1440FFFE, 0x00000000, 0x03E00008, 0x00000000) + body = b"".join(struct.pack("<I", word) for word in words) + struct.pack_into("<I", header, 0x1C, len(body)) + + with tempfile.TemporaryDirectory() as tmp: + exe = os.path.join(tmp, "cycle.psx") + seeds = os.path.join(tmp, "seeds.txt") + out = os.path.join(tmp, "out") + with open(exe, "wb") as stream: + stream.write(header + body) + with open(seeds, "w", encoding="utf-8") as stream: + stream.write(f"0x{LOAD:08X}\n") + result = subprocess.run( + [recompiler, exe, "--seeds", seeds, "--out-dir", out], + capture_output=True, + text=True, + ) + if result.returncode: + raise RuntimeError(result.stderr or result.stdout) + full = sorted( + name + for name in os.listdir(out) + if "_full" in name and name.endswith(".c") and "_dispatch" not in name + ) + if not full: + raise RuntimeError("no generated _full*.c source found") + sources = [] + for name in full: + with open(os.path.join(out, name), encoding="utf-8") as stream: + sources.append(stream.read()) + return "\n".join(sources) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--recompiler", required=True) + args = parser.parse_args() + try: + source = generate_source(args.recompiler) + except (OSError, RuntimeError) as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + + guard = ( + "#if defined(PSX_ENABLE_BLOCK_CYCLES) && " + "(defined(__GNUC__) || defined(__clang__))\n" + ) + guarded_begin = ( + guard + + " __attribute__((cleanup(psx_cyc_bb_defer_cleanup))) " + "int _psx_cyc_bb_guard = 1;\n" + " psx_cyc_bb_defer_begin();\n" + "#endif\n" + ) + failures = [] + if guarded_begin not in source: + failures.append("basic-block defer is not wholly GCC/Clang guarded") + flush = "psx_cyc_bb_defer_flush();\n#endif\n" + if flush not in source or "psx_check_interrupts_at(cpu," not in source: + failures.append("generated interrupt edges do not flush deferred cycles") + if failures: + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + return 1 + print("PASS: generated FMV cycle batching is scoped and flushed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/recompiler/tests/test_overlay_shim_compile.py b/recompiler/tests/test_overlay_shim_compile.py new file mode 100644 index 00000000..1ef5debe --- /dev/null +++ b/recompiler/tests/test_overlay_shim_compile.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Compile the overlay callback shim against the public runtime headers.""" + +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +INCLUDE = ROOT / "runtime" / "include" + + +def find_gcc() -> str: + candidates = [ + os.environ.get("CC"), + r"C:\msys64\mingw64\bin\gcc.exe" if os.name == "nt" else None, + shutil.which("gcc"), + shutil.which("cc"), + ] + for candidate in candidates: + if candidate and pathlib.Path(candidate).is_file(): + return candidate + raise SystemExit("gcc/cc is required for the overlay shim compile test") + + +def main() -> int: + source = r'''#include "cpu_state.h" +#include "overlay_dispatch_preamble.c.inc" + +void func_80010000(CPUState *cpu) { + psx_cyc_bb_defer_begin(); + psx_cyc_charge(1u); + psx_advance_cycles(1u); + cpu->gpr[2] = psx_cyc_load_word(cpu, cpu->gpr[4], 2u, 0u); + cpu->gpr[3] = psx_cyc_load_half(cpu, cpu->gpr[5], 3u, 0u); + psx_cyc_bb_defer_flush(); + psx_cyc_bb_defer_end(); + if (psx_slice_block(cpu, 0x80010000u, 1u, 0)) return; + debug_server_log_call_entry(0x80010000u); +} +''' + gcc = find_gcc() + with tempfile.TemporaryDirectory() as temp_dir: + temp = pathlib.Path(temp_dir) + c_path = temp / "overlay_shim_contract.c" + out_path = temp / ("overlay_shim_contract.dll" if os.name == "nt" + else "overlay_shim_contract.so") + c_path.write_text(source, encoding="ascii") + command = [ + gcc, "-shared", "-O2", + "-DPSX_OVERLAY_DLL_BUILD", + "-DPSX_NO_DEBUG_TOOLS", + "-DPSX_ENABLE_BLOCK_CYCLES=1", + "-DPSX_OVERLAY_FLAVOR=0", + ] + if os.name != "nt": + command.append("-fPIC") + command.extend([ + str(c_path), "-o", str(out_path), f"-I{INCLUDE}", "-lm", + ]) + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + return result.returncode + if not out_path.is_file(): + raise AssertionError("overlay compiler reported success without output") + print("PASS: overlay callback shim compiles against runtime headers") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/runtime/check_generated_sources.cmake b/runtime/check_generated_sources.cmake new file mode 100644 index 00000000..0b82262a --- /dev/null +++ b/runtime/check_generated_sources.cmake @@ -0,0 +1,72 @@ +# check_generated_sources.cmake — fail the build with a HUMAN message when a +# game's recompiled C is missing, instead of letting the C compiler emit a raw +# "cc1: fatal error: <file>: No such file or directory". +# +# The runtime links generated/<serial>_{full,dispatch}.c, which are produced by +# a SEPARATE step (the recompiler tool) that a from-source builder runs before +# cmake --build. Those paths are marked GENERATED in CMake so `cmake configure` +# succeeds before the first generation — which means a skipped generation step +# is only discovered deep in the build as an inscrutable compiler error naming a +# file the user has no reason to know how to produce. This script is run as a +# build-time dependency of the runtime target (see psxrecomp_add_runtime_target) +# so the failure surfaces first, names the missing files, and states the fix. +# +# Invoked via: cmake -DSOURCES=<;-list> -DTARGET=.. -DGAME_CONFIG=.. \ +# -DRECOMPILER=.. -DDOC=.. -P check_generated_sources.cmake + +if(NOT DEFINED SOURCES) + message(FATAL_ERROR "check_generated_sources.cmake requires SOURCES") +endif() + +set(_missing "") +foreach(_src IN LISTS SOURCES) + if(_src AND NOT EXISTS "${_src}") + list(APPEND _missing "${_src}") + endif() +endforeach() + +if(NOT _missing) + return() # all recompiled sources present — nothing to say. +endif() + +if(NOT GAME_CONFIG) + set(GAME_CONFIG "game.toml") +endif() +if(NOT RECOMPILER) + set(RECOMPILER "psxrecomp/recompiler/build/psxrecomp-game") +endif() +if(NOT DOC) + set(DOC "psxrecomp/docs/BUILDING.md (\"Build and run a game\")") +endif() +if(NOT TARGET) + set(TARGET "the runtime") +endif() + +string(REPLACE ";" "\n " _missing_pretty "${_missing}") + +message(FATAL_ERROR + "\n" + "=====================================================================\n" + " Recompiled game code is MISSING — cannot build ${TARGET}.\n" + "=====================================================================\n" + "\n" + " These files do not exist yet:\n" + " ${_missing_pretty}\n" + "\n" + " They are not shipped and not produced by this build. They are emitted\n" + " by the recompiler tool, which you must run ONCE before building (and\n" + " again whenever you change the disc/EXE or gen-time settings):\n" + "\n" + " 1. Build the recompiler tool (separate CMake tree, one time):\n" + " cmake -S psxrecomp/recompiler -B psxrecomp/recompiler/build \\\n" + " -G Ninja -DCMAKE_BUILD_TYPE=Release\n" + " cmake --build psxrecomp/recompiler/build\n" + "\n" + " 2. Extract your game's PS-X EXE from your own disc image, then\n" + " generate the recompiled C (paths come from ${GAME_CONFIG}):\n" + " ${RECOMPILER} --config ${GAME_CONFIG}\n" + "\n" + " 3. Re-run the build.\n" + "\n" + " Full walkthrough: ${DOC}\n" + "=====================================================================\n") diff --git a/runtime/check_target_name_collisions.cmake b/runtime/check_target_name_collisions.cmake new file mode 100644 index 00000000..f20b3df7 --- /dev/null +++ b/runtime/check_target_name_collisions.cmake @@ -0,0 +1,70 @@ +# Guard against build targets whose output files collide on a case-insensitive +# filesystem (Windows and macOS — two of the three platforms we document). +# +# WHY THIS EXISTS +# --------------- +# Until 2026-07-27 recompiler/CMakeLists.txt defined BOTH `psxrecomp` (the +# user-facing CLI, from src/main_cli.cpp) and `PSXRecomp` (a legacy duplicate of +# psxrecomp-game). CMake treats those as two distinct targets and links both +# happily, but on Windows they write to the SAME path on disk. Whichever linked +# last silently overwrote the other. The CLI lost, so the binary shipped as +# `psxrecomp.exe` in the released CLI package was actually the game recompiler, +# whose arguments match nothing in the README. Nothing failed, nothing warned, +# and it never reproduced on Linux — so it shipped for a long time. +# +# A rename alone would not prevent recurrence. This check does. +# +# Compares OUTPUT_NAME (falling back to the target name, which is what CMake +# uses when OUTPUT_NAME is unset) rather than the target name itself, because +# the on-disk filename is what actually collides — two differently-named targets +# can still be given the same OUTPUT_NAME. +# +# Usage: call at the END of a CMakeLists.txt, after all targets are declared. +# include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_target_name_collisions.cmake) +# psxrecomp_check_output_name_collisions() + +function(psxrecomp_check_output_name_collisions) + get_property(_targets + DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + PROPERTY BUILDSYSTEM_TARGETS) + + set(_seen_lower "") + set(_seen_target "") + set(_seen_output "") + + foreach(_t IN LISTS _targets) + get_target_property(_type ${_t} TYPE) + # Only real artifacts land on disk under a name we control. + if(NOT _type STREQUAL "EXECUTABLE" + AND NOT _type STREQUAL "SHARED_LIBRARY" + AND NOT _type STREQUAL "STATIC_LIBRARY" + AND NOT _type STREQUAL "MODULE_LIBRARY") + continue() + endif() + + get_target_property(_out ${_t} OUTPUT_NAME) + if(NOT _out) + set(_out "${_t}") + endif() + + string(TOLOWER "${_out}" _lower) + list(FIND _seen_lower "${_lower}" _idx) + if(NOT _idx EQUAL -1) + list(GET _seen_target ${_idx} _other_target) + list(GET _seen_output ${_idx} _other_output) + message(FATAL_ERROR + "Case-insensitive output-name collision in ${CMAKE_CURRENT_SOURCE_DIR}:\n" + " target '${_other_target}' produces '${_other_output}'\n" + " target '${_t}' produces '${_out}'\n" + "These differ only by case, so on Windows and macOS they write the " + "SAME file and the last one linked silently wins. Rename one target " + "(or give it a distinct OUTPUT_NAME). Do not suppress this check: it " + "exists because exactly this bug shipped the wrong binary in the " + "released Windows CLI package.") + endif() + + list(APPEND _seen_lower "${_lower}") + list(APPEND _seen_target "${_t}") + list(APPEND _seen_output "${_out}") + endforeach() +endfunction() diff --git a/runtime/include/bios_hle_plan.h b/runtime/include/bios_hle_plan.h new file mode 100644 index 00000000..5950b422 --- /dev/null +++ b/runtime/include/bios_hle_plan.h @@ -0,0 +1,74 @@ +/* bios_hle_plan.h — the ONE place the BIOS-backend axes are decided. + * + * The HLE tier has two INDEPENDENT axes, and conflating them was a real bug: + * + * call_hle — service implemented kernel calls (B0 events) in HLE instead of + * the recompiled BIOS. Needs the per-image `deliver_event_ret` + * anchor, because a delivered callback has to return through the + * exact $ra the kernel's DeliverEvent loop uses. An image that + * does not export it CANNOT run call-HLE at all. + * + * boot_skip — the one-shot shell intercept that skips the BIOS boot animation + * and lets Main() proceed straight to SYSTEM.CNF + game EXE load. + * Needs only the per-image `shell_entry_phys` anchor, and works + * under pure LLE: nothing is synthesized, the shell call simply + * returns immediately, exactly as if the player had let the disc + * menu exit on its own. + * + * THE BUG THIS MODULE FIXES (2026-07-27). main.cpp used to force the requested + * `bios_hle` flag to false when an image exported no `deliver_event_ret` (the + * correct call-HLE decision), and THEN derived boot_skip from that already- + * mutated flag. So on OpenBIOS — which deliberately omits deliver_event_ret + * until its B0 semantics are validated, but does export shell_entry_phys — the + * player's boot-skip silently stopped working: choosing OpenBIOS sat the player + * in the boot animation while the same build on retail SCPH-1001 went straight + * to the game. One flag, two behaviours, depending on a BIOS detail no player + * can see. + * + * The rule is now explicit and image-independent: `bios_hle` (or the deprecated + * `fast_boot` alias) means "skip the BIOS boot and go to the game", on EVERY + * BIOS that exports a shell entry. Whether the same flag additionally routes + * kernel calls through HLE is a separate, anchor-gated question. + * + * Kept dependency-free (no CPUState, no psx_bios_image, no runtime globals) so + * the whole decision matrix is unit-tested directly — see + * runtime/tests/test_bios_hle_plan.c. + */ +#ifndef PSXRECOMP_BIOS_HLE_PLAN_H +#define PSXRECOMP_BIOS_HLE_PLAN_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* What the config/env asked for, plus what the linked BIOS image can support. */ +typedef struct PsxBiosHleRequest { + /* ---- requested (game.toml [runtime] / settings.toml / env) ---- */ + int bios_hle; /* [runtime] bios_hle, PSX_BIOS_HLE */ + int keep_intro; /* bios_hle_keep_intro, PSX_BIOS_HLE_KEEP_INTRO */ + int fast_boot; /* deprecated alias: boot-skip only, never call-HLE */ + /* ---- capabilities of the SELECTED backend (psx_bios_image anchors) ---- */ + int have_deliver_event_ret; /* image exports the DeliverEvent $ra anchor */ + int have_shell_entry; /* image exports the shell entry PC */ + /* ---- session shape ---- */ + int have_game_entry; /* a game EXE is loaded (0 = BIOS-only run) */ +} PsxBiosHleRequest; + +typedef struct PsxBiosHlePlan { + int call_hle; /* pass to psx_bios_hle_configure() as call_hle */ + int boot_skip; /* pass to psx_bios_hle_configure() as boot_skip */ + /* Denial flags: the feature was asked for and is being refused because the + * SELECTED image cannot support it. Purely for the startup banner — a + * silent downgrade is what made the original bug invisible. */ + int call_hle_denied; + int boot_skip_denied; +} PsxBiosHlePlan; + +/* Pure function: same request in, same plan out, no globals touched. */ +PsxBiosHlePlan psx_bios_hle_plan(PsxBiosHleRequest req); + +#ifdef __cplusplus +} +#endif + +#endif /* PSXRECOMP_BIOS_HLE_PLAN_H */ diff --git a/runtime/include/cue_sheet.h b/runtime/include/cue_sheet.h new file mode 100644 index 00000000..33f060bc --- /dev/null +++ b/runtime/include/cue_sheet.h @@ -0,0 +1,77 @@ +// cue_sheet.h — the single cue-sheet parser for the runtime. +// +// A .cue is the authoritative table of contents for a PS1 disc dump: it names +// the backing BINARY file(s) and the TRACK/INDEX layout (data track, CD-DA +// audio tracks, pregaps). Everything that needs to understand a cue goes +// through here so the mount path (iso_reader.cpp), the identity/verify path +// (disc_identity.cpp) and the picked-path resolver (disc_path.cpp) can never +// disagree about what a given cue describes. +// +// Parsing is deliberately permissive about surface syntax (case, quoting, +// leading whitespace) and strict about semantics: a FILE whose type is not +// BINARY is reported rather than guessed at, because WAVE/MP3 payloads have no +// fixed sector geometry and mis-mapping the TOC is worse than refusing the +// image. + +#pragma once + +#include <cstdint> +#include <filesystem> +#include <string> +#include <vector> + +namespace PSXRecompV4 { + +// One FILE line, resolved against the cue's own directory. +struct CueFileRef { + std::string name; // exactly as written in the cue + std::filesystem::path path; // resolved (absolute where possible) + bool is_binary = false; // FILE type token was BINARY + bool exists = false; // resolved path is a real file +}; + +// One TRACK, with its INDEX times still expressed relative to the OWNING FILE +// (the cue's own convention). Converting to disc-relative LBAs needs each +// file's sector count, which only the mounting code knows. +struct CueTrackRef { + int number = 0; + bool is_audio = false; + size_t file_index = 0; // index into CueSheet::files + uint32_t index01 = 0; // track start, file-relative LBA + uint32_t index00 = 0; // pregap start, file-relative LBA + bool has_index00 = false; +}; + +struct CueSheet { + bool opened = false; // the .cue itself was readable + + std::vector<CueFileRef> files; // in cue order + std::vector<CueTrackRef> tracks; // in cue order + + // True when every FILE line named a BINARY payload that exists on disk — + // i.e. this cue can actually be mounted as written. + bool usable() const; + + // True when at least one FILE line named a non-BINARY payload. + bool has_non_binary_file() const; + + // Index of the file backing the first non-audio track, else 0. This is the + // file that carries SYSTEM.CNF / the ISO9660 header, so it is the one + // identity and CRC checks must read. + size_t data_file_index() const; +}; + +// Case-insensitive extension test. `dotted_ext` includes the dot, e.g. ".cue". +bool path_has_extension_ci(const std::filesystem::path& p, const char* dotted_ext); + +// Resolve `name` (as written in a cue) against `dir`. Falls back to a +// case-insensitive directory scan when the literal name does not exist, so a +// redump cue written on Windows still mounts on a case-sensitive filesystem. +// Returns the literal resolution when nothing matches. +std::filesystem::path resolve_cue_relative_file(const std::filesystem::path& dir, + const std::string& name); + +// Parse `cue_path`. A sheet whose `opened` is false could not be read at all. +CueSheet parse_cue_sheet(const std::filesystem::path& cue_path); + +} // namespace PSXRecompV4 diff --git a/runtime/include/disc_path.h b/runtime/include/disc_path.h new file mode 100644 index 00000000..63bd1828 --- /dev/null +++ b/runtime/include/disc_path.h @@ -0,0 +1,52 @@ +// disc_path.h — canonicalize whatever disc file the user actually picked. +// +// Players do not reliably know whether to hand us the .cue or the .bin, and +// both are legitimate things to find in a dump folder. Either pick must mount +// and must verify identically, so every entry point that accepts a disc path +// (game.toml [runtime] disc, --disc, the cached disc.cfg, the launcher's +// "Change Disc" picker, the first-run file dialog) routes through +// resolve_disc_path() first. +// +// The rule is "prefer the cue, but never require it": +// +// picked a .cue that resolves -> mount the cue (keeps the TOC) +// picked a .cue with a missing bin -> mount a sibling image, if one exists +// picked a .bin/.iso/.img owned by a cue -> mount that cue (keeps the TOC) +// picked a .bin/.iso/.img with no cue -> mount it directly +// +// Preferring the cue matters beyond tidiness: the cue is the only place the +// CD-DA track layout lives. Silently swapping a cue for its same-named .bin +// (which is what the runtime used to do) drops every audio track and pregap on +// a single-file multi-track dump. +// +// `data` is always the binary carrying the ISO9660 header and SYSTEM.CNF, so +// header / serial / CRC32 checks read the same bytes no matter which file the +// user picked — that is what keeps the "Disc verified" verdict stable across +// picks. + +#pragma once + +#include <filesystem> +#include <string> + +namespace PSXRecompV4 { + +struct DiscPathResolution { + std::filesystem::path picked; // what the caller asked for (absolute where possible) + std::filesystem::path mount; // hand this to ISOReader::Open / cdrom_init + std::filesystem::path data; // data-track binary; read identity/CRC from this + + bool from_cue = false; // `mount` is a cue sheet + bool upgraded_to_cue = false; // caller picked a bin; we found the cue that owns it + bool cue_fallback = false; // caller picked an unusable cue; fell back to a bin + + // Human-readable note describing any substitution, "" when `mount` is just + // `picked`. Suitable for a launcher warning or a startup banner. + std::string note; +}; + +// Never fails: an unrecognizable or missing path resolves to itself, so the +// caller's existing "could not open the disc" reporting still fires. +DiscPathResolution resolve_disc_path(const std::filesystem::path& picked); + +} // namespace PSXRecompV4 diff --git a/runtime/include/launcher_device.h b/runtime/include/launcher_device.h new file mode 100644 index 00000000..bc7ca194 --- /dev/null +++ b/runtime/include/launcher_device.h @@ -0,0 +1,47 @@ +// launcher_device.h - controller-source conversion at the recomp-ui C ABI. + +#pragma once + +#include <algorithm> +#include <cctype> +#include <string> + +namespace PSXRecompV4 { + +inline std::string trim_launcher_device(const std::string& device) { + const auto first = std::find_if_not(device.begin(), device.end(), + [](unsigned char c) { return std::isspace(c) != 0; }); + const auto last = std::find_if_not(device.rbegin(), device.rend(), + [](unsigned char c) { return std::isspace(c) != 0; }).base(); + return first < last ? std::string(first, last) : std::string(); +} + +inline std::string normalize_launcher_device(const std::string& device) { + std::string normalized = trim_launcher_device(device); + std::transform(normalized.begin(), normalized.end(), normalized.begin(), + [](unsigned char c) { return static_cast<char>(std::tolower(c)); }); + return normalized; +} + +inline int launcher_source_from_device(const std::string& device) { + const std::string normalized = normalize_launcher_device(device); + if (normalized.empty() || normalized == "none") return 0; + if (normalized == "keyboard") return 1; + return 2; +} + +inline std::string launcher_device_from_source( + int source, const std::string& previous_device) { + if (source <= 0) return "none"; + if (source == 1) return "keyboard"; + + // recomp-ui's C ABI currently returns a source category, not the selected + // controller GUID. Preserve an existing gamepad/GUID assignment; when the + // user switched from None/Keyboard, persist the runtime's first-pad alias. + if (launcher_source_from_device(previous_device) == 2) { + return trim_launcher_device(previous_device); + } + return "gamepad"; +} + +} // namespace PSXRecompV4 diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h new file mode 100644 index 00000000..f9953423 --- /dev/null +++ b/runtime/include/mod_packages.h @@ -0,0 +1,335 @@ +#pragma once + +#include <cstdint> +#include <filesystem> +#include <functional> +#include <map> +#include <string> +#include <vector> + +namespace PSXRecompV4 { + +enum class ModOptionType { + Boolean, + Choice, + Integer, +}; + +struct ModChoice { + std::string value; + std::string label; +}; + +struct ModOption { + std::string feature_id; + std::string id; + std::string label; + std::string description; + std::string group; + ModOptionType type = ModOptionType::Boolean; + std::string default_value; + int64_t min_value = 0; + int64_t max_value = 0; + int64_t step = 1; + std::vector<ModChoice> choices; +}; + +struct ModFeature { + std::string id; + std::string name; + std::string description; + std::string group = "General"; + bool default_enabled = false; + bool legacy = false; +}; + +enum class ModConstraintKind { + OrderedInteger, + RequiresFeature, +}; + +enum class ModConstraintDirection { + Nondecreasing, + Nonincreasing, +}; + +struct ModConstraint { + std::string feature_id; + ModConstraintKind kind = ModConstraintKind::OrderedInteger; + ModConstraintDirection direction = + ModConstraintDirection::Nondecreasing; + std::vector<std::string> options; + std::string required_feature_id; + std::string required_option_id; + std::string required_value; +}; + +struct ModRequirement { + std::string id; + std::string version; +}; + +struct ModTarget { + std::string game_id; + std::string exe_sha256; + std::string disc_sha256; +}; + +enum class ModPatchTarget { + MainExe, + DiscRaw, + DiscUser, +}; + +enum class ModValueEncoding { + U8, + U16LE, + U32LE, + MipsLuiOriU32, +}; + +enum class ModIntegerPredicateOp { + Equal, + NotEqual, + Less, + LessEqual, + Greater, + GreaterEqual, +}; + +struct ModIntegerPredicate { + bool present = false; + std::string option; + ModIntegerPredicateOp op = ModIntegerPredicateOp::Equal; + int64_t value = 0; +}; + +struct ModPatchField { + uint64_t offset = 0; + std::vector<uint8_t> replacement; + std::string replace_from_option; + ModValueEncoding replace_encoding = ModValueEncoding::U8; + int64_t replace_addend = 0; +}; + +struct ModPatch { + std::string feature_id; + ModPatchTarget target = ModPatchTarget::MainExe; + uint64_t location = 0; /* guest address or canonical disc-stream byte offset */ + std::vector<uint8_t> expected; + std::vector<uint8_t> replacement; + std::string replace_from_option; + ModValueEncoding replace_encoding = ModValueEncoding::U8; + uint64_t replace_offset = 0; + int64_t replace_addend = 0; + bool replace_omit_when_default = false; + std::string when_option; + std::string when_value; + std::map<std::string, std::string> when; + ModIntegerPredicate when_integer; + std::vector<ModPatchField> fields; + int64_t order = 0; +}; + +struct ModOverlay { + std::string feature_id; + ModPatchTarget target = ModPatchTarget::DiscRaw; + uint64_t location = 0; + std::filesystem::path file; + std::string sha256; + std::string expected_sha256; + uint64_t size = 0; + std::map<std::string, std::string> when; + int64_t order = 0; +}; + +struct ModPlugin { + std::string feature_id; + std::string id; + std::map<std::string, std::string> when; + int64_t order = 0; +}; + +struct ModDerivedDisc { + std::string kind = "vcdiff"; + std::filesystem::path patch; + std::string patch_sha256; + uint64_t output_size = 0; + std::string output_sha256; + std::string when_option; + std::string when_value; + std::map<std::string, std::string> when; +}; + +struct ModPackage { + uint32_t format_version = 0; + std::string id; + std::string version; + std::string name; + std::string author; + std::string description; + std::string license; + std::string resolver = "declarative"; + std::string save_compatibility = "shared"; + std::filesystem::path root; + std::vector<ModTarget> targets; + std::vector<ModRequirement> dependencies; + std::vector<std::string> conflicts; + std::vector<ModFeature> features; + std::vector<ModOption> options; + std::vector<ModConstraint> constraints; + std::vector<ModPatch> patches; + std::vector<ModOverlay> overlays; + std::vector<ModPlugin> plugins; + std::vector<ModDerivedDisc> derived_discs; +}; + +struct ModFeatureSelection { + bool enabled = false; + bool has_enabled = false; + std::map<std::string, std::string> values; +}; + +struct ModSelection { + /* v1 migration state. Feature-style manifests do not use these fields. */ + bool enabled = false; + std::string version; + std::map<std::string, std::string> values; + std::map<std::string, ModFeatureSelection> features; +}; + +struct ModResolution { + bool ok = false; + std::string fingerprint; + std::vector<const ModPackage*> ordered; + struct Write { + struct Field { + uint64_t offset = 0; + std::vector<uint8_t> replacement; + }; + ModPatchTarget target = ModPatchTarget::MainExe; + uint64_t location = 0; + std::vector<uint8_t> expected; + std::vector<uint8_t> replacement; + std::vector<Field> fields; + std::string package_id; + std::string feature_id; + }; + std::vector<Write> writes; + struct Overlay { + ModPatchTarget target = ModPatchTarget::DiscRaw; + uint64_t location = 0; + std::vector<uint8_t> payload; + std::string payload_sha256; + std::string expected_sha256; + std::string package_id; + std::string feature_id; + }; + std::vector<Overlay> overlays; + struct DerivedDisc { + std::string kind; + std::filesystem::path patch; + std::string patch_sha256; + uint64_t output_size = 0; + std::string output_sha256; + std::string package_id; + }; + std::vector<DerivedDisc> derived_discs; + struct Plugin { + std::string id; + std::string package_id; + std::string feature_id; + }; + std::vector<Plugin> plugins; + struct Diagnostic { + std::string message; + std::string resource; + std::string package_id; + std::string feature_id; + std::string other_package_id; + std::string other_feature_id; + }; + std::vector<Diagnostic> diagnostics; + std::vector<std::string> errors; +}; + +struct ModBuiltinResolverContext { + const std::map<std::string, const ModPackage*>* active_packages = nullptr; + const std::map<std::string, ModSelection>* selections = nullptr; +}; + +using ModBuiltinResolver = std::function<bool( + const ModPackage& package, + const ModSelection& selection, + const ModBuiltinResolverContext& context, + std::vector<ModResolution::Write>& writes, + std::vector<std::string>& errors)>; + +class ModPackageManager { +public: + explicit ModPackageManager(std::filesystem::path mods_root = {}); + + void set_root(std::filesystem::path mods_root); + const std::filesystem::path& root() const { return root_; } + + bool scan(std::string* error = nullptr); + bool load_state(std::string* error = nullptr); + bool save_state(std::string* error = nullptr) const; + + bool install_archive(const std::filesystem::path& archive, + std::string* installed_id = nullptr, + std::string* installed_version = nullptr, + std::string* error = nullptr); + bool remove_version(const std::string& id, const std::string& version, + std::string* error = nullptr); + + bool set_enabled(const std::string& id, bool enabled, std::string* error = nullptr); + bool select_version(const std::string& id, const std::string& version, + std::string* error = nullptr); + bool set_option(const std::string& id, const std::string& option, + const std::string& value, std::string* error = nullptr); + bool set_feature_enabled(const std::string& package_id, + const std::string& feature_id, bool enabled, + std::string* error = nullptr); + bool set_feature_option(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id, + const std::string& value, + std::string* error = nullptr); + + const std::map<std::string, std::map<std::string, ModPackage>>& packages() const { + return packages_; + } + const std::map<std::string, ModSelection>& selections() const { return selections_; } + const ModPackage* selected_package(const std::string& id) const; + const ModFeature* selected_feature(const std::string& package_id, + const std::string& feature_id) const; + bool feature_enabled(const std::string& package_id, + const std::string& feature_id) const; + std::string feature_option_value(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id) const; + + ModResolution resolve(const std::string& game_id, + const std::string& exe_sha256 = {}, + const std::string& disc_sha256 = {}) const; + + static bool read_manifest(const std::filesystem::path& path, ModPackage& out, + std::string* error = nullptr); + +private: + std::filesystem::path root_; + std::map<std::string, std::map<std::string, ModPackage>> packages_; + std::map<std::string, ModSelection> selections_; +}; + +bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver); +void mod_clear_builtin_resolvers_for_tests(); +bool mod_register_activation_plugin(const std::string& id, void (*callback)(void)); +bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); +bool mod_plugin_registered(const std::string& id); +void mod_invoke_activation_plugin(const std::string& id); +void mod_invoke_vblank_plugin(const std::string& id); +void mod_clear_plugins_for_tests(); + +} // namespace PSXRecompV4 diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h new file mode 100644 index 00000000..526397bd --- /dev/null +++ b/runtime/include/mod_plugins.h @@ -0,0 +1,128 @@ +#pragma once + +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*PSXModVBlankCallback)(void); +typedef void (*PSXModActivationCallback)(void); + +/* + * Register a trusted, statically linked plugin implementation. Package + * manifests select implementations by this stable id; archives never provide + * native code or symbol names. + */ +int psx_mod_register_activation_plugin(const char* id, + PSXModActivationCallback callback); +int psx_mod_register_vblank_plugin(const char* id, + PSXModVBlankCallback callback); + +/* Narrow guest services available to trusted plugin callbacks. */ +int psx_mod_game_started(void); +uint8_t psx_mod_read_byte(uint32_t address); +void psx_mod_write_byte(uint32_t address, uint8_t value); + +/* + * Read the committed value of one of this package's declared options, as the + * player left it in the launcher (or the manifest default when untouched). + * Writes a NUL-terminated string into `out` and returns 1; returns 0 with + * out[0] = '\0' when the plan is not committed, the ids do not resolve, or the + * value does not fit — the caller then applies its own default rather than + * treating an empty string as a selection. + * + * Why this exists: the manifest schema already carries typed, validated, + * launcher-rendered, persisted options ([[option]] boolean/choice/integer), but + * an activation callback takes no arguments and had no way to read them, so a + * trusted plugin could only ever be an on/off switch. A parameterised feature + * then had to be modelled as one feature per value — and `constraint` only + * expresses ordered_integer WITHIN a feature, so those pseudo-features could + * not even be made mutually exclusive. This closes that gap: one feature, one + * option, the plugin reads what was chosen. + * + * Ids are passed explicitly because registration is by plugin id alone and the + * callback carries no package/feature context. + */ +int psx_mod_option_value(const char* package_id, const char* feature_id, + const char* option_id, char* out, uint32_t out_size); + +/* + * Request a fixed host display aspect before renderer/window initialization. + * Intended for activation callbacks that move a game's widescreen enhancement + * out of generic Settings and into its mod catalog. + */ +int psx_mod_set_fixed_display_aspect(uint32_t numerator, + uint32_t denominator); +/* + * Request resize-driven widescreen, capped at the supplied maximum aspect. + * The current fixed aspect continues to shape the initial game window, so a + * plugin may select that first with psx_mod_set_fixed_display_aspect(). + */ +int psx_mod_set_adaptive_display_aspect(uint32_t max_numerator, + uint32_t max_denominator); +/* + * Set the wall-clock cadence of simulated guest VBlanks. A value of zero + * removes frontend pacing; 60 and higher request that many native guest + * update opportunities per host second. This intentionally changes whole- + * machine realtime speed and is for experimental game-owned frame-rate mods. + */ +int psx_mod_set_native_vblank_rate(uint32_t frames_per_second); + +/* + * Enable presentation-only frame interpolation while leaving guest VBlank, + * game logic, timers, and audio at their stock cadence. The OpenGL presenter + * blends between completed guest frames at the requested output rate. + * A value of zero follows the measured host-display refresh rate. + */ +int psx_mod_set_frame_interpolation(uint32_t frames_per_second); +/* + * Choose how the OpenGL presenter combines completed frames. Linear is the + * legacy full-frame crossfade. Motion-adaptive retains interpolation for + * small temporal changes but switches large changes cleanly to reduce the + * double-image trails produced by moving objects. + */ +enum { + PSX_MOD_FRAME_INTERPOLATION_LINEAR = 0, + PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE = 1 +}; +int psx_mod_set_frame_interpolation_blend(uint32_t blend_mode); +int psx_mod_set_auto_skip_fmv(int enabled); + +/* + * Override one player's resolved controller presentation mode for this launch. + * This is intentionally a trusted-plugin API, not a generic launcher setting: + * games may hide Hybrid from their normal selector while offering it as an + * explicit game-owned mod. + */ +enum { + PSX_MOD_CONTROLLER_HYBRID = 0, + PSX_MOD_CONTROLLER_ANALOG = 1, + PSX_MOD_CONTROLLER_DIGITAL = 2 +}; +int psx_mod_set_controller_mode_override(uint32_t player, + uint32_t controller_mode); + +/* + * Register a C plugin before main() on the compilers supported by the runtime. + * The registry itself uses function-local initialization, so constructor order + * between game sources and the framework is safe. + */ +#if defined(_MSC_VER) +#pragma section(".CRT$XCU", read) +#define PSX_MOD_CONSTRUCTOR(name) \ + static void __cdecl name(void); \ + __declspec(allocate(".CRT$XCU")) \ + static void (__cdecl* name##_constructor)(void) = name; \ + static void __cdecl name(void) +#elif defined(__GNUC__) || defined(__clang__) +#define PSX_MOD_CONSTRUCTOR(name) \ + static void name(void) __attribute__((constructor)); \ + static void name(void) +#else +#error "PSX mod plugin registration needs a supported constructor mechanism" +#endif + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h new file mode 100644 index 00000000..a4f5a66a --- /dev/null +++ b/runtime/include/mod_runtime.h @@ -0,0 +1,51 @@ +#pragma once + +#include <stdint.h> + +#ifdef __cplusplus +#include <filesystem> +#include <string> +#if defined(RECOMP_LAUNCHER) +#include "recomp_launcher.h" +#endif + +namespace PSXRecompV4 { + +bool mod_runtime_initialize(const std::filesystem::path& root, + const std::string& game_id, + uint32_t game_entry_pc, + const std::filesystem::path& exe_path = {}, + std::string* error = nullptr); +bool mod_runtime_commit(const std::filesystem::path& disc_path = {}, + std::string* error = nullptr); +/* Drop the in-session mod plan for a netplay launch without rewriting the + * user's persisted offline selection on disk. */ +bool mod_runtime_clear_for_netplay(std::string* error = nullptr); +const std::string& mod_runtime_fingerprint(); +const std::filesystem::path& mod_runtime_effective_disc_path(); + +#if defined(RECOMP_LAUNCHER) +const ::RecompLauncherCModProvider* mod_runtime_launcher_provider(); +#endif + +} // namespace PSXRecompV4 +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Called before a guest dispatch. Applies the complete main-EXE plan + * transactionally on the first dispatch to the configured entry point. */ +void mod_runtime_on_dispatch(uint32_t target); +/* Invokes activation callbacks for the committed plan. Call after the final + * launcher commit and before renderer/window initialization. */ +void mod_runtime_activate_plugins(void); +void mod_runtime_on_vblank(void); +void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, + uint8_t* bytes, uint32_t size); +void mod_runtime_enable_disc_patches(void); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/psx_bios_backend.h b/runtime/include/psx_bios_backend.h new file mode 100644 index 00000000..c5e1842a --- /dev/null +++ b/runtime/include/psx_bios_backend.h @@ -0,0 +1,83 @@ +/* psx_bios_backend.h — the routing seam between the runtime and whichever + * recompiled BIOS is active. + * + * Every build links more than one recompiled BIOS (bundled OpenBIOS plus a + * retail image). Each one exports exactly ONE symbol — its backend descriptor, + * named <STEM>_psx_bios_backend — and everything else it defines is either + * static or stem-prefixed, so the images cannot collide at link time. + * + * The runtime picks a backend at startup (docs/BIOS_SELECTION.md: no explicit + * player choice -> OpenBIOS; an explicit, identity-matching choice -> that + * image) and publishes it as psx_bios_active. + * + * Callers do not go through this struct. The unprefixed psx_dispatch() / + * psx_dispatch_call() the game's generated C and the runtime already call are + * thin forwarders (psx_bios_backend.c) that route to the active backend, and + * psx_bios_image is assigned from it at selection time — so adding a second + * BIOS changed no call sites. + * + * Adding another BIOS later is one more descriptor and one more registry + * entry; it is not another round of symbol collisions. + */ +#ifndef PSX_BIOS_BACKEND_H +#define PSX_BIOS_BACKEND_H + +#include <stdint.h> + +#include "psx_bios_image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct CPUState; + +typedef struct PsxBiosBackend { + /* Identity, kernel-bless window and HLE anchors for this image. Copied + * into the global psx_bios_image when this backend is selected. */ + const PsxBiosImageInfo *image; + + /* Dispatch entry points for this image. */ + void (*dispatch)(struct CPUState *cpu, uint32_t addr); + void (*dispatch_call)(struct CPUState *cpu, uint32_t addr, + uint32_t return_addr); + + /* Kernel body-extent table used by the kernel-image bless mechanism + * (memory.c). Published as psx_bios_kernel_bodies/_count on selection. + * + * The native call-stub table is deliberately absent: it is only read by + * the generated dispatch itself, so it stays static there. */ + const PsxKernelBody *kernel_bodies; + uint32_t kernel_body_count; +} PsxBiosBackend; + +/* The backend in use. Null before psx_bios_select() runs; every forwarder and + * every consumer of psx_bios_image depends on it being set first. */ +extern const PsxBiosBackend *psx_bios_active; + +/* Backends compiled into this binary, in preference order (bundled OpenBIOS + * first). Emitted by the build as psx_bios_registry.c. */ +extern const PsxBiosBackend *const psx_bios_registry[]; +extern const uint32_t psx_bios_registry_count; + +/* Look up a compiled-in backend by its profile id ("SCPH-1001", "OPENBIOS"). + * Null if this build does not carry it. */ +const PsxBiosBackend *psx_bios_find(const char *image_id); + +/* Select a backend and publish it (also assigns the global psx_bios_image). + * Returns 0 if backend is null. */ +int psx_bios_activate(const PsxBiosBackend *backend); + +/* The bundled, redistributable backend (image_bundled != 0), or null if this + * build has none. */ +const PsxBiosBackend *psx_bios_bundled(void); + +/* True if a player-supplied image is meaningful for this build (some linked + * backend is not the bundled one). */ +int psx_bios_has_selectable(void); + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_BIOS_BACKEND_H */ diff --git a/runtime/include/psx_bios_image.h b/runtime/include/psx_bios_image.h new file mode 100644 index 00000000..e5301ef6 --- /dev/null +++ b/runtime/include/psx_bios_image.h @@ -0,0 +1,80 @@ +/* psx_bios_image.h — the linked recompiled BIOS describes itself. + * + * The generated <stem>_dispatch.c DEFINES these symbols; the runtime READS + * them instead of hardcoding per-image constants (memory.c's old + * KBLESS_RAM_LO/HI/ROM_OFF, bios_hle.c's old PSX_SHELL_ENTRY_PHYS / + * KADDR_DELIVER_RET). Because the values are emitted next to the code they + * describe — couriered verbatim from the BIOS profile by the emitter — they + * cannot disagree with the linked BIOS. Same seam as psx_bios_kernel_bodies. + * + * A zero value means "this BIOS has no such thing" (no kernel-bless window, + * no shell boot-skip entry, no DeliverEvent HLE return): consumers must treat + * zero as structurally-unavailable, never as address 0. + */ +#ifndef PSX_BIOS_IMAGE_H +#define PSX_BIOS_IMAGE_H + +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* Kernel body-extent table for the kernel-image bless mechanism (memory.c). + * Emitted by full_function_emitter.cpp next to psx_bios_image. */ +typedef struct { + uint32_t key; /* dispatch key (RAM, normalized) */ + uint32_t body_lo; /* RAM extent of code reachable from key */ + uint32_t body_hi; /* exclusive */ +} PsxKernelBody; + +/* Published from the ACTIVE backend at selection (psx_bios_backend.c), + * hence a pointer rather than an array: a build links more than one + * recompiled BIOS and each carries its own table. Indexing is unchanged + * for consumers. */ +extern const PsxKernelBody *psx_bios_kernel_bodies; +extern uint32_t psx_bios_kernel_body_count; + +/* Native call-stub extents (A0/B0/C0). Layout shared with the generated + * dispatch, which keeps its own static table. */ +typedef struct { + uint32_t key; + uint32_t body_lo; + uint32_t body_hi; +} PsxNativeStub; + +typedef struct { + /* Kernel-bless window (profile address_model copy with kernel_bless): + * RAM [kbless_ram_lo, kbless_ram_hi) is the BIOS's boot-time verbatim + * copy of ROM file offset kbless_rom_off. All zero = no bless window. */ + uint32_t kbless_ram_lo; + uint32_t kbless_ram_hi; + uint32_t kbless_rom_off; + + /* HLE anchors (profile [recompiler.runtime_exports]); 0 = unavailable. */ + uint32_t shell_entry_phys; /* boot-skip trigger (bios_hle.c) */ + uint32_t deliver_event_ret; /* $ra after the kernel DeliverEvent jalr */ + + /* Image identity, computed from the ROM bytes at emit time. */ + uint32_t image_size; /* bytes */ + uint32_t image_crc32; /* IEEE CRC-32 (zlib) */ + uint32_t image_wordsum; /* sum of LE u32 words (savestate checksum) */ + const char* image_sha256; /* 64 lowercase hex chars */ + const char* image_id; /* profile [program] id, e.g. "SCPH-1001" */ + + /* Profile [program.image] redistributable: the BIOS ships WITH the game + * (e.g. OpenBIOS, MIT). The runtime then resolves the bundled image + * next to the build and hides the entire BIOS-selection surface + * (no picker, no bios.cfg, no launcher row). */ + int image_bundled; +} PsxBiosImageInfo; + +/* Assigned from the active backend at selection; not const for that + * reason. Reads are unchanged. */ +extern PsxBiosImageInfo psx_bios_image; + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_BIOS_IMAGE_H */ diff --git a/runtime/include/psx_sdl.h b/runtime/include/psx_sdl.h new file mode 100644 index 00000000..ef1ceb70 --- /dev/null +++ b/runtime/include/psx_sdl.h @@ -0,0 +1,243 @@ +#pragma once + +/* + * Runtime-facing SDL compatibility seam. + * + * PSXRecomp uses SDL3 by default, while PSX_SDL_BACKEND=SDL2 keeps the + * previous implementation available for comparison and older platforms. + */ +#if defined(PSX_SDL3) + +#define SDL_ENABLE_OLD_NAMES +#define SDL_FUNCTION_POINTER_IS_VOID_POINTER +#include <SDL3/SDL.h> +#include <SDL3/SDL_main.h> + +#ifndef SDL_WINDOW_SHOWN +#define SDL_WINDOW_SHOWN 0 +#endif +#ifndef SDL_WINDOW_FULLSCREEN_DESKTOP +#define SDL_WINDOW_FULLSCREEN_DESKTOP SDL_WINDOW_FULLSCREEN +#endif +#ifndef SDL_RENDERER_ACCELERATED +#define SDL_RENDERER_ACCELERATED 0x00000001u +#endif +#ifndef SDL_RENDERER_PRESENTVSYNC +#define SDL_RENDERER_PRESENTVSYNC 0x00000002u +#endif +#ifndef SDL_RENDERER_SOFTWARE +#define SDL_RENDERER_SOFTWARE 0x00000004u +#endif +#ifndef SDL_MUTEX_TIMEDOUT +#define SDL_MUTEX_TIMEDOUT 1 +#endif +#ifndef SDL_HINT_RENDER_SCALE_QUALITY +#define SDL_HINT_RENDER_SCALE_QUALITY "SDL_RENDER_SCALE_QUALITY" +#endif + +static inline int psx_sdl_init(SDL_InitFlags flags) +{ + return SDL_Init(flags) ? 0 : -1; +} + +static inline int psx_sdl_init_subsystem(SDL_InitFlags flags) +{ + return SDL_InitSubSystem(flags) ? 0 : -1; +} + +static inline SDL_Window *psx_sdl_create_window( + const char *title, int x, int y, int w, int h, Uint64 flags) +{ + (void)x; + (void)y; + return SDL_CreateWindow(title, w, h, (SDL_WindowFlags)flags); +} + +static inline SDL_Renderer *psx_sdl_create_renderer( + SDL_Window *window, int driver_index, Uint32 flags) +{ + (void)driver_index; + SDL_Renderer *renderer = SDL_CreateRenderer(window, NULL); + if (renderer && (flags & SDL_RENDERER_PRESENTVSYNC)) { + (void)SDL_SetRenderVSync(renderer, 1); + } + return renderer; +} + +static inline int psx_sdl_render_set_logical_size( + SDL_Renderer *renderer, int w, int h) +{ + return SDL_SetRenderLogicalPresentation( + renderer, w, h, SDL_LOGICAL_PRESENTATION_LETTERBOX) + ? 0 + : -1; +} + +static inline int psx_sdl_render_copy( + SDL_Renderer *renderer, SDL_Texture *texture, + const SDL_Rect *src, const SDL_Rect *dst) +{ + SDL_FRect srcf; + SDL_FRect dstf; + const SDL_FRect *srcp = NULL; + const SDL_FRect *dstp = NULL; + if (src) { + srcf.x = (float)src->x; + srcf.y = (float)src->y; + srcf.w = (float)src->w; + srcf.h = (float)src->h; + srcp = &srcf; + } + if (dst) { + dstf.x = (float)dst->x; + dstf.y = (float)dst->y; + dstf.w = (float)dst->w; + dstf.h = (float)dst->h; + dstp = &dstf; + } + return SDL_RenderTexture(renderer, texture, srcp, dstp) ? 0 : -1; +} + +static inline int psx_sdl_render_set_vsync( + SDL_Renderer *renderer, int enabled) +{ + return SDL_SetRenderVSync(renderer, enabled) ? 0 : -1; +} + +static inline int psx_sdl_gl_make_current( + SDL_Window *window, SDL_GLContext context) +{ + return SDL_GL_MakeCurrent(window, context) ? 0 : -1; +} + +static inline int psx_sdl_gl_set_swap_interval(int interval) +{ + return SDL_GL_SetSwapInterval(interval) ? 0 : -1; +} + +static inline void psx_sdl_gl_get_drawable_size( + SDL_Window *window, int *w, int *h) +{ + (void)SDL_GetWindowSizeInPixels(window, w, h); +} + +static inline int psx_sdl_get_display_usable_bounds( + SDL_DisplayID display, SDL_Rect *bounds) +{ + if (!display) display = SDL_GetPrimaryDisplay(); + return SDL_GetDisplayUsableBounds(display, bounds) ? 0 : -1; +} + +static inline int psx_sdl_get_current_display_mode( + SDL_DisplayID display, SDL_DisplayMode *mode) +{ + if (!display) display = SDL_GetPrimaryDisplay(); + const SDL_DisplayMode *current = SDL_GetCurrentDisplayMode(display); + if (!current) return -1; + *mode = *current; + return 0; +} + +static inline const Uint8 *psx_sdl_get_keyboard_state(int *count) +{ + return (const Uint8 *)(const void *)SDL_GetKeyboardState(count); +} + +static inline SDL_JoystickID psx_sdl_joystick_id_for_index(int index) +{ + int count = 0; + SDL_JoystickID result = 0; + SDL_JoystickID *ids = SDL_GetJoysticks(&count); + if (ids && index >= 0 && index < count) result = ids[index]; + SDL_free(ids); + return result; +} + +static inline int psx_sdl_num_joysticks(void) +{ + int count = 0; + SDL_JoystickID *ids = SDL_GetJoysticks(&count); + SDL_free(ids); + return count; +} + +static inline bool psx_sdl_is_game_controller(int index) +{ + SDL_JoystickID id = psx_sdl_joystick_id_for_index(index); + return id && SDL_IsGamepad(id); +} + +static inline SDL_Gamepad *psx_sdl_game_controller_open(int index) +{ + SDL_JoystickID id = psx_sdl_joystick_id_for_index(index); + return id ? SDL_OpenGamepad(id) : NULL; +} + +static inline SDL_GUID psx_sdl_joystick_get_device_guid(int index) +{ + return SDL_GetJoystickGUIDForID(psx_sdl_joystick_id_for_index(index)); +} + +static inline SDL_JoystickID psx_sdl_joystick_get_device_instance_id(int index) +{ + return psx_sdl_joystick_id_for_index(index); +} + +static inline void psx_sdl_joystick_get_guid_string( + SDL_GUID guid, char *buffer, int buffer_size) +{ + SDL_GUIDToString(guid, buffer, buffer_size); +} + +static inline int psx_sdl_cond_wait_timeout( + SDL_Condition *condition, SDL_Mutex *mutex, Sint32 timeout_ms) +{ + return SDL_WaitConditionTimeout(condition, mutex, timeout_ms) + ? 0 + : SDL_MUTEX_TIMEDOUT; +} + +#undef SDL_Init +#define SDL_Init psx_sdl_init +#undef SDL_InitSubSystem +#define SDL_InitSubSystem psx_sdl_init_subsystem +#undef SDL_CreateWindow +#define SDL_CreateWindow psx_sdl_create_window +#undef SDL_CreateRenderer +#define SDL_CreateRenderer psx_sdl_create_renderer +#undef SDL_RenderSetLogicalSize +#define SDL_RenderSetLogicalSize psx_sdl_render_set_logical_size +#undef SDL_RenderCopy +#define SDL_RenderCopy psx_sdl_render_copy +#undef SDL_RenderSetVSync +#define SDL_RenderSetVSync psx_sdl_render_set_vsync +#undef SDL_GL_MakeCurrent +#define SDL_GL_MakeCurrent psx_sdl_gl_make_current +#undef SDL_GL_SetSwapInterval +#define SDL_GL_SetSwapInterval psx_sdl_gl_set_swap_interval +#define SDL_GL_GetDrawableSize psx_sdl_gl_get_drawable_size +#undef SDL_GetDisplayUsableBounds +#define SDL_GetDisplayUsableBounds psx_sdl_get_display_usable_bounds +#undef SDL_GetCurrentDisplayMode +#define SDL_GetCurrentDisplayMode psx_sdl_get_current_display_mode +#undef SDL_GetKeyboardState +#define SDL_GetKeyboardState psx_sdl_get_keyboard_state +#define SDL_NumJoysticks psx_sdl_num_joysticks +#undef SDL_IsGameController +#define SDL_IsGameController psx_sdl_is_game_controller +#undef SDL_GameControllerOpen +#define SDL_GameControllerOpen psx_sdl_game_controller_open +#undef SDL_JoystickGetDeviceGUID +#define SDL_JoystickGetDeviceGUID psx_sdl_joystick_get_device_guid +#undef SDL_JoystickGetDeviceInstanceID +#define SDL_JoystickGetDeviceInstanceID psx_sdl_joystick_get_device_instance_id +#undef SDL_JoystickGetGUIDString +#define SDL_JoystickGetGUIDString psx_sdl_joystick_get_guid_string +#undef SDL_CondWaitTimeout +#define SDL_CondWaitTimeout psx_sdl_cond_wait_timeout + +#else + +#include <SDL.h> + +#endif diff --git a/runtime/include/psx_sdl_audio.h b/runtime/include/psx_sdl_audio.h new file mode 100644 index 00000000..a2055e83 --- /dev/null +++ b/runtime/include/psx_sdl_audio.h @@ -0,0 +1,34 @@ +#pragma once + +#include "psx_sdl.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (SDLCALL *PsxSdlAudioCallback)(void *userdata, Uint8 *stream, int len); + +typedef struct PsxSdlAudioSpec { + int freq; + SDL_AudioFormat format; + Uint8 channels; + Uint16 samples; + int allow_frequency_change; + PsxSdlAudioCallback callback; + void *userdata; +} PsxSdlAudioSpec; + +SDL_AudioDeviceID psx_sdl_audio_open( + const PsxSdlAudioSpec *want, PsxSdlAudioSpec *have); +int psx_sdl_audio_resume(SDL_AudioDeviceID device); +void psx_sdl_audio_close(SDL_AudioDeviceID device); +void psx_sdl_audio_clear(SDL_AudioDeviceID device); +Uint32 psx_sdl_audio_queued_size(SDL_AudioDeviceID device); +int psx_sdl_audio_queue( + SDL_AudioDeviceID device, const void *data, Uint32 bytes); +void psx_sdl_audio_lock(SDL_AudioDeviceID device); +void psx_sdl_audio_unlock(SDL_AudioDeviceID device); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/psx_sha256.h b/runtime/include/psx_sha256.h new file mode 100644 index 00000000..0b3e5abc --- /dev/null +++ b/runtime/include/psx_sha256.h @@ -0,0 +1,24 @@ +#pragma once +#include <stddef.h> +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct psx_sha256_ctx { + uint32_t h[8]; + uint64_t total; + uint8_t buffer[64]; + size_t buffered; +} psx_sha256_ctx; + +/* Self-contained public-domain SHA-256 implementation. */ +void psx_sha256_init(psx_sha256_ctx* ctx); +void psx_sha256_update(psx_sha256_ctx* ctx, const uint8_t* data, size_t len); +void psx_sha256_final(psx_sha256_ctx* ctx, uint8_t out[32]); +void psx_sha256_compute(const uint8_t *data, size_t len, uint8_t out[32]); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/psx_stick.h b/runtime/include/psx_stick.h new file mode 100644 index 00000000..6349dfb8 --- /dev/null +++ b/runtime/include/psx_stick.h @@ -0,0 +1,26 @@ +#ifndef PSX_STICK_H +#define PSX_STICK_H + +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Convert one SDL stick vector to the two unsigned DualShock axis bytes. + * + * deadzone and anti_deadzone use SDL's 0..32767 radial magnitude scale. + * Values inside deadzone are centred. Outside it, magnitude is remapped from + * anti_deadzone to full travel while direction is preserved. anti_deadzone=0 + * is an ordinary rescaled radial deadzone. + */ +void psx_stick_to_dualshock(int16_t x, int16_t y, + int deadzone, int anti_deadzone, + uint8_t *out_x, uint8_t *out_y); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/runtime/include/ws_aspect_cone_math.h b/runtime/include/ws_aspect_cone_math.h new file mode 100644 index 00000000..abb3adac --- /dev/null +++ b/runtime/include/ws_aspect_cone_math.h @@ -0,0 +1,29 @@ +#pragma once + +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* Test whether a camera-relative vector is inside a rectangular view cone. + * + * Coordinates use Tomba 2's X,Z,Y ordering. The camera forward vector is Q12. + * `threshold` is the game's Q10 cosine threshold (the SLTI immediate after its + * normalized dot product). `extent_pixels` expands only the horizontal + * half-angle from the vanilla 160-pixel half-width; the vertical half-angle is + * unchanged. + */ +int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, + int32_t fx, int32_t fz, int32_t fy, + uint32_t threshold, int extent_pixels); + +/* Widen a positive PSX 12-bit angular half-extent by the same horizontal + * projection scale used by a 320-wide 4:3 viewport. `extent_pixels` is the + * extra per-side reveal (including any configured guard). Identity is exact + * when extent_pixels is zero. Results stay below a 90-degree half-angle. */ +uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/ws_ui_group.h b/runtime/include/ws_ui_group.h new file mode 100644 index 00000000..07921723 --- /dev/null +++ b/runtime/include/ws_ui_group.h @@ -0,0 +1,34 @@ +#ifndef PSXRECOMP_WS_UI_GROUP_H +#define PSXRECOMP_WS_UI_GROUP_H + +#include <stddef.h> +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +#define WS_UI_GROUP_JOIN_GAP 24 + +typedef struct { + uint32_t key; + int32_t x; + int32_t width; + int32_t anchor; +} WsUiGroupItem; + +/* Assign one thirds anchor to each complete spatial run. Items with the same + * texture/row key join when their horizontal intervals touch or are separated + * by at most WS_UI_GROUP_JOIN_GAP pixels. This is intentionally a whole-frame + * operation: animated text is coherent on its first frame, not one frame late. */ +void ws_ui_group_assign(WsUiGroupItem *items, size_t count, + int32_t display_width, int dense_menu); + +int32_t ws_ui_anchor_for_bounds(int32_t x, int32_t width, + int32_t display_width); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/runtime/src/bios_hle_plan.c b/runtime/src/bios_hle_plan.c new file mode 100644 index 00000000..0dd993a4 --- /dev/null +++ b/runtime/src/bios_hle_plan.c @@ -0,0 +1,37 @@ +/* bios_hle_plan.c — see bios_hle_plan.h for why this is its own module. */ + +#include "bios_hle_plan.h" + +PsxBiosHlePlan psx_bios_hle_plan(PsxBiosHleRequest req) +{ + PsxBiosHlePlan p; + p.call_hle = 0; + p.boot_skip = 0; + p.call_hle_denied = 0; + p.boot_skip_denied = 0; + + /* Axis 1 — call-HLE. Anchor-gated on deliver_event_ret: servicing B0 + * events without the kernel's own DeliverEvent return address would send a + * delivered callback back to the wrong PC. An image that omits the anchor + * declares the tier structurally unavailable and config/env cannot force + * it on (OpenBIOS bring-up: the shell menu hung in a CD-event wait under + * HLE, booted fine under LLE). */ + const int want_call = (req.bios_hle != 0); + if (want_call) { + if (req.have_deliver_event_ret) p.call_hle = 1; + else p.call_hle_denied = 1; + } + + /* Axis 2 — boot-skip. Derived from the REQUESTED bios_hle, deliberately + * NOT from p.call_hle: refusing call-HLE on an image must not silently + * take the player's boot-skip away with it. Gated only on shell_entry (the + * one thing the intercept needs) and on a game being loaded — with no game + * EXE the shell IS the product, so there is nothing to skip TO. */ + const int want_boot = (want_call && !req.keep_intro) || (req.fast_boot != 0); + if (want_boot && req.have_game_entry) { + if (req.have_shell_entry) p.boot_skip = 1; + else p.boot_skip_denied = 1; + } + + return p; +} diff --git a/runtime/src/cue_sheet.cpp b/runtime/src/cue_sheet.cpp new file mode 100644 index 00000000..bad497dc --- /dev/null +++ b/runtime/src/cue_sheet.cpp @@ -0,0 +1,190 @@ +// cue_sheet.cpp — see cue_sheet.h. + +#include "cue_sheet.h" + +#include <cctype> +#include <cstdio> +#include <fstream> +#include <sstream> + +namespace fs = std::filesystem; + +namespace PSXRecompV4 { + +namespace { + +std::string upper_ascii(std::string s) { + for (char& c : s) c = (char)std::toupper((unsigned char)c); + return s; +} + +bool equal_ci(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); i++) { + if (std::toupper((unsigned char)a[i]) != std::toupper((unsigned char)b[i])) return false; + } + return true; +} + +// Extract the FILE line's payload name and type token. Handles both the quoted +// form (`FILE "game.bin" BINARY`) and the bare form (`FILE game.bin BINARY`) +// that some hand-written cues use. +bool parse_file_line(const std::string& line, size_t file_kw_pos, + std::string& out_name, std::string& out_type) { + const size_t after_kw = file_kw_pos + 4; // strlen("FILE") + const size_t q1 = line.find('"', after_kw); + size_t rest_pos; + + if (q1 != std::string::npos) { + const size_t q2 = line.find('"', q1 + 1); + if (q2 == std::string::npos || q2 <= q1 + 1) return false; + out_name = line.substr(q1 + 1, q2 - q1 - 1); + rest_pos = q2 + 1; + } else { + std::istringstream iss(line.substr(after_kw)); + if (!(iss >> out_name) || out_name.empty()) return false; + rest_pos = after_kw; + // Advance past the name we just consumed so the type token scan below + // starts after it. + const size_t name_at = line.find(out_name, after_kw); + if (name_at != std::string::npos) rest_pos = name_at + out_name.size(); + } + + std::istringstream rest(line.substr(rest_pos)); + out_type.clear(); + rest >> out_type; + return true; +} + +} // namespace + +bool CueSheet::usable() const { + if (!opened || files.empty()) return false; + for (const CueFileRef& f : files) { + if (!f.is_binary || !f.exists) return false; + } + return true; +} + +bool CueSheet::has_non_binary_file() const { + for (const CueFileRef& f : files) { + if (!f.is_binary) return true; + } + return false; +} + +size_t CueSheet::data_file_index() const { + for (const CueTrackRef& t : tracks) { + if (!t.is_audio && t.file_index < files.size()) return t.file_index; + } + return 0; +} + +bool path_has_extension_ci(const fs::path& p, const char* dotted_ext) { + return equal_ci(p.extension().string(), std::string(dotted_ext)); +} + +fs::path resolve_cue_relative_file(const fs::path& dir, const std::string& name) { + fs::path named(name); + // Cues written on Windows may use backslashes; std::filesystem on POSIX + // treats those as ordinary characters, so normalize them first. + if (name.find('\\') != std::string::npos) { + std::string slashed = name; + for (char& c : slashed) if (c == '\\') c = '/'; + named = fs::path(slashed); + } + + const fs::path literal = named.is_absolute() ? named : (dir / named); + std::error_code ec; + if (fs::exists(literal, ec)) return literal; + + // Case-insensitive fallback: scan the containing directory for a filename + // that differs only by case. + const fs::path parent = literal.parent_path(); + const std::string want = literal.filename().string(); + ec.clear(); + if (fs::is_directory(parent, ec)) { + ec.clear(); + for (fs::directory_iterator it(parent, ec), end; !ec && it != end; it.increment(ec)) { + if (equal_ci(it->path().filename().string(), want)) return it->path(); + } + } + return literal; +} + +CueSheet parse_cue_sheet(const fs::path& cue_path) { + CueSheet sheet; + + std::ifstream cue(cue_path); + if (!cue.is_open()) return sheet; + sheet.opened = true; + + const fs::path dir = cue_path.parent_path(); + + std::string line; + int cur_track_num = -1; + bool cur_track_audio = false; + uint32_t cur_index00 = 0; + bool cur_has_index00 = false; + + while (std::getline(cue, line)) { + // Strip a trailing CR so cues with CRLF endings parse on POSIX. + if (!line.empty() && line.back() == '\r') line.pop_back(); + + const std::string upper = upper_ascii(line); + + const size_t file_pos = upper.find("FILE"); + if (file_pos != std::string::npos) { + std::string name, type; + if (parse_file_line(line, file_pos, name, type)) { + CueFileRef ref; + ref.name = name; + ref.is_binary = equal_ci(type, "BINARY"); + ref.path = resolve_cue_relative_file(dir, name); + std::error_code ec; + ref.exists = fs::exists(ref.path, ec) && !fs::is_directory(ref.path, ec); + sheet.files.push_back(std::move(ref)); + } + // A FILE line resets any track state carried from the previous file. + cur_track_num = -1; + cur_has_index00 = false; + cur_index00 = 0; + continue; + } + + int tn = 0; + char type_buf[32] = {0}; + if (std::sscanf(upper.c_str(), " TRACK %d %31s", &tn, type_buf) == 2) { + cur_track_num = tn; + cur_track_audio = (std::string(type_buf).find("AUDIO") != std::string::npos); + cur_index00 = 0; + cur_has_index00 = false; + continue; + } + + int idx = 0, mm = 0, ss = 0, ff = 0; + if (std::sscanf(upper.c_str(), " INDEX %d %d:%d:%d", &idx, &mm, &ss, &ff) == 4 && + cur_track_num >= 1 && !sheet.files.empty()) { + const uint32_t index_lba = (uint32_t)(((mm * 60 + ss) * 75) + ff); + if (idx == 0) { + cur_index00 = index_lba; + cur_has_index00 = true; + continue; + } + if (idx != 1) continue; + CueTrackRef t; + t.number = cur_track_num; + t.is_audio = cur_track_audio; + t.file_index = sheet.files.size() - 1; + t.index01 = index_lba; + t.index00 = cur_index00; + t.has_index00 = cur_has_index00; + sheet.tracks.push_back(t); + cur_track_num = -1; + } + } + + return sheet; +} + +} // namespace PSXRecompV4 diff --git a/runtime/src/disc_path.cpp b/runtime/src/disc_path.cpp new file mode 100644 index 00000000..669c6681 --- /dev/null +++ b/runtime/src/disc_path.cpp @@ -0,0 +1,198 @@ +// disc_path.cpp — see disc_path.h. + +#include "disc_path.h" + +#include "cue_sheet.h" + +#include <system_error> +#include <vector> + +namespace fs = std::filesystem; + +namespace PSXRecompV4 { + +namespace { + +// Raw image extensions we are willing to mount on their own, in preference +// order for the cue-fallback search. +const char* const kImageExtensions[] = { ".bin", ".img", ".iso" }; + +bool is_cue(const fs::path& p) { return path_has_extension_ci(p, ".cue"); } + +bool is_raw_image(const fs::path& p) { + for (const char* ext : kImageExtensions) { + if (path_has_extension_ci(p, ext)) return true; + } + return false; +} + +bool same_file_path(const fs::path& a, const fs::path& b) { + std::error_code ec; + if (fs::exists(a, ec) && fs::exists(b, ec)) { + ec.clear(); + if (fs::equivalent(a, b, ec) && !ec) return true; + ec.clear(); + } + return a.lexically_normal() == b.lexically_normal(); +} + +fs::path absolute_or_self(const fs::path& p) { + std::error_code ec; + fs::path abs = fs::absolute(p, ec); + return ec ? p : abs; +} + +// The BINARY payload an unusable cue still points at: the data track when it +// is present, else the first existing BINARY FILE entry. Empty when the cue +// names nothing that is both BINARY and on disk. +fs::path first_existing_binary(const CueSheet& sheet) { + const size_t di = sheet.data_file_index(); + if (di < sheet.files.size() && sheet.files[di].is_binary && sheet.files[di].exists) + return sheet.files[di].path; + for (const CueFileRef& f : sheet.files) { + if (f.is_binary && f.exists) return f.path; + } + return {}; +} + +// <stem>.bin / .img / .iso next to a cue we could not use. +fs::path find_sibling_image(const fs::path& cue) { + std::error_code ec; + for (const char* ext : kImageExtensions) { + fs::path cand = cue; + cand.replace_extension(ext); + ec.clear(); + if (fs::exists(cand, ec) && !fs::is_directory(cand, ec)) return cand; + } + // Case-insensitive sweep for filesystems where ".BIN" != ".bin". + const fs::path dir = cue.parent_path(); + ec.clear(); + if (fs::is_directory(dir, ec)) { + const fs::path stem = cue.stem(); + ec.clear(); + for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) { + if (it->path().stem() != stem) continue; + if (is_raw_image(it->path())) return it->path(); + } + } + return {}; +} + +// The cue that owns `image`: try <stem>.cue first (the common single-bin +// layout), then any cue in the same directory whose FILE list names it (the +// redump multi-track layout, where the cue is "Game.cue" and the payloads are +// "Game (Track 01).bin", ...). +fs::path find_owning_cue(const fs::path& image) { + std::error_code ec; + + auto cue_owns_image = [&](const fs::path& cue) { + const CueSheet sheet = parse_cue_sheet(cue); + if (!sheet.usable()) return false; + for (const CueFileRef& f : sheet.files) { + if (same_file_path(f.path, image)) return true; + } + return false; + }; + + fs::path same_stem = image; + same_stem.replace_extension(".cue"); + if (fs::exists(same_stem, ec) && cue_owns_image(same_stem)) return same_stem; + + const fs::path dir = image.parent_path(); + ec.clear(); + if (!fs::is_directory(dir, ec)) return {}; + + ec.clear(); + for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) { + if (!is_cue(it->path())) continue; + if (same_file_path(it->path(), same_stem)) continue; // already tried + if (cue_owns_image(it->path())) return it->path(); + } + return {}; +} + +} // namespace + +DiscPathResolution resolve_disc_path(const fs::path& picked) { + DiscPathResolution r; + r.picked = absolute_or_self(picked); + r.mount = r.picked; + r.data = r.picked; + + if (r.picked.empty()) return r; + + if (is_cue(r.picked)) { + const CueSheet sheet = parse_cue_sheet(r.picked); + if (sheet.usable()) { + const size_t di = sheet.data_file_index(); + r.from_cue = true; + r.mount = r.picked; + r.data = di < sheet.files.size() ? sheet.files[di].path : r.picked; + return r; + } + + // The cue is unusable. Recover to a raw image rather than failing the + // launch outright — a renamed or missing bin is the single most common + // way a hand-assembled dump folder is broken. + // + // Prefer a BINARY payload the cue itself names and that actually + // exists: a cue whose only defect is a WAVE audio track still points + // at a perfectly good data track. Only fall back to a stem-matched + // sibling when the cue names nothing usable at all. + fs::path sibling = first_existing_binary(sheet); + if (sibling.empty()) sibling = find_sibling_image(r.picked); + if (!sibling.empty()) { + r.mount = absolute_or_self(sibling); + r.data = r.mount; + r.cue_fallback = true; + r.note = "The cue sheet \"" + r.picked.filename().string() + + "\" does not resolve to a usable BINARY payload; falling back to \"" + + r.mount.filename().string() + + "\". CD audio tracks described by the cue will not be available."; + return r; + } + + if (!sheet.opened) { + r.note = "The cue sheet \"" + r.picked.filename().string() + "\" could not be read."; + } else if (sheet.has_non_binary_file()) { + r.note = "The cue sheet \"" + r.picked.filename().string() + + "\" references a non-BINARY track payload (WAVE/MP3), which has no fixed " + "sector geometry and cannot be mounted."; + } else if (sheet.files.empty()) { + r.note = "The cue sheet \"" + r.picked.filename().string() + "\" names no FILE."; + } else { + std::string missing; + for (const CueFileRef& f : sheet.files) { + if (f.exists) continue; + if (!missing.empty()) missing += ", "; + missing += "\"" + f.name + "\""; + } + r.note = "The cue sheet \"" + r.picked.filename().string() + + "\" references files that are not next to it: " + missing + "."; + } + return r; + } + + if (is_raw_image(r.picked)) { + const fs::path cue = find_owning_cue(r.picked); + if (!cue.empty()) { + const CueSheet sheet = parse_cue_sheet(cue); + const size_t di = sheet.data_file_index(); + r.mount = absolute_or_self(cue); + r.data = di < sheet.files.size() ? sheet.files[di].path : r.picked; + r.from_cue = true; + r.upgraded_to_cue = true; + if (sheet.tracks.size() > 1) { + r.note = "Mounting \"" + r.mount.filename().string() + "\" instead of \"" + + r.picked.filename().string() + "\" so the disc's " + + std::to_string(sheet.tracks.size()) + + " tracks (including CD audio) are available."; + } + return r; + } + } + + return r; +} + +} // namespace PSXRecompV4 diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp new file mode 100644 index 00000000..a131a971 --- /dev/null +++ b/runtime/src/mod_packages.cpp @@ -0,0 +1,3207 @@ +#include "mod_packages.h" + +#include "crc32.h" +#include "mod_plugins.h" +#include "psx_sha256.h" +#include "toml.hpp" + +#include <algorithm> +#include <array> +#include <cctype> +#include <cstdio> +#include <fstream> +#include <iomanip> +#include <limits> +#include <set> +#include <sstream> +#include <system_error> + +namespace fs = std::filesystem; + +namespace PSXRecompV4 { +namespace { + +constexpr uint32_t kMinFormatVersion = 1; +constexpr uint32_t kMaxFormatVersion = 5; +constexpr uint64_t kMaxArchiveBytes = 256ull * 1024ull * 1024ull; +constexpr uint32_t kMaxArchiveFiles = 4096; + +std::map<std::string, ModBuiltinResolver>& builtin_resolvers() { + static std::map<std::string, ModBuiltinResolver> value; + return value; +} + +struct RegisteredPlugin { + PSXModActivationCallback activation = nullptr; + PSXModVBlankCallback vblank = nullptr; +}; + +std::map<std::string, RegisteredPlugin>& registered_plugins() { + static std::map<std::string, RegisteredPlugin> value; + return value; +} + +void set_error(std::string* out, const std::string& value) { + if (out) *out = value; +} + +bool valid_id(const std::string& value) { + if (value.empty() || value.size() > 96) return false; + for (unsigned char c : value) { + if (!(std::islower(c) || std::isdigit(c) || c == '.' || c == '-' || c == '_')) + return false; + } + return value.front() != '.' && value.back() != '.'; +} + +bool valid_sha256(const std::string& value) { + return value.size() == 64 && + std::all_of(value.begin(), value.end(), [](unsigned char c) { + return std::isdigit(c) || (c >= 'a' && c <= 'f'); + }); +} + +bool parse_hex_bytes(const std::string& text, std::vector<uint8_t>& out) { + std::string compact; + compact.reserve(text.size()); + for (unsigned char c : text) { + if (!std::isspace(c) && c != '_') compact.push_back((char)c); + } + if (compact.size() % 2 != 0) return false; + out.clear(); + out.reserve(compact.size() / 2); + auto nibble = [](unsigned char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + c = (unsigned char)std::tolower(c); + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; + }; + for (size_t i = 0; i < compact.size(); i += 2) { + const int hi = nibble((unsigned char)compact[i]); + const int lo = nibble((unsigned char)compact[i + 1]); + if (hi < 0 || lo < 0) return false; + out.push_back((uint8_t)((hi << 4) | lo)); + } + return true; +} + +bool parse_value_encoding(const std::string& text, ModValueEncoding& out) { + static const std::pair<const char*, ModValueEncoding> values[] = { + {"u8", ModValueEncoding::U8}, + {"u16le", ModValueEncoding::U16LE}, + {"u32le", ModValueEncoding::U32LE}, + {"mips_lui_ori_u32", ModValueEncoding::MipsLuiOriU32}, + }; + for (const auto& [name, encoding] : values) { + if (text == name) { + out = encoding; + return true; + } + } + return false; +} + +bool parse_integer_predicate_op(const std::string& text, + ModIntegerPredicateOp& out) { + static const std::pair<const char*, ModIntegerPredicateOp> values[] = { + {"eq", ModIntegerPredicateOp::Equal}, + {"ne", ModIntegerPredicateOp::NotEqual}, + {"lt", ModIntegerPredicateOp::Less}, + {"le", ModIntegerPredicateOp::LessEqual}, + {"gt", ModIntegerPredicateOp::Greater}, + {"ge", ModIntegerPredicateOp::GreaterEqual}, + }; + for (const auto& [name, op] : values) { + if (text == name) { + out = op; + return true; + } + } + return false; +} + +uint32_t value_encoding_size(ModValueEncoding encoding) { + switch (encoding) { + case ModValueEncoding::U8: + return 1; + case ModValueEncoding::U16LE: + return 2; + case ModValueEncoding::U32LE: + return 4; + case ModValueEncoding::MipsLuiOriU32: + return 8; + } + return 0; +} + +bool valid_mips_lui_ori_guard(const std::vector<uint8_t>& expected, + uint64_t offset) { + if (offset > expected.size() || expected.size() - offset < 8) + return false; + const uint8_t* bytes = + expected.data() + static_cast<size_t>(offset); + const uint32_t lui = + static_cast<uint32_t>(bytes[0]) | + (static_cast<uint32_t>(bytes[1]) << 8) | + (static_cast<uint32_t>(bytes[2]) << 16) | + (static_cast<uint32_t>(bytes[3]) << 24); + const uint32_t ori = + static_cast<uint32_t>(bytes[4]) | + (static_cast<uint32_t>(bytes[5]) << 8) | + (static_cast<uint32_t>(bytes[6]) << 16) | + (static_cast<uint32_t>(bytes[7]) << 24); + const uint32_t lui_rs = (lui >> 21) & 0x1Fu; + const uint32_t lui_rt = (lui >> 16) & 0x1Fu; + const uint32_t ori_rs = (ori >> 21) & 0x1Fu; + const uint32_t ori_rt = (ori >> 16) & 0x1Fu; + return (lui >> 26) == 0x0Fu && lui_rs == 0 && + lui_rt != 0 && (ori >> 26) == 0x0Du && + ori_rs == lui_rt && ori_rt == lui_rt; +} + +struct RelativeRange { + uint64_t offset = 0; + uint64_t size = 0; +}; + +std::vector<RelativeRange> patch_field_ranges(const ModPatchField& field) { + if (!field.replacement.empty()) + return {{field.offset, field.replacement.size()}}; + if (field.replace_encoding == ModValueEncoding::MipsLuiOriU32) + return {{field.offset, 2}, {field.offset + 4, 2}}; + return {{field.offset, value_encoding_size(field.replace_encoding)}}; +} + +std::string hex_bytes(const std::vector<uint8_t>& bytes) { + std::ostringstream out; + for (uint8_t byte : bytes) + out << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + return out.str(); +} + +struct SemVer { + int64_t major = 0, minor = 0, patch = 0; + std::string suffix; + bool valid = false; +}; + +SemVer parse_semver(const std::string& text) { + SemVer out; + std::string core = text; + const size_t dash = core.find('-'); + if (dash != std::string::npos) { + out.suffix = core.substr(dash + 1); + core.resize(dash); + } + std::array<int64_t*, 3> parts = {&out.major, &out.minor, &out.patch}; + size_t at = 0; + for (size_t i = 0; i < parts.size(); ++i) { + const size_t end = core.find('.', at); + const std::string token = core.substr(at, end == std::string::npos ? end : end - at); + if (token.empty() || + !std::all_of(token.begin(), token.end(), [](unsigned char c) { return std::isdigit(c); })) + return out; + try { + *parts[i] = std::stoll(token); + } catch (...) { + return out; + } + if (end == std::string::npos) { + if (i != 2) return out; + at = core.size(); + } else { + at = end + 1; + } + } + if (at != core.size()) return out; + out.valid = true; + return out; +} + +int compare_semver(const std::string& a, const std::string& b) { + const SemVer av = parse_semver(a), bv = parse_semver(b); + if (!av.valid || !bv.valid) return a.compare(b); + if (av.major != bv.major) return av.major < bv.major ? -1 : 1; + if (av.minor != bv.minor) return av.minor < bv.minor ? -1 : 1; + if (av.patch != bv.patch) return av.patch < bv.patch ? -1 : 1; + if (av.suffix.empty() != bv.suffix.empty()) return av.suffix.empty() ? 1 : -1; + return av.suffix.compare(bv.suffix); +} + +bool version_satisfies(const std::string& actual, const std::string& requirement) { + if (requirement.empty() || requirement == "*") return true; + if (requirement.rfind(">=", 0) == 0) + return compare_semver(actual, requirement.substr(2)) >= 0; + if (requirement.rfind("<=", 0) == 0) + return compare_semver(actual, requirement.substr(2)) <= 0; + if (requirement.rfind(">", 0) == 0) + return compare_semver(actual, requirement.substr(1)) > 0; + if (requirement.rfind("<", 0) == 0) + return compare_semver(actual, requirement.substr(1)) < 0; + if (requirement.rfind("^", 0) == 0) { + const SemVer base = parse_semver(requirement.substr(1)); + const SemVer got = parse_semver(actual); + return base.valid && got.valid && got.major == base.major && + compare_semver(actual, requirement.substr(1)) >= 0; + } + return actual == requirement; +} + +std::string quote_toml(const std::string& value) { + std::string out = "\""; + for (unsigned char c : value) { + if (c == '\\' || c == '"') out.push_back('\\'); + if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else out.push_back((char)c); + } + out.push_back('"'); + return out; +} + +bool read_file(const fs::path& path, std::vector<uint8_t>& out, std::string* error) { + std::ifstream in(path, std::ios::binary); + if (!in) { + set_error(error, "cannot open " + path.string()); + return false; + } + in.seekg(0, std::ios::end); + const std::streamoff size = in.tellg(); + if (size < 0 || (uint64_t)size > kMaxArchiveBytes) { + set_error(error, "archive is too large"); + return false; + } + in.seekg(0); + out.resize((size_t)size); + if (!out.empty() && !in.read((char*)out.data(), size)) { + set_error(error, "cannot read " + path.string()); + return false; + } + return true; +} + +uint16_t le16(const uint8_t* p) { + return (uint16_t)(p[0] | ((uint16_t)p[1] << 8)); +} + +uint32_t le32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +struct ZipEntry { + std::string name; + uint16_t method = 0; + uint32_t crc = 0; + uint32_t compressed_size = 0; + uint32_t size = 0; + uint32_t local_offset = 0; + bool directory = false; +}; + +bool safe_archive_name(const std::string& name) { + if (name.empty() || name.size() > 512 || name[0] == '/' || name[0] == '\\') + return false; + if (name.size() >= 2 && std::isalpha((unsigned char)name[0]) && name[1] == ':') + return false; + fs::path p = fs::path(name).lexically_normal(); + for (const auto& part : p) { + const std::string s = part.string(); + if (s == ".." || s == "." || s.empty()) return false; + } + return true; +} + +bool parse_zip(const std::vector<uint8_t>& bytes, std::vector<ZipEntry>& entries, + std::string* error) { + if (bytes.size() < 22) { + set_error(error, "not a ZIP archive"); + return false; + } + size_t eocd = std::string::npos; + const size_t floor = bytes.size() > 65557 ? bytes.size() - 65557 : 0; + for (size_t pos = bytes.size() - 22;; --pos) { + if (le32(bytes.data() + pos) == 0x06054b50u) { eocd = pos; break; } + if (pos == floor) break; + } + if (eocd == std::string::npos) { + set_error(error, "ZIP end record is missing"); + return false; + } + const uint16_t count = le16(bytes.data() + eocd + 10); + const uint32_t central_size = le32(bytes.data() + eocd + 12); + const uint32_t central_offset = le32(bytes.data() + eocd + 16); + if (count > kMaxArchiveFiles || (uint64_t)central_offset + central_size > bytes.size()) { + set_error(error, "ZIP central directory is invalid"); + return false; + } + size_t at = central_offset; + uint64_t expanded = 0; + for (uint32_t i = 0; i < count; ++i) { + if (at + 46 > bytes.size() || le32(bytes.data() + at) != 0x02014b50u) { + set_error(error, "ZIP entry record is invalid"); + return false; + } + const uint16_t flags = le16(bytes.data() + at + 8); + ZipEntry e; + e.method = le16(bytes.data() + at + 10); + e.crc = le32(bytes.data() + at + 16); + e.compressed_size = le32(bytes.data() + at + 20); + e.size = le32(bytes.data() + at + 24); + const uint16_t name_len = le16(bytes.data() + at + 28); + const uint16_t extra_len = le16(bytes.data() + at + 30); + const uint16_t comment_len = le16(bytes.data() + at + 32); + e.local_offset = le32(bytes.data() + at + 42); + if (flags & 1u) { + set_error(error, "encrypted ZIP entries are not supported"); + return false; + } + if (e.method != 0 && e.method != 8) { + set_error(error, "ZIP compression method is not supported"); + return false; + } + if (at + 46ull + name_len + extra_len + comment_len > bytes.size()) { + set_error(error, "ZIP entry name is truncated"); + return false; + } + e.name.assign((const char*)bytes.data() + at + 46, name_len); + std::replace(e.name.begin(), e.name.end(), '\\', '/'); + e.directory = !e.name.empty() && e.name.back() == '/'; + if (!safe_archive_name(e.directory ? e.name.substr(0, e.name.size() - 1) : e.name)) { + set_error(error, "unsafe ZIP path: " + e.name); + return false; + } + expanded += e.size; + if (expanded > kMaxArchiveBytes) { + set_error(error, "expanded archive exceeds the size limit"); + return false; + } + entries.push_back(std::move(e)); + at += 46ull + name_len + extra_len + comment_len; + } + return true; +} + +struct DeflateBits { + const uint8_t* at = nullptr; + const uint8_t* end = nullptr; + uint64_t hold = 0; + unsigned bits = 0; + + bool read(unsigned count, uint32_t& out) { + while (bits < count) { + if (at == end) return false; + hold |= (uint64_t)*at++ << bits; + bits += 8; + } + out = count == 32 ? (uint32_t)hold : + (uint32_t)(hold & ((1ull << count) - 1)); + hold >>= count; + bits -= count; + return true; + } + void align_byte() { + const unsigned drop = bits & 7u; + hold >>= drop; + bits -= drop; + } +}; + +struct DeflateHuffman { + std::array<uint16_t, 16> count{}; + std::vector<uint16_t> symbols; +}; + +bool build_huffman(const std::vector<uint8_t>& lengths, DeflateHuffman& out) { + out = {}; + for (uint8_t length : lengths) { + if (length > 15) return false; + out.count[length]++; + } + if (out.count[0] == lengths.size()) return false; + int left = 1; + for (int length = 1; length <= 15; ++length) { + left <<= 1; + left -= out.count[(size_t)length]; + if (left < 0) return false; + } + std::array<uint16_t, 16> offsets{}; + for (size_t length = 1; length < 15; ++length) + offsets[length + 1] = offsets[length] + out.count[length]; + out.symbols.resize(lengths.size() - out.count[0]); + for (uint16_t symbol = 0; symbol < lengths.size(); ++symbol) + if (lengths[symbol]) + out.symbols[offsets[lengths[symbol]]++] = symbol; + return true; +} + +bool decode_symbol(DeflateBits& bits, const DeflateHuffman& table, uint16_t& symbol) { + uint32_t code = 0, first = 0, index = 0; + for (uint32_t length = 1; length <= 15; ++length) { + uint32_t bit = 0; + if (!bits.read(1, bit)) return false; + code |= bit; + const uint32_t count = table.count[length]; + if (code < first + count) { + const uint32_t slot = index + code - first; + if (slot >= table.symbols.size()) return false; + symbol = table.symbols[slot]; + return true; + } + index += count; + first = (first + count) << 1; + code <<= 1; + } + return false; +} + +bool inflate_deflate(const uint8_t* data, size_t size, size_t expected, + std::vector<uint8_t>& out) { + static const uint16_t length_base[29] = { + 3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99, + 115,131,163,195,227,258}; + static const uint8_t length_extra[29] = { + 0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0}; + static const uint16_t distance_base[30] = { + 1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769, + 1025,1537,2049,3073,4097,6145,8193,12289,16385,24577}; + static const uint8_t distance_extra[30] = { + 0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11, + 12,12,13,13}; + DeflateBits input{data, data + size}; + out.clear(); + out.reserve(expected); + bool final = false; + while (!final) { + uint32_t final_bit = 0, type = 0; + if (!input.read(1, final_bit) || !input.read(2, type)) return false; + final = final_bit != 0; + if (type == 0) { + input.align_byte(); + uint32_t length = 0, complement = 0; + if (!input.read(16, length) || !input.read(16, complement) || + (length ^ 0xffffu) != complement || + out.size() + length > expected) return false; + for (uint32_t i = 0; i < length; ++i) { + uint32_t byte = 0; + if (!input.read(8, byte)) return false; + out.push_back((uint8_t)byte); + } + continue; + } + if (type == 3) return false; + + std::vector<uint8_t> literal_lengths; + std::vector<uint8_t> distance_lengths; + if (type == 1) { + literal_lengths.resize(288); + for (size_t i = 0; i <= 143; ++i) literal_lengths[i] = 8; + for (size_t i = 144; i <= 255; ++i) literal_lengths[i] = 9; + for (size_t i = 256; i <= 279; ++i) literal_lengths[i] = 7; + for (size_t i = 280; i <= 287; ++i) literal_lengths[i] = 8; + distance_lengths.assign(32, 5); + } else { + uint32_t hlit = 0, hdist = 0, hclen = 0; + if (!input.read(5, hlit) || !input.read(5, hdist) || + !input.read(4, hclen)) return false; + hlit += 257; hdist += 1; hclen += 4; + static const uint8_t order[19] = + {16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15}; + std::vector<uint8_t> code_lengths(19, 0); + for (uint32_t i = 0; i < hclen; ++i) { + uint32_t value = 0; + if (!input.read(3, value)) return false; + code_lengths[order[i]] = (uint8_t)value; + } + DeflateHuffman code_table; + if (!build_huffman(code_lengths, code_table)) return false; + std::vector<uint8_t> lengths; + lengths.reserve(hlit + hdist); + while (lengths.size() < hlit + hdist) { + uint16_t symbol = 0; + if (!decode_symbol(input, code_table, symbol)) return false; + if (symbol <= 15) { + lengths.push_back((uint8_t)symbol); + continue; + } + uint32_t repeat = 0, extra = 0; + uint8_t value = 0; + if (symbol == 16) { + if (lengths.empty() || !input.read(2, extra)) return false; + repeat = extra + 3; + value = lengths.back(); + } else if (symbol == 17) { + if (!input.read(3, extra)) return false; + repeat = extra + 3; + } else if (symbol == 18) { + if (!input.read(7, extra)) return false; + repeat = extra + 11; + } else return false; + if (lengths.size() + repeat > hlit + hdist) return false; + lengths.insert(lengths.end(), repeat, value); + } + literal_lengths.assign(lengths.begin(), lengths.begin() + hlit); + distance_lengths.assign(lengths.begin() + hlit, lengths.end()); + } + DeflateHuffman literals, distances; + if (!build_huffman(literal_lengths, literals) || + !build_huffman(distance_lengths, distances)) return false; + for (;;) { + uint16_t symbol = 0; + if (!decode_symbol(input, literals, symbol)) return false; + if (symbol < 256) { + if (out.size() >= expected) return false; + out.push_back((uint8_t)symbol); + continue; + } + if (symbol == 256) break; + if (symbol < 257 || symbol > 285) return false; + const unsigned length_index = symbol - 257; + uint32_t extra = 0; + if (!input.read(length_extra[length_index], extra)) return false; + const size_t length = length_base[length_index] + extra; + uint16_t distance_symbol = 0; + if (!decode_symbol(input, distances, distance_symbol) || + distance_symbol >= 30) return false; + if (!input.read(distance_extra[distance_symbol], extra)) return false; + const size_t distance = distance_base[distance_symbol] + extra; + if (distance == 0 || distance > out.size() || + out.size() + length > expected) return false; + for (size_t i = 0; i < length; ++i) + out.push_back(out[out.size() - distance]); + } + } + return out.size() == expected; +} + +bool extract_zip(const std::vector<uint8_t>& bytes, + const std::vector<ZipEntry>& entries, + const fs::path& target, std::string* error) { + std::error_code ec; + fs::create_directories(target, ec); + if (ec) { + set_error(error, "cannot create staging directory: " + ec.message()); + return false; + } + for (const ZipEntry& e : entries) { + const fs::path out = target / fs::path(e.name); + if (e.directory) { + fs::create_directories(out, ec); + if (ec) { + set_error(error, "cannot create archive directory: " + ec.message()); + return false; + } + continue; + } + if ((uint64_t)e.local_offset + 30 > bytes.size() || + le32(bytes.data() + e.local_offset) != 0x04034b50u) { + set_error(error, "ZIP local entry is invalid"); + return false; + } + const uint16_t name_len = le16(bytes.data() + e.local_offset + 26); + const uint16_t extra_len = le16(bytes.data() + e.local_offset + 28); + const uint64_t data_at = (uint64_t)e.local_offset + 30 + name_len + extra_len; + if (data_at + e.compressed_size > bytes.size()) { + set_error(error, "ZIP entry payload is invalid"); + return false; + } + const uint8_t* compressed = bytes.data() + data_at; + std::vector<uint8_t> expanded; + const uint8_t* data = compressed; + if (e.method == 0) { + if (e.compressed_size != e.size) { + set_error(error, "stored ZIP entry has inconsistent size"); + return false; + } + } else { + if (!inflate_deflate(compressed, e.compressed_size, e.size, expanded)) { + set_error(error, "cannot inflate ZIP entry: " + e.name); + return false; + } + data = expanded.data(); + } + if (crc32_compute(data, e.size) != e.crc) { + set_error(error, "ZIP entry checksum failed: " + e.name); + return false; + } + fs::create_directories(out.parent_path(), ec); + if (ec) { + set_error(error, "cannot create archive parent directory: " + ec.message()); + return false; + } + std::ofstream file(out, std::ios::binary | std::ios::trunc); + if (!file || (e.size && !file.write((const char*)data, e.size))) { + set_error(error, "cannot extract archive entry: " + e.name); + return false; + } + } + return true; +} + +const ModPackage* find_selected( + const std::map<std::string, std::map<std::string, ModPackage>>& packages, + const std::string& id, const ModSelection& selection) { + const auto p = packages.find(id); + if (p == packages.end() || p->second.empty()) return nullptr; + if (!selection.version.empty()) { + const auto v = p->second.find(selection.version); + return v == p->second.end() ? nullptr : &v->second; + } + const ModPackage* best = nullptr; + for (const auto& [version, package] : p->second) + if (!best || compare_semver(version, best->version) > 0) best = &package; + return best; +} + +bool target_matches(const ModPackage& package, const std::string& game, + const std::string& exe, const std::string& disc) { + if (package.targets.empty()) return false; + for (const ModTarget& target : package.targets) { + if (target.game_id != game) continue; + if (!target.exe_sha256.empty() && target.exe_sha256 != exe) continue; + if (!target.disc_sha256.empty() && target.disc_sha256 != disc) continue; + return true; + } + return false; +} + +std::string canonical_resolution(const std::vector<const ModPackage*>& ordered, + const std::map<std::string, ModSelection>& selections, + const std::vector<ModResolution::Write>& writes, + const std::vector<ModResolution::Overlay>& overlays, + const std::vector<ModResolution::DerivedDisc>& derived_discs, + const std::vector<ModResolution::Plugin>& plugins, + const std::string& source_disc_sha256) { + std::ostringstream out; + out << "source_disc=" << source_disc_sha256 << '\n'; + for (const ModPackage* package : ordered) { + out << package->id << '@' << package->version << '\n'; + const auto sit = selections.find(package->id); + for (const ModFeature& feature : package->features) { + bool enabled = feature.default_enabled; + const std::map<std::string, std::string>* values = nullptr; + if (sit != selections.end()) { + if (feature.legacy) { + enabled = sit->second.enabled; + values = &sit->second.values; + } else { + const auto selected = sit->second.features.find(feature.id); + if (selected != sit->second.features.end()) { + if (selected->second.has_enabled) + enabled = selected->second.enabled; + values = &selected->second.values; + } + } + } + out << "feature:" << feature.id << '=' + << (enabled ? "enabled" : "disabled") << '\n'; + for (const ModOption& option : package->options) { + if (option.feature_id != feature.id) continue; + const auto selected = values ? values->find(option.id) : + std::map<std::string, std::string>::const_iterator{}; + const std::string value = + values && selected != values->end() + ? selected->second : option.default_value; + out << "feature:" << feature.id << ':' << option.id + << '=' << value << '\n'; + } + } + } + for (const ModResolution::Write& write : writes) { + const char* target = + write.target == ModPatchTarget::MainExe ? "main_exe" : + write.target == ModPatchTarget::DiscRaw ? "disc_raw" : + "disc_user"; + if (write.fields.empty()) { + out << target << '@' << std::hex << write.location << std::dec + << ':' << hex_bytes(write.expected) << '>' + << hex_bytes(write.replacement) << ':' << write.package_id + << ':' << write.feature_id << '\n'; + } else { + out << "sparse_" << target << '@' << std::hex + << write.location << std::dec << ":guard=" + << hex_bytes(write.expected) << ":owned="; + for (const ModResolution::Write::Field& field : write.fields) + out << std::hex << field.offset << std::dec << '=' + << hex_bytes(field.replacement) << ','; + out << ':' << write.package_id << ':' << write.feature_id + << '\n'; + } + } + for (const ModResolution::Overlay& overlay : overlays) { + out << (overlay.target == ModPatchTarget::DiscRaw + ? "disc_raw_overlay" : "disc_user_overlay") + << '@' << std::hex << overlay.location << std::dec << ':' + << overlay.payload_sha256 << ':' << overlay.expected_sha256 << ':' + << overlay.package_id << ':' << overlay.feature_id << '\n'; + } + for (const ModResolution::DerivedDisc& derived : derived_discs) { + out << "derived_disc:" << derived.kind << ':' + << derived.patch_sha256 << ':' << derived.output_size << ':' + << derived.output_sha256 << ':' << derived.package_id << '\n'; + } + for (const ModResolution::Plugin& plugin : plugins) { + out << "plugin:" << plugin.id << ':' << plugin.package_id << ':' + << plugin.feature_id << '\n'; + } + return out.str(); +} + +const ModFeature* find_feature(const ModPackage& package, const std::string& id) { + const auto found = std::find_if(package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { return feature.id == id; }); + return found == package.features.end() ? nullptr : &*found; +} + +const ModOption* find_option(const ModPackage& package, + const std::string& feature_id, + const std::string& id) { + const auto option = std::find_if(package.options.begin(), package.options.end(), + [&](const ModOption& item) { + return item.feature_id == feature_id && item.id == id; + }); + return option == package.options.end() ? nullptr : &*option; +} + +bool constraint_satisfied( + const ModPackage& package, const ModConstraint& constraint, + const std::function<std::string(const std::string&)>& value_for, + std::string* reason); + +const ModFeatureSelection* find_feature_selection(const ModPackage& package, + const ModSelection& selection, + const std::string& feature_id) { + const ModFeature* feature = find_feature(package, feature_id); + if (!feature) return nullptr; + if (feature->legacy) return nullptr; + const auto found = selection.features.find(feature_id); + return found == selection.features.end() ? nullptr : &found->second; +} + +bool is_feature_enabled(const ModPackage& package, const ModSelection& selection, + const ModFeature& feature) { + if (feature.legacy) return selection.enabled; + const ModFeatureSelection* selected = + find_feature_selection(package, selection, feature.id); + return selected && selected->has_enabled + ? selected->enabled : feature.default_enabled; +} + +bool has_enabled_feature(const ModPackage& package, + const ModSelection& selection) { + return std::any_of(package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { + return is_feature_enabled(package, selection, feature); + }); +} + +std::string effective_option_value(const ModPackage& package, + const ModSelection& selection, + const std::string& feature_id, + const std::string& id) { + const ModFeature* feature = find_feature(package, feature_id); + if (feature && feature->legacy) { + const auto selected = selection.values.find(id); + if (selected != selection.values.end()) return selected->second; + } else { + const ModFeatureSelection* selected = + find_feature_selection(package, selection, feature_id); + if (selected) { + const auto value = selected->values.find(id); + if (value != selected->values.end()) return value->second; + } + } + const ModOption* option = find_option(package, feature_id, id); + return option ? option->default_value : std::string(); +} + +bool prospective_feature_enabled( + const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::string* override_feature_id, + const bool* override_enabled) { + if (override_feature_id && override_enabled && + feature_id == *override_feature_id) + return *override_enabled; + const ModFeature* feature = find_feature(package, feature_id); + return feature && is_feature_enabled(package, selection, *feature); +} + +std::string prospective_option_value( + const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::string& option_id, + const std::string* override_feature_id, + const std::string* override_option_id, + const std::string* override_value) { + if (override_feature_id && override_option_id && override_value && + feature_id == *override_feature_id && option_id == *override_option_id) + return *override_value; + return effective_option_value(package, selection, feature_id, option_id); +} + +bool runtime_constraint_satisfied( + const ModPackage& package, const ModSelection& selection, + const ModConstraint& constraint, + const std::string* override_feature_id, const bool* override_enabled, + const std::string* override_option_feature_id, + const std::string* override_option_id, + const std::string* override_value, std::string* reason) { + if (!prospective_feature_enabled( + package, selection, constraint.feature_id, override_feature_id, + override_enabled)) + return true; + if (constraint.kind == ModConstraintKind::OrderedInteger) { + return constraint_satisfied( + package, constraint, + [&](const std::string& id) { + return prospective_option_value( + package, selection, constraint.feature_id, id, + override_option_feature_id, override_option_id, + override_value); + }, + reason); + } + if (constraint.kind == ModConstraintKind::RequiresFeature) { + const ModFeature* required = + find_feature(package, constraint.required_feature_id); + const std::string required_name = + required && !required->name.empty() + ? required->name + : constraint.required_feature_id; + if (!prospective_feature_enabled( + package, selection, constraint.required_feature_id, + override_feature_id, override_enabled)) { + if (reason) *reason = "requires " + required_name + " to be enabled"; + return false; + } + if (!constraint.required_option_id.empty()) { + const std::string actual = prospective_option_value( + package, selection, constraint.required_feature_id, + constraint.required_option_id, override_option_feature_id, + override_option_id, override_value); + if (actual != constraint.required_value) { + const ModOption* option = find_option( + package, constraint.required_feature_id, + constraint.required_option_id); + if (reason) { + *reason = + "requires " + required_name + " " + + (option && !option->label.empty() + ? option->label + : constraint.required_option_id) + + " = " + constraint.required_value; + } + return false; + } + } + return true; + } + if (reason) *reason = "unsupported constraint kind"; + return false; +} + +bool package_constraints_satisfied( + const ModPackage& package, const ModSelection& selection, + const std::string* override_feature_id, const bool* override_enabled, + const std::string* override_option_feature_id, + const std::string* override_option_id, + const std::string* override_value, + std::string* failing_feature_id, std::string* reason) { + for (const ModConstraint& constraint : package.constraints) { + std::string local_reason; + if (!runtime_constraint_satisfied( + package, selection, constraint, override_feature_id, + override_enabled, override_option_feature_id, + override_option_id, override_value, &local_reason)) { + if (failing_feature_id) *failing_feature_id = constraint.feature_id; + if (reason) *reason = local_reason; + return false; + } + } + return true; +} + +void set_feature_selected(ModSelection& selection, + const std::string& feature_id, bool enabled) { + ModFeatureSelection& feature = selection.features[feature_id]; + feature.enabled = enabled; + feature.has_enabled = true; +} + +bool apply_feature_requirements(const ModPackage& package, + ModSelection& selection, + const std::string& feature_id, + std::set<std::string>& visiting, + std::string* error) { + if (!visiting.insert(feature_id).second) return true; + for (const ModConstraint& constraint : package.constraints) { + if (constraint.kind != ModConstraintKind::RequiresFeature || + constraint.feature_id != feature_id) + continue; + const ModFeature* required = + find_feature(package, constraint.required_feature_id); + if (!required || required->legacy) { + set_error(error, feature_id + ": has an invalid feature requirement"); + visiting.erase(feature_id); + return false; + } + set_feature_selected(selection, constraint.required_feature_id, true); + if (!constraint.required_option_id.empty()) { + selection.features[constraint.required_feature_id] + .values[constraint.required_option_id] = + constraint.required_value; + } + if (!apply_feature_requirements( + package, selection, constraint.required_feature_id, + visiting, error)) { + visiting.erase(feature_id); + return false; + } + } + visiting.erase(feature_id); + return true; +} + +void cascade_unsatisfied_feature_requirements(const ModPackage& package, + ModSelection& selection) { + bool changed = false; + do { + changed = false; + for (const ModConstraint& constraint : package.constraints) { + if (constraint.kind != ModConstraintKind::RequiresFeature) + continue; + const ModFeature* dependent = + find_feature(package, constraint.feature_id); + if (!dependent || dependent->legacy || + !is_feature_enabled(package, selection, *dependent)) + continue; + if (runtime_constraint_satisfied( + package, selection, constraint, nullptr, nullptr, + nullptr, nullptr, nullptr, nullptr)) + continue; + set_feature_selected(selection, constraint.feature_id, false); + changed = true; + } + } while (changed); +} + +bool conditions_match(const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, + const std::map<std::string, std::string>& conditions) { + for (const auto& [id, value] : conditions) { + if (effective_option_value(package, selection, feature_id, id) != value) + return false; + } + return true; +} + +bool valid_option_value(const ModOption& option, const std::string& value); + +void read_conditions(const toml::value& value, const std::vector<ModOption>& options, + const std::string& feature_id, + std::map<std::string, std::string>& when, + const char* label) { + const std::string when_option = + value.contains("when_option") ? toml::find<std::string>(value, "when_option") : ""; + const std::string when_value = + value.contains("when_value") ? toml::find<std::string>(value, "when_value") : ""; + if (when_option.empty() != when_value.empty()) + throw std::runtime_error( + std::string(label) + " condition requires both when_option and when_value"); + if (!when_option.empty()) when[when_option] = when_value; + if (value.contains("when")) { + const auto table = toml::find<std::map<std::string, std::string>>(value, "when"); + for (const auto& [id, condition_value] : table) { + if (when.find(id) != when.end() && when[id] != condition_value) + throw std::runtime_error( + std::string(label) + " has conflicting conditions for " + id); + when[id] = condition_value; + } + } + for (const auto& [id, condition_value] : when) { + (void)condition_value; + const auto option = std::find_if( + options.begin(), options.end(), + [&](const ModOption& item) { + return item.feature_id == feature_id && item.id == id; + }); + if (option == options.end()) + throw std::runtime_error(std::string(label) + " references unknown option"); + if (!valid_option_value(*option, condition_value)) + throw std::runtime_error( + std::string(label) + " condition has invalid option value"); + } +} + +bool ranges_overlap(ModPatchTarget a_target, uint64_t a_location, size_t a_size, + ModPatchTarget b_target, uint64_t b_location, size_t b_size); + +template <typename Fn> +void for_each_owned_span(const ModResolution::Write& write, Fn&& fn) { + if (write.fields.empty()) { + fn(write.location, write.expected.data(), write.replacement.data(), + write.replacement.size()); + return; + } + for (const ModResolution::Write::Field& field : write.fields) { + fn(write.location + field.offset, + write.expected.data() + static_cast<size_t>(field.offset), + field.replacement.data(), field.replacement.size()); + } +} + +bool writes_overlap(const ModResolution::Write& a, const ModResolution::Write& b) { + if (a.target != b.target) return false; + bool overlap = false; + for_each_owned_span( + a, [&](uint64_t a_location, const uint8_t*, const uint8_t*, + size_t a_size) { + for_each_owned_span( + b, [&](uint64_t b_location, const uint8_t*, const uint8_t*, + size_t b_size) { + overlap = overlap || ranges_overlap( + a.target, a_location, a_size, + b.target, b_location, b_size); + }); + }); + return overlap; +} + +bool ranges_overlap(ModPatchTarget a_target, uint64_t a_location, size_t a_size, + ModPatchTarget b_target, uint64_t b_location, size_t b_size) { + if (a_target != b_target) return false; + const uint64_t a_end = a_location + a_size; + const uint64_t b_end = b_location + b_size; + return a_location < b_end && b_location < a_end; +} + +bool identical_write(const ModResolution::Write& a, const ModResolution::Write& b) { + if (a.target != b.target || a.location != b.location || + a.expected != b.expected || a.replacement != b.replacement || + a.fields.size() != b.fields.size()) + return false; + for (size_t i = 0; i < a.fields.size(); ++i) { + if (a.fields[i].offset != b.fields[i].offset || + a.fields[i].replacement != b.fields[i].replacement) + return false; + } + return true; +} + +bool write_overlap_mismatch(const ModResolution::Write& a, + const ModResolution::Write& b, + uint64_t& mismatch) { + bool found = false; + for_each_owned_span( + a, [&](uint64_t a_location, const uint8_t* a_expected, + const uint8_t* a_replacement, size_t a_size) { + for_each_owned_span( + b, [&](uint64_t b_location, const uint8_t* b_expected, + const uint8_t* b_replacement, size_t b_size) { + const uint64_t begin = + std::max(a_location, b_location); + const uint64_t end = std::min( + a_location + a_size, b_location + b_size); + for (uint64_t at = begin; !found && at < end; ++at) { + const size_t ai = + static_cast<size_t>(at - a_location); + const size_t bi = + static_cast<size_t>(at - b_location); + if (a_expected[ai] != b_expected[bi] || + a_replacement[ai] != b_replacement[bi]) { + mismatch = at; + found = true; + } + } + }); + }); + return found; +} + +bool write_guard_overlap_mismatch(const ModResolution::Write& a, + const ModResolution::Write& b, + uint64_t& mismatch) { + if (a.target != b.target) return false; + const uint64_t begin = std::max(a.location, b.location); + const uint64_t end = std::min( + a.location + a.expected.size(), b.location + b.expected.size()); + for (uint64_t at = begin; at < end; ++at) { + if (a.expected[static_cast<size_t>(at - a.location)] != + b.expected[static_cast<size_t>(at - b.location)]) { + mismatch = at; + return true; + } + } + return false; +} + +bool write_overlaps_range(const ModResolution::Write& write, + ModPatchTarget target, uint64_t location, + size_t size) { + if (write.target != target) return false; + bool overlap = false; + for_each_owned_span( + write, [&](uint64_t owned_location, const uint8_t*, + const uint8_t*, size_t owned_size) { + overlap = overlap || ranges_overlap( + write.target, owned_location, owned_size, + target, location, size); + }); + return overlap; +} + +bool write_overlay_mismatch(const ModResolution::Write& write, + const ModResolution::Overlay& overlay, + uint64_t& mismatch) { + bool found = false; + for_each_owned_span( + write, [&](uint64_t location, const uint8_t*, + const uint8_t* replacement, size_t size) { + const uint64_t begin = std::max(location, overlay.location); + const uint64_t end = std::min( + location + size, overlay.location + overlay.payload.size()); + for (uint64_t at = begin; !found && at < end; ++at) { + if (replacement[static_cast<size_t>(at - location)] != + overlay.payload[ + static_cast<size_t>(at - overlay.location)]) { + mismatch = at; + found = true; + } + } + }); + return found; +} + +bool overlay_overlap_mismatch(const ModResolution::Overlay& a, + const ModResolution::Overlay& b, + uint64_t& mismatch) { + const uint64_t begin = std::max(a.location, b.location); + const uint64_t end = std::min( + a.location + a.payload.size(), b.location + b.payload.size()); + for (uint64_t at = begin; at < end; ++at) { + if (a.payload[(size_t)(at - a.location)] != + b.payload[(size_t)(at - b.location)]) { + mismatch = at; + return true; + } + } + return false; +} + +std::string overlap_resource(ModPatchTarget target, + uint64_t a_location, size_t a_size, + uint64_t b_location, size_t b_size) { + const char* name = target == ModPatchTarget::MainExe ? "main_exe" : + target == ModPatchTarget::DiscRaw ? "disc_raw" : + "disc_user"; + const uint64_t begin = std::max(a_location, b_location); + const uint64_t end = std::min( + a_location + a_size, b_location + b_size); + std::ostringstream out; + out << name << ":0x" << std::hex << begin << "-0x" << end; + return out.str(); +} + +std::string byte_resource(ModPatchTarget target, uint64_t location) { + return overlap_resource(target, location, 1, location, 1); +} + +bool parse_canonical_int64(const std::string& value, int64_t& parsed) { + try { + size_t used = 0; + parsed = std::stoll(value, &used); + return used == value.size() && std::to_string(parsed) == value; + } catch (...) { + return false; + } +} + +bool integer_step_aligned(int64_t value, int64_t minimum, int64_t step) { + if (value < minimum || step <= 0) return false; + const uint64_t distance = + static_cast<uint64_t>(value) - static_cast<uint64_t>(minimum); + return distance % static_cast<uint64_t>(step) == 0; +} + +bool valid_option_value(const ModOption& option, const std::string& value) { + if (option.type == ModOptionType::Boolean) + return value == "true" || value == "false"; + if (option.type == ModOptionType::Choice) + return std::any_of(option.choices.begin(), option.choices.end(), + [&](const ModChoice& choice) { return choice.value == value; }); + int64_t parsed = 0; + return parse_canonical_int64(value, parsed) && + parsed >= option.min_value && parsed <= option.max_value && + integer_step_aligned(parsed, option.min_value, option.step); +} + +bool integer_predicate_matches( + const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, + const ModIntegerPredicate& predicate) { + if (!predicate.present) return true; + int64_t selected = 0; + if (!parse_canonical_int64( + effective_option_value( + package, selection, feature_id, predicate.option), + selected)) + return false; + switch (predicate.op) { + case ModIntegerPredicateOp::Equal: + return selected == predicate.value; + case ModIntegerPredicateOp::NotEqual: + return selected != predicate.value; + case ModIntegerPredicateOp::Less: + return selected < predicate.value; + case ModIntegerPredicateOp::LessEqual: + return selected <= predicate.value; + case ModIntegerPredicateOp::Greater: + return selected > predicate.value; + case ModIntegerPredicateOp::GreaterEqual: + return selected >= predicate.value; + } + return false; +} + +bool constraint_satisfied( + const ModPackage& package, const ModConstraint& constraint, + const std::function<std::string(const std::string&)>& value_for, + std::string* reason) { + if (constraint.kind != ModConstraintKind::OrderedInteger) return false; + for (size_t index = 1; index < constraint.options.size(); ++index) { + const std::string& previous_id = constraint.options[index - 1]; + const std::string& current_id = constraint.options[index]; + int64_t previous = 0; + int64_t current = 0; + if (!parse_canonical_int64(value_for(previous_id), previous) || + !parse_canonical_int64(value_for(current_id), current)) { + if (reason) + *reason = "constraint references a non-integer value"; + return false; + } + const bool violated = + constraint.direction == ModConstraintDirection::Nondecreasing + ? previous > current + : previous < current; + if (violated) { + if (reason) { + const ModOption* previous_option = find_option( + package, constraint.feature_id, previous_id); + const ModOption* current_option = find_option( + package, constraint.feature_id, current_id); + *reason = + (previous_option ? previous_option->label : previous_id) + + (constraint.direction == + ModConstraintDirection::Nondecreasing + ? " must be less than or equal to " + : " must be greater than or equal to ") + + (current_option ? current_option->label : current_id); + } + return false; + } + } + return true; +} + +bool checked_add_int64(int64_t left, int64_t right, int64_t& out) { + if ((right > 0 && left > std::numeric_limits<int64_t>::max() - right) || + (right < 0 && left < std::numeric_limits<int64_t>::min() - right)) + return false; + out = left + right; + return true; +} + +uint64_t value_encoding_max(ModValueEncoding encoding) { + switch (encoding) { + case ModValueEncoding::U8: + return UINT8_MAX; + case ModValueEncoding::U16LE: + return UINT16_MAX; + case ModValueEncoding::U32LE: + return UINT32_MAX; + case ModValueEncoding::MipsLuiOriU32: + return UINT32_MAX; + } + return 0; +} + +bool option_range_fits_encoding(const ModOption& option, + ModValueEncoding encoding, + int64_t addend) { + int64_t low = 0; + int64_t high = 0; + return checked_add_int64(option.min_value, addend, low) && + checked_add_int64(option.max_value, addend, high) && + low >= 0 && + static_cast<uint64_t>(high) <= value_encoding_max(encoding); +} + +bool encode_unsigned_value(ModValueEncoding encoding, int64_t value, + std::vector<uint8_t>& out) { + if (value < 0 || + static_cast<uint64_t>(value) > value_encoding_max(encoding)) + return false; + const uint32_t size = value_encoding_size(encoding); + out.resize(size); + const uint64_t encoded = static_cast<uint64_t>(value); + if (encoding == ModValueEncoding::MipsLuiOriU32) return false; + for (uint32_t i = 0; i < size; ++i) + out[i] = static_cast<uint8_t>(encoded >> (i * 8)); + return true; +} + +std::string fingerprint_text(const std::string& text) { + uint8_t digest[32]; + psx_sha256_compute((const uint8_t*)text.data(), text.size(), digest); + std::ostringstream out; + for (uint8_t byte : digest) + out << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + return out.str(); +} + +} // namespace + +bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver) { + if (!valid_id(id) || !resolver) return false; + return builtin_resolvers().emplace(id, std::move(resolver)).second; +} + +void mod_clear_builtin_resolvers_for_tests() { + builtin_resolvers().clear(); +} + +bool mod_register_activation_plugin(const std::string& id, void (*callback)(void)) { + if (!valid_id(id) || !callback) return false; + RegisteredPlugin& plugin = registered_plugins()[id]; + if (plugin.activation) return false; + plugin.activation = callback; + return true; +} + +bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) { + if (!valid_id(id) || !callback) return false; + RegisteredPlugin& plugin = registered_plugins()[id]; + if (plugin.vblank) return false; + plugin.vblank = callback; + return true; +} + +bool mod_plugin_registered(const std::string& id) { + const auto found = registered_plugins().find(id); + return found != registered_plugins().end() && + (found->second.activation || found->second.vblank); +} + +void mod_invoke_activation_plugin(const std::string& id) { + const auto found = registered_plugins().find(id); + if (found != registered_plugins().end() && found->second.activation) + found->second.activation(); +} + +void mod_invoke_vblank_plugin(const std::string& id) { + const auto found = registered_plugins().find(id); + if (found != registered_plugins().end() && found->second.vblank) + found->second.vblank(); +} + +void mod_clear_plugins_for_tests() { + registered_plugins().clear(); +} + +ModPackageManager::ModPackageManager(fs::path mods_root) : root_(std::move(mods_root)) {} + +void ModPackageManager::set_root(fs::path mods_root) { + root_ = std::move(mods_root); + packages_.clear(); + selections_.clear(); +} + +bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, + std::string* error) { + try { + const toml::value cfg = toml::parse(path.string()); + out = {}; + out.format_version = (uint32_t)toml::find<int64_t>(cfg, "format_version"); + out.id = toml::find<std::string>(cfg, "id"); + out.version = toml::find<std::string>(cfg, "version"); + out.name = toml::find<std::string>(cfg, "name"); + out.author = cfg.contains("author") ? toml::find<std::string>(cfg, "author") : ""; + out.description = + cfg.contains("description") ? toml::find<std::string>(cfg, "description") : ""; + out.license = cfg.contains("license") ? toml::find<std::string>(cfg, "license") : ""; + out.resolver = + cfg.contains("resolver") ? toml::find<std::string>(cfg, "resolver") : "declarative"; + out.save_compatibility = cfg.contains("save_compatibility") + ? toml::find<std::string>(cfg, "save_compatibility") : "shared"; + out.root = path.parent_path(); + if (out.format_version < kMinFormatVersion || + out.format_version > kMaxFormatVersion) + throw std::runtime_error("unsupported format_version"); + if (!valid_id(out.id)) throw std::runtime_error("invalid package id"); + if (!parse_semver(out.version).valid) throw std::runtime_error("invalid semantic version"); + if (out.name.empty()) throw std::runtime_error("package name is empty"); + if (out.resolver != "declarative" && out.resolver.rfind("builtin:", 0) != 0) + throw std::runtime_error("resolver must be declarative or builtin:<id>"); + if (out.save_compatibility != "shared" && out.save_compatibility != "isolated") + throw std::runtime_error("save_compatibility must be shared or isolated"); + + if (cfg.contains("target")) { + for (const toml::value& v : toml::find(cfg, "target").as_array()) { + ModTarget target; + target.game_id = toml::find<std::string>(v, "game_id"); + target.exe_sha256 = + v.contains("exe_sha256") ? toml::find<std::string>(v, "exe_sha256") : ""; + target.disc_sha256 = + v.contains("disc_sha256") ? toml::find<std::string>(v, "disc_sha256") : ""; + if (target.game_id.empty()) throw std::runtime_error("target game_id is empty"); + out.targets.push_back(std::move(target)); + } + } + if (out.targets.empty()) throw std::runtime_error("package has no [[target]] entries"); + + if (cfg.contains("dependency")) { + for (const toml::value& v : toml::find(cfg, "dependency").as_array()) { + ModRequirement dep; + dep.id = toml::find<std::string>(v, "id"); + dep.version = v.contains("version") ? toml::find<std::string>(v, "version") : "*"; + if (!valid_id(dep.id)) throw std::runtime_error("invalid dependency id"); + out.dependencies.push_back(std::move(dep)); + } + } + if (cfg.contains("conflicts")) + out.conflicts = toml::find<std::vector<std::string>>(cfg, "conflicts"); + for (const std::string& id : out.conflicts) + if (!valid_id(id)) throw std::runtime_error("invalid conflict id"); + + const bool feature_style = cfg.contains("feature"); + if (feature_style) { + std::set<std::string> feature_ids; + for (const toml::value& v : toml::find(cfg, "feature").as_array()) { + ModFeature feature; + feature.id = toml::find<std::string>(v, "id"); + feature.name = toml::find<std::string>(v, "name"); + feature.description = v.contains("description") + ? toml::find<std::string>(v, "description") : ""; + feature.group = v.contains("group") + ? toml::find<std::string>(v, "group") : "General"; + feature.default_enabled = + toml::find_or<bool>(v, "default_enabled", false); + if (!valid_id(feature.id) || + !feature_ids.insert(feature.id).second) + throw std::runtime_error("invalid or duplicate feature id"); + if (feature.name.empty()) + throw std::runtime_error("feature name is empty"); + out.features.push_back(std::move(feature)); + } + if (out.features.empty()) + throw std::runtime_error("package has no [[feature]] entries"); + } else { + ModFeature feature; + feature.id = "legacy"; + feature.name = out.name; + feature.description = out.description; + feature.legacy = true; + out.features.push_back(std::move(feature)); + } + + if (cfg.contains("option")) { + std::set<std::pair<std::string, std::string>> option_ids; + for (const toml::value& v : toml::find(cfg, "option").as_array()) { + ModOption option; + option.feature_id = feature_style + ? toml::find<std::string>(v, "feature") : "legacy"; + option.id = toml::find<std::string>(v, "id"); + option.label = toml::find<std::string>(v, "label"); + option.description = + v.contains("description") ? toml::find<std::string>(v, "description") : ""; + option.group = v.contains("group") ? toml::find<std::string>(v, "group") : "General"; + const std::string type = toml::find<std::string>(v, "type"); + if (!find_feature(out, option.feature_id)) + throw std::runtime_error("option references unknown feature"); + if (!valid_id(option.id) || + !option_ids.insert({option.feature_id, option.id}).second) + throw std::runtime_error("invalid or duplicate option id"); + if (type == "boolean") { + option.type = ModOptionType::Boolean; + option.default_value = toml::find_or<std::string>(v, "default", "false"); + if (option.default_value != "true" && option.default_value != "false") + throw std::runtime_error("boolean default must be true or false"); + } else if (type == "choice") { + option.type = ModOptionType::Choice; + option.default_value = toml::find<std::string>(v, "default"); + for (const toml::value& c : toml::find(v, "choice").as_array()) { + ModChoice choice; + choice.value = toml::find<std::string>(c, "value"); + choice.label = toml::find<std::string>(c, "label"); + option.choices.push_back(std::move(choice)); + } + const auto found = std::find_if(option.choices.begin(), option.choices.end(), + [&](const ModChoice& c) { return c.value == option.default_value; }); + if (found == option.choices.end()) + throw std::runtime_error("choice default is not declared"); + } else if (type == "integer") { + option.type = ModOptionType::Integer; + option.min_value = toml::find<int64_t>(v, "min"); + option.max_value = toml::find<int64_t>(v, "max"); + option.step = toml::find_or<int64_t>(v, "step", 1); + const int64_t def = toml::find<int64_t>(v, "default"); + if (option.min_value > option.max_value || option.step <= 0 || + def < option.min_value || def > option.max_value || + !integer_step_aligned( + def, option.min_value, option.step)) + throw std::runtime_error("invalid integer bounds/default"); + option.default_value = std::to_string(def); + } else { + throw std::runtime_error("unknown option type"); + } + out.options.push_back(std::move(option)); + } + } + if (cfg.contains("constraint")) { + if (out.format_version < 3) + throw std::runtime_error( + "constraints require format_version 3"); + for (const toml::value& v : + toml::find(cfg, "constraint").as_array()) { + ModConstraint constraint; + constraint.feature_id = + toml::find<std::string>(v, "feature"); + const std::string kind = + toml::find<std::string>(v, "kind"); + if (!find_feature(out, constraint.feature_id)) + throw std::runtime_error( + "constraint references unknown feature"); + if (kind == "ordered_integer") { + const std::string direction = + toml::find<std::string>(v, "direction"); + constraint.options = + toml::find<std::vector<std::string>>(v, "options"); + if (direction == "nondecreasing") { + constraint.direction = + ModConstraintDirection::Nondecreasing; + } else if (direction == "nonincreasing") { + constraint.direction = + ModConstraintDirection::Nonincreasing; + } else { + throw std::runtime_error( + "ordered_integer direction must be " + "nondecreasing or nonincreasing"); + } + if (constraint.options.size() < 2) + throw std::runtime_error( + "ordered_integer requires at least two options"); + std::set<std::string> option_ids; + for (const std::string& id : constraint.options) { + const ModOption* option = + find_option(out, constraint.feature_id, id); + if (!option || option->type != ModOptionType::Integer) + throw std::runtime_error( + "ordered_integer must reference same-feature " + "integer options"); + if (!option_ids.insert(id).second) + throw std::runtime_error( + "ordered_integer contains a duplicate option"); + } + std::string reason; + if (!constraint_satisfied( + out, constraint, + [&](const std::string& id) { + return find_option( + out, constraint.feature_id, id) + ->default_value; + }, + &reason)) + throw std::runtime_error( + "constraint defaults are invalid: " + reason); + } else if (kind == "requires_feature") { + if (out.format_version < 4) + throw std::runtime_error( + "requires_feature constraints require format_version 4"); + constraint.kind = ModConstraintKind::RequiresFeature; + constraint.required_feature_id = + toml::find<std::string>(v, "requires_feature"); + if (!find_feature(out, constraint.required_feature_id)) + throw std::runtime_error( + "requires_feature references unknown feature"); + constraint.required_option_id = + toml::find_or<std::string>(v, "requires_option", ""); + constraint.required_value = + toml::find_or<std::string>(v, "requires_value", ""); + if (constraint.required_option_id.empty() != + constraint.required_value.empty()) + throw std::runtime_error( + "requires_feature option constraint requires both " + "requires_option and requires_value"); + if (!constraint.required_option_id.empty()) { + const ModOption* option = find_option( + out, constraint.required_feature_id, + constraint.required_option_id); + if (!option) + throw std::runtime_error( + "requires_feature references unknown option"); + if (!valid_option_value( + *option, constraint.required_value)) + throw std::runtime_error( + "requires_feature references invalid option value"); + } + } else { + throw std::runtime_error("unknown constraint kind"); + } + out.constraints.push_back(std::move(constraint)); + } + } + if (cfg.contains("patch")) { + size_t declaration_index = 0; + for (const toml::value& v : toml::find(cfg, "patch").as_array()) { + ModPatch patch; + patch.feature_id = feature_style + ? toml::find<std::string>(v, "feature") : "legacy"; + if (!find_feature(out, patch.feature_id)) + throw std::runtime_error("patch references unknown feature"); + const std::string target = toml::find<std::string>(v, "target"); + if (target == "main_exe") { + patch.target = ModPatchTarget::MainExe; + const int64_t address = toml::find<int64_t>(v, "address"); + if (address < 0) throw std::runtime_error("patch address is negative"); + patch.location = (uint64_t)address; + } else if (target == "disc_raw" || target == "disc") { + patch.target = ModPatchTarget::DiscRaw; + const int64_t offset = toml::find<int64_t>(v, "offset"); + if (offset < 0) throw std::runtime_error("patch offset is negative"); + patch.location = (uint64_t)offset; + } else if (target == "disc_user") { + patch.target = ModPatchTarget::DiscUser; + const int64_t offset = toml::find<int64_t>(v, "offset"); + if (offset < 0) throw std::runtime_error("patch offset is negative"); + patch.location = (uint64_t)offset; + } else { + throw std::runtime_error( + "patch target must be main_exe, disc_raw, or disc_user"); + } + const std::string expected = toml::find<std::string>(v, "expected"); + if (!parse_hex_bytes(expected, patch.expected) || + patch.expected.empty()) + throw std::runtime_error( + "patch expected must be non-empty hex"); + const bool has_static_replace = v.contains("replace"); + const bool has_dynamic_replace = v.contains("replace_from"); + const bool has_sparse_fields = v.contains("fields"); + const unsigned replacement_forms = + static_cast<unsigned>(has_static_replace) + + static_cast<unsigned>(has_dynamic_replace) + + static_cast<unsigned>(has_sparse_fields); + if (replacement_forms != 1) + throw std::runtime_error( + "patch requires exactly one of replace, replace_from, " + "or fields"); + if (has_static_replace) { + const std::string replacement = + toml::find<std::string>(v, "replace"); + if (!parse_hex_bytes(replacement, patch.replacement) || + patch.expected.size() != patch.replacement.size()) + throw std::runtime_error( + "patch expected/replace must be equal-length non-empty hex"); + } else if (has_dynamic_replace) { + if (out.format_version < 2) + throw std::runtime_error( + "replace_from requires format_version 2"); + const toml::value& replacement = + toml::find(v, "replace_from"); + const auto& table = replacement.as_table(); + for (const auto& [key, unused] : table) { + (void)unused; + if (key != "option" && key != "encoding" && + key != "offset" && key != "addend" && + key != "omit_when_default") + throw std::runtime_error( + "replace_from has unknown field: " + key); + } + patch.replace_from_option = + toml::find<std::string>(replacement, "option"); + const std::string encoding = + toml::find<std::string>(replacement, "encoding"); + const int64_t replace_offset = + toml::find_or<int64_t>(replacement, "offset", 0); + if (replace_offset < 0) + throw std::runtime_error( + "replace_from offset must not be negative"); + patch.replace_offset = + static_cast<uint64_t>(replace_offset); + patch.replace_addend = + toml::find_or<int64_t>(replacement, "addend", 0); + patch.replace_omit_when_default = + toml::find_or<bool>( + replacement, "omit_when_default", false); + if (!parse_value_encoding( + encoding, patch.replace_encoding)) + throw std::runtime_error( + "replace_from encoding is unsupported"); + const bool mips_pair_encoding = + patch.replace_encoding == + ModValueEncoding::MipsLuiOriU32; + if ((mips_pair_encoding || + patch.replace_omit_when_default) && + out.format_version < 3) + throw std::runtime_error( + "typed MIPS encodings and omit_when_default " + "require format_version 3"); + const ModOption* option = find_option( + out, patch.feature_id, patch.replace_from_option); + if (!option || option->type != ModOptionType::Integer) + throw std::runtime_error( + "replace_from must reference a same-feature integer option"); + const uint64_t encoded_size = + value_encoding_size(patch.replace_encoding); + if (patch.replace_offset > patch.expected.size() || + encoded_size > + patch.expected.size() - patch.replace_offset) + throw std::runtime_error( + "replace_from value exceeds expected byte range"); + if (!option_range_fits_encoding( + *option, patch.replace_encoding, + patch.replace_addend)) + throw std::runtime_error( + "replace_from option range/addend does not fit encoding"); + if (mips_pair_encoding) { + if (patch.target != ModPatchTarget::MainExe || + patch.expected.size() != 8 || + patch.replace_offset != 0 || + patch.replace_addend != 0 || + patch.location % 4 != 0) + throw std::runtime_error( + "typed MIPS LUI/ORI encoding requires one " + "aligned complete main_exe instruction pair " + "at offset zero without an addend"); + const uint32_t lui = + static_cast<uint32_t>(patch.expected[0]) | + (static_cast<uint32_t>(patch.expected[1]) << 8) | + (static_cast<uint32_t>(patch.expected[2]) << 16) | + (static_cast<uint32_t>(patch.expected[3]) << 24); + const uint32_t ori = + static_cast<uint32_t>(patch.expected[4]) | + (static_cast<uint32_t>(patch.expected[5]) << 8) | + (static_cast<uint32_t>(patch.expected[6]) << 16) | + (static_cast<uint32_t>(patch.expected[7]) << 24); + const uint32_t lui_rs = (lui >> 21) & 0x1Fu; + const uint32_t lui_rt = (lui >> 16) & 0x1Fu; + const uint32_t ori_rs = (ori >> 21) & 0x1Fu; + const uint32_t ori_rt = (ori >> 16) & 0x1Fu; + if ((lui >> 26) != 0x0Fu || lui_rs != 0 || + lui_rt == 0 || (ori >> 26) != 0x0Du || + ori_rs != lui_rt || ori_rt != lui_rt) + throw std::runtime_error( + "typed MIPS encoding guard is not a linked " + "LUI/ORI register pair"); + } + } else { + if (out.format_version < 4) + throw std::runtime_error( + "sparse patch fields require format_version 4"); + const toml::value fields_value = + toml::find(v, "fields"); + const toml::array& fields = + fields_value.as_array(); + if (fields.empty()) + throw std::runtime_error( + "sparse patch fields must not be empty"); + std::vector<RelativeRange> claimed; + for (const toml::value& field_value : fields) { + const auto& table = field_value.as_table(); + for (const auto& [key, unused] : table) { + (void)unused; + if (key != "offset" && key != "replace" && + key != "option" && key != "encoding" && + key != "addend") + throw std::runtime_error( + "sparse patch field has unknown field: " + + key); + } + ModPatchField field; + const int64_t field_offset = + toml::find_or<int64_t>( + field_value, "offset", 0); + if (field_offset < 0) + throw std::runtime_error( + "sparse patch field offset must not be " + "negative"); + field.offset = + static_cast<uint64_t>(field_offset); + const bool literal = + field_value.contains("replace"); + const bool dynamic = + field_value.contains("option") || + field_value.contains("encoding") || + field_value.contains("addend"); + if (literal == dynamic) + throw std::runtime_error( + "sparse patch field requires exactly one of " + "literal replace or option encoding"); + if (literal) { + if (!parse_hex_bytes( + toml::find<std::string>( + field_value, "replace"), + field.replacement) || + field.replacement.empty()) + throw std::runtime_error( + "sparse patch literal field must be " + "non-empty hex"); + } else { + if (!field_value.contains("option") || + !field_value.contains("encoding")) + throw std::runtime_error( + "sparse dynamic field requires option " + "and encoding"); + field.replace_from_option = + toml::find<std::string>( + field_value, "option"); + if (!parse_value_encoding( + toml::find<std::string>( + field_value, "encoding"), + field.replace_encoding)) + throw std::runtime_error( + "sparse dynamic field encoding is " + "unsupported"); + field.replace_addend = + toml::find_or<int64_t>( + field_value, "addend", 0); + const ModOption* option = find_option( + out, patch.feature_id, + field.replace_from_option); + if (!option || + option->type != ModOptionType::Integer) + throw std::runtime_error( + "sparse dynamic field must reference a " + "same-feature integer option"); + if (!option_range_fits_encoding( + *option, field.replace_encoding, + field.replace_addend)) + throw std::runtime_error( + "sparse dynamic field option range/" + "addend does not fit encoding"); + if (field.replace_encoding == + ModValueEncoding::MipsLuiOriU32) { + if (patch.target != + ModPatchTarget::MainExe || + field.replace_addend != 0 || + (patch.location + field.offset) % 4 != 0 || + !valid_mips_lui_ori_guard( + patch.expected, field.offset)) + throw std::runtime_error( + "sparse typed MIPS field requires an " + "aligned linked main_exe LUI/ORI " + "guard without an addend"); + } + } + const uint64_t guard_span = + field.replacement.empty() + ? value_encoding_size( + field.replace_encoding) + : field.replacement.size(); + if (field.offset > patch.expected.size() || + guard_span > + patch.expected.size() - field.offset) + throw std::runtime_error( + "sparse patch field exceeds expected guard"); + for (const RelativeRange& range : + patch_field_ranges(field)) { + for (const RelativeRange& previous : claimed) { + if (range.offset < + previous.offset + previous.size && + previous.offset < + range.offset + range.size) + throw std::runtime_error( + "sparse patch fields overlap"); + } + claimed.push_back(range); + } + patch.fields.push_back(std::move(field)); + } + } + const uint64_t sector_size = + patch.target == ModPatchTarget::DiscRaw ? 2352 : + patch.target == ModPatchTarget::DiscUser ? 2048 : 0; + if (sector_size != 0 && + patch.location % sector_size + patch.expected.size() > sector_size) + throw std::runtime_error( + "disc patch may not cross a sector boundary"); + if (patch.target == ModPatchTarget::MainExe && + (patch.location > UINT32_MAX || + patch.expected.size() > + static_cast<uint64_t>(UINT32_MAX) + 1 - + patch.location)) + throw std::runtime_error( + "main_exe patch exceeds 32-bit guest address space"); + if (sector_size != 0 && + patch.location / sector_size > UINT32_MAX) + throw std::runtime_error( + "disc patch LBA exceeds runtime index range"); + patch.order = toml::find_or<int64_t>( + v, "order", (int64_t)declaration_index); + read_conditions(v, out.options, patch.feature_id, patch.when, "patch"); + if (!patch.when.empty()) { + patch.when_option = patch.when.begin()->first; + patch.when_value = patch.when.begin()->second; + } + if (v.contains("when_integer")) { + if (out.format_version < 4) + throw std::runtime_error( + "when_integer requires format_version 4"); + const toml::value predicate = + toml::find(v, "when_integer"); + const auto& table = predicate.as_table(); + for (const auto& [key, unused] : table) { + (void)unused; + if (key != "option" && key != "op" && + key != "value") + throw std::runtime_error( + "when_integer has unknown field: " + key); + } + patch.when_integer.present = true; + patch.when_integer.option = + toml::find<std::string>(predicate, "option"); + if (!parse_integer_predicate_op( + toml::find<std::string>(predicate, "op"), + patch.when_integer.op)) + throw std::runtime_error( + "when_integer op must be eq, ne, lt, le, gt, " + "or ge"); + patch.when_integer.value = + toml::find<int64_t>(predicate, "value"); + const ModOption* option = find_option( + out, patch.feature_id, + patch.when_integer.option); + if (!option || + option->type != ModOptionType::Integer) + throw std::runtime_error( + "when_integer must reference a same-feature " + "integer option"); + if (patch.when_integer.value < option->min_value || + patch.when_integer.value > option->max_value) + throw std::runtime_error( + "when_integer value is outside option bounds"); + if ((patch.when_integer.op == + ModIntegerPredicateOp::Equal || + patch.when_integer.op == + ModIntegerPredicateOp::NotEqual) && + !integer_step_aligned( + patch.when_integer.value, + option->min_value, option->step)) + throw std::runtime_error( + "when_integer equality value is not selectable"); + } + out.patches.push_back(std::move(patch)); + ++declaration_index; + } + } + if (cfg.contains("overlay")) { + if (!feature_style) + throw std::runtime_error( + "disc overlays require explicit [[feature]] ownership"); + size_t declaration_index = 0; + for (const toml::value& v : toml::find(cfg, "overlay").as_array()) { + ModOverlay overlay; + overlay.feature_id = toml::find<std::string>(v, "feature"); + if (!find_feature(out, overlay.feature_id)) + throw std::runtime_error("overlay references unknown feature"); + const std::string target = toml::find<std::string>(v, "target"); + if (target == "disc_raw") + overlay.target = ModPatchTarget::DiscRaw; + else if (target == "disc_user") + overlay.target = ModPatchTarget::DiscUser; + else + throw std::runtime_error( + "overlay target must be disc_raw or disc_user"); + const int64_t offset = toml::find<int64_t>(v, "offset"); + if (offset < 0) + throw std::runtime_error("overlay offset is negative"); + overlay.location = (uint64_t)offset; + const std::string relative_file = + toml::find<std::string>(v, "file"); + if (!safe_archive_name(relative_file)) + throw std::runtime_error("overlay file path is unsafe"); + overlay.file = out.root / fs::path(relative_file); + overlay.sha256 = toml::find<std::string>(v, "sha256"); + overlay.expected_sha256 = v.contains("expected_sha256") + ? toml::find<std::string>(v, "expected_sha256") : ""; + if (!valid_sha256(overlay.sha256) || + (!overlay.expected_sha256.empty() && + !valid_sha256(overlay.expected_sha256))) + throw std::runtime_error( + "overlay hashes must be lowercase SHA-256"); + std::string file_error; + std::vector<uint8_t> payload; + if (!read_file(overlay.file, payload, &file_error)) + throw std::runtime_error(file_error); + if (payload.empty()) + throw std::runtime_error("overlay payload is empty"); + const std::string actual = fingerprint_text(std::string( + (const char*)payload.data(), payload.size())); + if (actual != overlay.sha256) + throw std::runtime_error("overlay payload checksum failed"); + overlay.size = payload.size(); + if (overlay.location > + std::numeric_limits<uint64_t>::max() - overlay.size) + throw std::runtime_error("overlay range overflows"); + overlay.order = toml::find_or<int64_t>( + v, "order", (int64_t)declaration_index); + read_conditions(v, out.options, overlay.feature_id, + overlay.when, "overlay"); + out.overlays.push_back(std::move(overlay)); + ++declaration_index; + } + const bool guarded_stock = std::all_of( + out.targets.begin(), out.targets.end(), + [](const ModTarget& target) { + return valid_sha256(target.disc_sha256); + }); + if (!out.overlays.empty() && !guarded_stock) + throw std::runtime_error( + "feature disc overlays require an exact disc_sha256 " + "on every [[target]]"); + } + if (cfg.contains("plugin")) { + if (!feature_style) + throw std::runtime_error( + "plugins require explicit [[feature]] ownership"); + if (out.format_version < 5) + throw std::runtime_error( + "plugins require format_version 5"); + size_t declaration_index = 0; + for (const toml::value& v : + toml::find(cfg, "plugin").as_array()) { + ModPlugin plugin; + plugin.feature_id = + toml::find<std::string>(v, "feature"); + if (!find_feature(out, plugin.feature_id)) + throw std::runtime_error( + "plugin references unknown feature"); + plugin.id = toml::find<std::string>(v, "id"); + if (!valid_id(plugin.id)) + throw std::runtime_error("invalid plugin id"); + plugin.order = toml::find_or<int64_t>( + v, "order", (int64_t)declaration_index); + read_conditions(v, out.options, plugin.feature_id, + plugin.when, "plugin"); + out.plugins.push_back(std::move(plugin)); + ++declaration_index; + } + } + if (cfg.contains("derived_disc")) { + if (feature_style) + throw std::runtime_error( + "derived_disc is a legacy conversion artifact and may not " + "be used by feature-style packages"); + for (const toml::value& v : toml::find(cfg, "derived_disc").as_array()) { + ModDerivedDisc derived; + derived.kind = toml::find_or<std::string>(v, "kind", "vcdiff"); + const std::string relative_patch = toml::find<std::string>(v, "patch"); + derived.patch_sha256 = toml::find<std::string>(v, "patch_sha256"); + const int64_t output_size = toml::find<int64_t>(v, "output_size"); + derived.output_sha256 = toml::find<std::string>(v, "output_sha256"); + if (derived.kind != "vcdiff") + throw std::runtime_error("derived_disc kind must be vcdiff"); + if (!safe_archive_name(relative_patch)) + throw std::runtime_error("derived_disc patch path is unsafe"); + if (!valid_sha256(derived.patch_sha256) || + !valid_sha256(derived.output_sha256)) + throw std::runtime_error( + "derived_disc hashes must be lowercase SHA-256"); + if (output_size <= 0) + throw std::runtime_error("derived_disc output_size must be positive"); + derived.output_size = (uint64_t)output_size; + read_conditions(v, out.options, "legacy", + derived.when, "derived_disc"); + if (!derived.when.empty()) { + derived.when_option = derived.when.begin()->first; + derived.when_value = derived.when.begin()->second; + } + derived.patch = out.root / fs::path(relative_patch); + if (!fs::is_regular_file(derived.patch)) + throw std::runtime_error( + "derived_disc patch asset is missing: " + relative_patch); + out.derived_discs.push_back(std::move(derived)); + } + } + return true; + } catch (const std::exception& ex) { + set_error(error, path.string() + ": " + ex.what()); + return false; + } +} + +bool ModPackageManager::scan(std::string* error) { + packages_.clear(); + std::error_code ec; + const fs::path packages_root = root_ / "packages"; + if (!fs::exists(packages_root, ec)) return true; + for (const fs::directory_entry& id_dir : fs::directory_iterator(packages_root, ec)) { + if (ec) break; + if (!id_dir.is_directory()) continue; + for (const fs::directory_entry& version_dir : fs::directory_iterator(id_dir.path(), ec)) { + if (ec) break; + if (!version_dir.is_directory()) continue; + const fs::path manifest = version_dir.path() / "manifest.toml"; + if (!fs::exists(manifest)) continue; + ModPackage package; + std::string parse_error; + if (!read_manifest(manifest, package, &parse_error)) { + continue; + } + if (package.id != id_dir.path().filename().string() || + package.version != version_dir.path().filename().string()) { + set_error(error, "package path does not match manifest id/version: " + + manifest.string()); + return false; + } + packages_[package.id][package.version] = std::move(package); + } + } + if (ec) { + set_error(error, "cannot scan packages: " + ec.message()); + return false; + } + return true; +} + +bool ModPackageManager::load_state(std::string* error) { + selections_.clear(); + const fs::path path = root_ / "state.toml"; + if (!fs::exists(path)) return true; + try { + const toml::value cfg = toml::parse(path.string()); + const int64_t version = toml::find<int64_t>(cfg, "format_version"); + if (version != 1 && version != 2) + throw std::runtime_error("unsupported state format_version"); + if (cfg.contains("package")) { + for (const toml::value& v : toml::find(cfg, "package").as_array()) { + const std::string id = toml::find<std::string>(v, "id"); + if (!valid_id(id)) throw std::runtime_error("invalid state package id"); + ModSelection selection; + selection.enabled = toml::find_or<bool>(v, "enabled", false); + selection.version = toml::find_or<std::string>(v, "version", ""); + if (v.contains("values")) { + for (const auto& [key, value] : toml::find(v, "values").as_table()) { + if (value.is_string()) + selection.values[key] = toml::get<std::string>(value); + else if (value.is_boolean()) + selection.values[key] = + toml::get<bool>(value) ? "true" : "false"; + else if (value.is_integer()) + selection.values[key] = + std::to_string(toml::get<int64_t>(value)); + else + throw std::runtime_error( + "state option values must be scalar"); + } + } + selections_[id] = std::move(selection); + } + } + if (version == 2 && cfg.contains("feature")) { + for (const toml::value& v : toml::find(cfg, "feature").as_array()) { + const std::string package_id = + toml::find<std::string>(v, "package_id"); + const std::string feature_id = toml::find<std::string>(v, "id"); + if (!valid_id(package_id) || !valid_id(feature_id)) + throw std::runtime_error("invalid state feature identity"); + ModFeatureSelection feature; + feature.enabled = toml::find<bool>(v, "enabled"); + feature.has_enabled = true; + if (v.contains("values")) { + for (const auto& [key, value] : + toml::find(v, "values").as_table()) { + if (value.is_string()) + feature.values[key] = toml::get<std::string>(value); + else if (value.is_boolean()) + feature.values[key] = + toml::get<bool>(value) ? "true" : "false"; + else if (value.is_integer()) + feature.values[key] = + std::to_string(toml::get<int64_t>(value)); + else + throw std::runtime_error( + "state feature option values must be scalar"); + } + } + selections_[package_id].features[feature_id] = + std::move(feature); + } + } + return true; + } catch (const std::exception& ex) { + set_error(error, path.string() + ": " + ex.what()); + return false; + } +} + +bool ModPackageManager::save_state(std::string* error) const { + std::error_code ec; + fs::create_directories(root_, ec); + if (ec) { + set_error(error, "cannot create mods directory: " + ec.message()); + return false; + } + const fs::path temp = root_ / "state.toml.tmp"; + const fs::path final = root_ / "state.toml"; + std::ofstream out(temp, std::ios::trunc); + if (!out) { + set_error(error, "cannot write " + temp.string()); + return false; + } + out << "format_version = 2\n"; + for (const auto& [id, selection] : selections_) { + out << "\n[[package]]\n"; + out << "id = " << quote_toml(id) << "\n"; + if (!selection.version.empty()) + out << "version = " << quote_toml(selection.version) << "\n"; + const ModPackage* package = find_selected(packages_, id, selection); + const bool legacy = package && package->features.size() == 1 && + package->features.front().legacy; + if (legacy) { + out << "enabled = " << (selection.enabled ? "true" : "false") << "\n"; + } + if (legacy && !selection.values.empty()) { + out << "[package.values]\n"; + for (const auto& [key, value] : selection.values) + out << key << " = " << quote_toml(value) << "\n"; + } + } + for (const auto& [package_id, selection] : selections_) { + for (const auto& [feature_id, feature] : selection.features) { + bool enabled = feature.enabled; + if (!feature.has_enabled) { + const ModPackage* package = + find_selected(packages_, package_id, selection); + const ModFeature* manifest_feature = + package ? find_feature(*package, feature_id) : nullptr; + if (manifest_feature) + enabled = manifest_feature->default_enabled; + } + out << "\n[[feature]]\n"; + out << "package_id = " << quote_toml(package_id) << "\n"; + out << "id = " << quote_toml(feature_id) << "\n"; + out << "enabled = " << (enabled ? "true" : "false") << "\n"; + if (!feature.values.empty()) { + out << "[feature.values]\n"; + for (const auto& [key, value] : feature.values) + out << key << " = " << quote_toml(value) << "\n"; + } + } + } + out.close(); + if (!out) { + set_error(error, "cannot finish " + temp.string()); + return false; + } + fs::rename(temp, final, ec); + if (ec) { + fs::remove(final, ec); + ec.clear(); + fs::rename(temp, final, ec); + } + if (ec) { + set_error(error, "cannot publish state: " + ec.message()); + return false; + } + return true; +} + +bool ModPackageManager::install_archive(const fs::path& archive, + std::string* installed_id, + std::string* installed_version, + std::string* error) { + std::vector<uint8_t> bytes; + std::vector<ZipEntry> entries; + if (!read_file(archive, bytes, error) || !parse_zip(bytes, entries, error)) + return false; + const auto manifest_entry = std::find_if(entries.begin(), entries.end(), + [](const ZipEntry& e) { return e.name == "manifest.toml" && !e.directory; }); + if (manifest_entry == entries.end()) { + set_error(error, "archive root does not contain manifest.toml"); + return false; + } + + std::error_code ec; + fs::create_directories(root_ / ".staging", ec); + if (ec) { + set_error(error, "cannot create install staging root: " + ec.message()); + return false; + } + const std::string token = + std::to_string((unsigned long long)crc32_compute(bytes.data(), bytes.size())); + const fs::path staging = root_ / ".staging" / ("install-" + token); + if (fs::exists(staging)) { + set_error(error, "install staging path already exists; remove " + staging.string()); + return false; + } + if (!extract_zip(bytes, entries, staging, error)) { + fs::remove_all(staging, ec); + return false; + } + ModPackage package; + if (!read_manifest(staging / "manifest.toml", package, error)) { + fs::remove_all(staging, ec); + return false; + } + const fs::path destination = root_ / "packages" / package.id / package.version; + if (fs::exists(destination)) { + fs::remove_all(staging, ec); + set_error(error, "package version is already installed"); + return false; + } + fs::create_directories(destination.parent_path(), ec); + if (ec) { + fs::remove_all(staging, ec); + set_error(error, "cannot create package directory: " + ec.message()); + return false; + } + fs::rename(staging, destination, ec); + if (ec) { + fs::remove_all(staging, ec); + set_error(error, "cannot publish installed package: " + ec.message()); + return false; + } + package.root = destination; + packages_[package.id][package.version] = package; + if (installed_id) *installed_id = package.id; + if (installed_version) *installed_version = package.version; + return true; +} + +bool ModPackageManager::remove_version(const std::string& id, const std::string& version, + std::string* error) { + const auto sit = selections_.find(id); + const ModSelection blank; + const ModSelection& current = + sit == selections_.end() ? blank : sit->second; + const ModPackage* selected = find_selected(packages_, id, current); + if (selected && selected->version == version && + has_enabled_feature(*selected, current)) { + set_error(error, "cannot remove an active package version"); + return false; + } + for (const auto& [other_id, versions] : packages_) { + (void)versions; + const auto other_selection = selections_.find(other_id); + const ModSelection& selection = + other_selection == selections_.end() ? blank : + other_selection->second; + const ModPackage* package = + find_selected(packages_, other_id, selection); + if (!package || other_id == id || + !has_enabled_feature(*package, selection)) continue; + for (const ModRequirement& dep : package->dependencies) { + if (dep.id == id && version_satisfies(version, dep.version)) { + set_error(error, "cannot remove a version required by " + other_id); + return false; + } + } + } + const auto pit = packages_.find(id); + if (pit == packages_.end() || pit->second.find(version) == pit->second.end()) { + set_error(error, "package version is not installed"); + return false; + } + const fs::path path = pit->second.at(version).root; + std::error_code ec; + fs::remove_all(path, ec); + if (ec) { + set_error(error, "cannot remove package version: " + ec.message()); + return false; + } + packages_[id].erase(version); + if (packages_[id].empty()) packages_.erase(id); + return true; +} + +bool ModPackageManager::set_enabled(const std::string& id, bool enabled, std::string* error) { + const auto pit = packages_.find(id); + if (pit == packages_.end()) { + set_error(error, "package is not installed"); + return false; + } + const ModPackage* package = selected_package(id); + if (!package) { + set_error(error, "package/version is not installed"); + return false; + } + if (package->features.size() != 1 || !package->features.front().legacy) { + set_error(error, + "feature-style packages must be enabled per feature"); + return false; + } + selections_[id].enabled = enabled; + return true; +} + +bool ModPackageManager::select_version(const std::string& id, const std::string& version, + std::string* error) { + const auto pit = packages_.find(id); + if (pit == packages_.end() || pit->second.find(version) == pit->second.end()) { + set_error(error, "package version is not installed"); + return false; + } + selections_[id].version = version; + return true; +} + +bool ModPackageManager::set_option(const std::string& id, const std::string& option_id, + const std::string& value, std::string* error) { + const auto sit = selections_.find(id); + const ModSelection empty; + const ModSelection& selection = sit == selections_.end() ? empty : sit->second; + const ModPackage* package = find_selected(packages_, id, selection); + if (!package) { + set_error(error, "package/version is not installed"); + return false; + } + if (package->features.size() != 1 || !package->features.front().legacy) { + set_error(error, + "feature-style package options must name a feature"); + return false; + } + const auto oit = std::find_if(package->options.begin(), package->options.end(), + [&](const ModOption& option) { + return option.feature_id == "legacy" && option.id == option_id; + }); + if (oit == package->options.end()) { + set_error(error, "unknown package option"); + return false; + } + if (!valid_option_value(*oit, value)) { + set_error(error, "invalid option value"); + return false; + } + selections_[id].values[option_id] = value; + return true; +} + +bool ModPackageManager::set_feature_enabled(const std::string& package_id, + const std::string& feature_id, + bool enabled, + std::string* error) { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + if (!feature || feature->legacy) { + set_error(error, "unknown package feature"); + return false; + } + ModSelection package_selection = selections_[package_id]; + if (enabled) { + std::set<std::string> visiting; + if (!apply_feature_requirements( + *package, package_selection, feature_id, visiting, error)) + return false; + } + set_feature_selected(package_selection, feature_id, enabled); + if (!enabled) + cascade_unsatisfied_feature_requirements(*package, package_selection); + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, package_selection, nullptr, nullptr, nullptr, nullptr, + nullptr, &failing_feature, &reason)) { + set_error(error, package_id + "/" + + (failing_feature.empty() ? feature_id : failing_feature) + + ": " + reason); + return false; + } + selections_[package_id] = std::move(package_selection); + return true; +} + +bool ModPackageManager::set_feature_option(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id, + const std::string& value, + std::string* error) { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + const ModOption* option = + package ? find_option(*package, feature_id, option_id) : nullptr; + if (!feature || feature->legacy || !option) { + set_error(error, "unknown feature option"); + return false; + } + if (!valid_option_value(*option, value)) { + set_error(error, "invalid feature option value"); + return false; + } + ModSelection package_selection = selections_[package_id]; + package_selection.features[feature_id].values[option_id] = value; + cascade_unsatisfied_feature_requirements(*package, package_selection); + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, package_selection, nullptr, nullptr, nullptr, nullptr, + nullptr, &failing_feature, &reason)) { + set_error(error, package_id + "/" + + (failing_feature.empty() ? feature_id : failing_feature) + + ": " + reason); + return false; + } + selections_[package_id] = std::move(package_selection); + return true; +} + +const ModPackage* ModPackageManager::selected_package(const std::string& id) const { + const auto selection = selections_.find(id); + const ModSelection blank; + return find_selected(packages_, id, + selection == selections_.end() ? blank : selection->second); +} + +const ModFeature* ModPackageManager::selected_feature( + const std::string& package_id, const std::string& feature_id) const { + const ModPackage* package = selected_package(package_id); + return package ? find_feature(*package, feature_id) : nullptr; +} + +bool ModPackageManager::feature_enabled(const std::string& package_id, + const std::string& feature_id) const { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + if (!package || !feature) return false; + const auto found = selections_.find(package_id); + const ModSelection blank; + return is_feature_enabled( + *package, found == selections_.end() ? blank : found->second, *feature); +} + +std::string ModPackageManager::feature_option_value( + const std::string& package_id, const std::string& feature_id, + const std::string& option_id) const { + const ModPackage* package = selected_package(package_id); + if (!package) return {}; + const auto found = selections_.find(package_id); + const ModSelection blank; + return effective_option_value( + *package, found == selections_.end() ? blank : found->second, + feature_id, option_id); +} + +ModResolution ModPackageManager::resolve(const std::string& game_id, + const std::string& exe_sha256, + const std::string& disc_sha256) const { + ModResolution result; + std::map<std::string, const ModPackage*> active; + for (const auto& [id, versions] : packages_) { + (void)versions; + const auto selected = selections_.find(id); + const ModSelection blank; + const ModSelection& selection = + selected == selections_.end() ? blank : selected->second; + const ModPackage* package = find_selected(packages_, id, selection); + if (!package) { + if (selected != selections_.end()) + result.errors.push_back( + "selected package/version is not installed: " + id); + continue; + } + if (!has_enabled_feature(*package, selection)) continue; + if (!target_matches(*package, game_id, exe_sha256, disc_sha256)) { + result.errors.push_back("package does not target this game/image: " + id); + continue; + } + active[id] = package; + } + + for (const auto& [id, package] : active) { + for (const ModRequirement& dep : package->dependencies) { + const auto found = active.find(dep.id); + if (found == active.end()) + result.errors.push_back(id + " requires enabled package " + dep.id); + else if (!version_satisfies(found->second->version, dep.version)) + result.errors.push_back(id + " requires " + dep.id + " " + dep.version); + } + for (const std::string& conflict : package->conflicts) + if (active.find(conflict) != active.end()) + result.errors.push_back(id + " conflicts with " + conflict); + + const auto selection = selections_.find(id); + const ModSelection blank_selection; + const ModSelection& effective_selection = + selection == selections_.end() + ? blank_selection + : selection->second; + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, effective_selection, nullptr, nullptr, + nullptr, nullptr, nullptr, &failing_feature, &reason)) { + result.errors.push_back( + id + "/" + failing_feature + ": " + reason); + } + if (selection != selections_.end()) { + for (const ModOption& option : package->options) { + const ModFeature* feature = + find_feature(*package, option.feature_id); + if (!feature || + !is_feature_enabled(*package, selection->second, *feature)) + continue; + const std::map<std::string, std::string>* values = nullptr; + if (feature->legacy) { + values = &selection->second.values; + } else { + const auto feature_selection = + selection->second.features.find(feature->id); + if (feature_selection != selection->second.features.end()) + values = &feature_selection->second.values; + } + if (!values) continue; + const auto value = values->find(option.id); + if (value != values->end() && + !valid_option_value(option, value->second)) + result.errors.push_back( + id + "/" + feature->id + + ": invalid value for " + option.id); + } + } + if (package->resolver.rfind("builtin:", 0) == 0) { + const std::string resolver_id = package->resolver.substr(8); + const auto resolver = builtin_resolvers().find(resolver_id); + if (resolver == builtin_resolvers().end()) + result.errors.push_back(id + ": built-in resolver is unavailable: " + resolver_id); + } + } + + enum class Visit { None, Active, Done }; + std::map<std::string, Visit> visits; + std::function<void(const std::string&)> visit = [&](const std::string& id) { + if (visits[id] == Visit::Done) return; + if (visits[id] == Visit::Active) { + result.errors.push_back("dependency cycle includes " + id); + return; + } + visits[id] = Visit::Active; + const ModPackage* package = active.at(id); + std::vector<std::string> deps; + for (const ModRequirement& dep : package->dependencies) + if (active.find(dep.id) != active.end()) deps.push_back(dep.id); + std::sort(deps.begin(), deps.end()); + for (const std::string& dep : deps) visit(dep); + visits[id] = Visit::Done; + result.ordered.push_back(package); + }; + for (const auto& [id, package] : active) visit(id); + + if (!result.errors.empty()) { + result.ordered.clear(); + return result; + } + + ModBuiltinResolverContext resolver_context; + resolver_context.active_packages = &active; + resolver_context.selections = &selections_; + for (const ModPackage* package : result.ordered) { + const auto selected_it = selections_.find(package->id); + const ModSelection blank; + const ModSelection& selected = + selected_it == selections_.end() ? blank : selected_it->second; + if (package->resolver == "declarative") { + for (const ModDerivedDisc& derived : package->derived_discs) { + const ModFeature& legacy = package->features.front(); + if (!legacy.legacy || + !is_feature_enabled(*package, selected, legacy) || + !conditions_match(*package, selected, "legacy", derived.when)) + continue; + ModResolution::DerivedDisc resolved; + resolved.kind = derived.kind; + resolved.patch = derived.patch; + resolved.patch_sha256 = derived.patch_sha256; + resolved.output_size = derived.output_size; + resolved.output_sha256 = derived.output_sha256; + resolved.package_id = package->id; + result.derived_discs.push_back(std::move(resolved)); + } + std::vector<const ModPatch*> patches; + patches.reserve(package->patches.size()); + for (const ModPatch& patch : package->patches) { + const ModFeature* feature = + find_feature(*package, patch.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + patch.feature_id, patch.when) || + !integer_predicate_matches( + *package, selected, patch.feature_id, + patch.when_integer)) + continue; + patches.push_back(&patch); + } + std::stable_sort(patches.begin(), patches.end(), + [](const ModPatch* a, const ModPatch* b) { return a->order < b->order; }); + for (const ModPatch* patch : patches) { + ModResolution::Write write; + write.target = patch->target; + write.location = patch->location; + write.expected = patch->expected; + if (!patch->fields.empty()) { + bool encode_failed = false; + for (const ModPatchField& field : patch->fields) { + std::vector<uint8_t> encoded = field.replacement; + if (encoded.empty()) { + const std::string selected_value = + effective_option_value( + *package, selected, patch->feature_id, + field.replace_from_option); + int64_t parsed = 0; + int64_t adjusted = 0; + if (!parse_canonical_int64( + selected_value, parsed) || + !checked_add_int64( + parsed, field.replace_addend, + adjusted)) { + result.errors.push_back( + package->id + "/" + + patch->feature_id + + ": could not encode sparse field option " + + field.replace_from_option); + encode_failed = true; + break; + } + if (field.replace_encoding == + ModValueEncoding::MipsLuiOriU32) { + if (adjusted < 0 || + static_cast<uint64_t>(adjusted) > + UINT32_MAX) { + result.errors.push_back( + package->id + "/" + + patch->feature_id + + ": could not encode sparse field " + "option " + + field.replace_from_option); + encode_failed = true; + break; + } + const uint32_t value = + static_cast<uint32_t>(adjusted); + const std::array< + std::pair<uint64_t, + std::vector<uint8_t>>, 2> + halves{{ + {field.offset, + { + static_cast<uint8_t>( + value >> 16), + static_cast<uint8_t>( + value >> 24), + }}, + {field.offset + 4, + { + static_cast<uint8_t>(value), + static_cast<uint8_t>( + value >> 8), + }}, + }}; + for (const auto& [offset, bytes] : halves) { + if (std::equal( + bytes.begin(), bytes.end(), + write.expected.begin() + + static_cast<size_t>( + offset))) + continue; + ModResolution::Write::Field resolved; + resolved.offset = offset; + resolved.replacement = bytes; + write.fields.push_back( + std::move(resolved)); + } + continue; + } + if (!encode_unsigned_value( + field.replace_encoding, adjusted, + encoded)) { + result.errors.push_back( + package->id + "/" + + patch->feature_id + + ": could not encode sparse field option " + + field.replace_from_option); + encode_failed = true; + break; + } + } + if (std::equal( + encoded.begin(), encoded.end(), + write.expected.begin() + + static_cast<size_t>(field.offset))) + continue; + ModResolution::Write::Field resolved; + resolved.offset = field.offset; + resolved.replacement = std::move(encoded); + write.fields.push_back(std::move(resolved)); + } + if (encode_failed) continue; + if (write.fields.empty()) continue; + std::sort( + write.fields.begin(), write.fields.end(), + [](const ModResolution::Write::Field& a, + const ModResolution::Write::Field& b) { + return a.offset < b.offset; + }); + } else if (patch->replace_from_option.empty()) { + write.replacement = patch->replacement; + } else { + const std::string selected_value = + effective_option_value( + *package, selected, patch->feature_id, + patch->replace_from_option); + const ModOption* source_option = find_option( + *package, patch->feature_id, + patch->replace_from_option); + if (patch->replace_omit_when_default && + source_option && + selected_value == source_option->default_value) + continue; + int64_t parsed = 0; + int64_t adjusted = 0; + std::vector<uint8_t> encoded; + if (!parse_canonical_int64(selected_value, parsed) || + !checked_add_int64( + parsed, patch->replace_addend, adjusted)) { + result.errors.push_back( + package->id + "/" + patch->feature_id + + ": could not encode replace_from option " + + patch->replace_from_option); + continue; + } + write.replacement = write.expected; + if (patch->replace_encoding == + ModValueEncoding::MipsLuiOriU32) { + if (adjusted < 0 || + static_cast<uint64_t>(adjusted) > UINT32_MAX) { + result.errors.push_back( + package->id + "/" + patch->feature_id + + ": could not encode replace_from option " + + patch->replace_from_option); + continue; + } + const uint32_t value = + static_cast<uint32_t>(adjusted); + write.replacement[0] = + static_cast<uint8_t>(value >> 16); + write.replacement[1] = + static_cast<uint8_t>(value >> 24); + write.replacement[4] = + static_cast<uint8_t>(value); + write.replacement[5] = + static_cast<uint8_t>(value >> 8); + } else { + if (!encode_unsigned_value( + patch->replace_encoding, adjusted, + encoded)) { + result.errors.push_back( + package->id + "/" + patch->feature_id + + ": could not encode replace_from option " + + patch->replace_from_option); + continue; + } + std::copy( + encoded.begin(), encoded.end(), + write.replacement.begin() + + static_cast<size_t>( + patch->replace_offset)); + } + if (write.replacement == write.expected && + !patch->replace_omit_when_default) + continue; + } + write.package_id = package->id; + write.feature_id = patch->feature_id; + result.writes.push_back(std::move(write)); + } + std::vector<const ModOverlay*> overlays; + overlays.reserve(package->overlays.size()); + for (const ModOverlay& overlay : package->overlays) { + const ModFeature* feature = + find_feature(*package, overlay.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + overlay.feature_id, overlay.when)) + continue; + overlays.push_back(&overlay); + } + std::stable_sort(overlays.begin(), overlays.end(), + [](const ModOverlay* a, const ModOverlay* b) { + return a->order < b->order; + }); + for (const ModOverlay* overlay : overlays) { + std::vector<uint8_t> payload; + std::string payload_error; + if (!read_file(overlay->file, payload, &payload_error)) { + result.errors.push_back( + package->id + "/" + overlay->feature_id + ": " + + payload_error); + continue; + } + const std::string actual = fingerprint_text(std::string( + (const char*)payload.data(), payload.size())); + if (payload.size() != overlay->size || + actual != overlay->sha256) { + result.errors.push_back( + package->id + "/" + overlay->feature_id + + ": overlay payload changed after installation"); + continue; + } + ModResolution::Overlay resolved; + resolved.target = overlay->target; + resolved.location = overlay->location; + resolved.payload = std::move(payload); + resolved.payload_sha256 = overlay->sha256; + resolved.expected_sha256 = overlay->expected_sha256; + resolved.package_id = package->id; + resolved.feature_id = overlay->feature_id; + result.overlays.push_back(std::move(resolved)); + } + } else { + const std::string resolver_id = package->resolver.substr(8); + const auto resolver = builtin_resolvers().find(resolver_id); + if (resolver != builtin_resolvers().end() && + !resolver->second( + *package, selected, resolver_context, + result.writes, result.errors) && + result.errors.empty()) + result.errors.push_back(package->id + ": built-in resolver failed"); + } + std::vector<const ModPlugin*> plugins; + plugins.reserve(package->plugins.size()); + for (const ModPlugin& plugin : package->plugins) { + const ModFeature* feature = + find_feature(*package, plugin.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + plugin.feature_id, plugin.when)) + continue; + plugins.push_back(&plugin); + } + std::stable_sort( + plugins.begin(), plugins.end(), + [](const ModPlugin* a, const ModPlugin* b) { + return a->order < b->order; + }); + for (const ModPlugin* plugin : plugins) { + if (!mod_plugin_registered(plugin->id)) { + result.errors.push_back( + package->id + "/" + plugin->feature_id + + ": trusted plugin is unavailable: " + plugin->id); + continue; + } + ModResolution::Plugin resolved; + resolved.id = plugin->id; + resolved.package_id = package->id; + resolved.feature_id = plugin->feature_id; + result.plugins.push_back(std::move(resolved)); + } + } + if (result.derived_discs.size() > 1) { + std::string providers; + for (const auto& derived : result.derived_discs) { + if (!providers.empty()) providers += ", "; + providers += derived.package_id; + } + result.errors.push_back( + "more than one derived-disc provider is active: " + providers); + } + std::vector<ModResolution::Plugin> coalesced_plugins; + coalesced_plugins.reserve(result.plugins.size()); + for (const ModResolution::Plugin& plugin : result.plugins) { + bool claimed = false; + for (const ModResolution::Plugin& previous : coalesced_plugins) { + if (plugin.id != previous.id) continue; + if (plugin.package_id == previous.package_id && + plugin.feature_id == previous.feature_id) { + claimed = true; + break; + } + ModResolution::Diagnostic diagnostic; + diagnostic.resource = "plugin:" + plugin.id; + diagnostic.package_id = plugin.package_id; + diagnostic.feature_id = plugin.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + plugin.package_id + "/" + plugin.feature_id + + " collides at " + diagnostic.resource + " with " + + previous.package_id + "/" + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + claimed = true; + break; + } + if (!claimed) coalesced_plugins.push_back(plugin); + } + result.plugins = std::move(coalesced_plugins); + std::vector<ModResolution::Write> coalesced; + coalesced.reserve(result.writes.size()); + for (const ModResolution::Write& write : result.writes) { + bool valid_write = !write.expected.empty(); + if (write.fields.empty()) { + valid_write = + valid_write && + write.expected.size() == write.replacement.size(); + } else { + valid_write = valid_write && write.replacement.empty(); + uint64_t previous_end = 0; + for (const ModResolution::Write::Field& field : write.fields) { + if (field.replacement.empty() || + field.offset > write.expected.size() || + field.replacement.size() > + write.expected.size() - field.offset || + field.offset < previous_end) { + valid_write = false; + break; + } + previous_end = + field.offset + field.replacement.size(); + } + } + if (!valid_write) { + result.errors.push_back( + write.package_id + "/" + write.feature_id + + ": resolver emitted invalid write"); + continue; + } + bool duplicate = false; + for (const ModResolution::Write& previous : coalesced) { + if (identical_write(previous, write)) { + duplicate = true; + break; + } + uint64_t guard_mismatch = 0; + if (write_guard_overlap_mismatch( + previous, write, guard_mismatch)) { + ModResolution::Diagnostic diagnostic; + diagnostic.resource = + byte_resource(write.target, guard_mismatch); + diagnostic.package_id = write.package_id; + diagnostic.feature_id = write.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + write.package_id + "/" + write.feature_id + + " has an incompatible guard at " + + diagnostic.resource + " with " + + previous.package_id + "/" + + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + duplicate = true; + break; + } + if (!writes_overlap(previous, write)) continue; + uint64_t mismatch = 0; + if (!write_overlap_mismatch(previous, write, mismatch)) + continue; + ModResolution::Diagnostic diagnostic; + diagnostic.resource = byte_resource(write.target, mismatch); + diagnostic.package_id = write.package_id; + diagnostic.feature_id = write.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + write.package_id + "/" + write.feature_id + + " collides at " + diagnostic.resource + " with " + + previous.package_id + "/" + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + duplicate = true; + break; + } + if (!duplicate) coalesced.push_back(write); + } + result.writes = std::move(coalesced); + std::vector<ModResolution::Overlay> coalesced_overlays; + coalesced_overlays.reserve(result.overlays.size()); + for (const ModResolution::Overlay& overlay : result.overlays) { + bool claimed = false; + for (const ModResolution::Write& write : result.writes) { + if (!write_overlaps_range( + write, overlay.target, overlay.location, + overlay.payload.size())) + continue; + uint64_t mismatch = 0; + if (!write_overlay_mismatch(write, overlay, mismatch)) + continue; + ModResolution::Diagnostic diagnostic; + diagnostic.resource = byte_resource(overlay.target, mismatch); + diagnostic.package_id = overlay.package_id; + diagnostic.feature_id = overlay.feature_id; + diagnostic.other_package_id = write.package_id; + diagnostic.other_feature_id = write.feature_id; + diagnostic.message = + overlay.package_id + "/" + overlay.feature_id + + " collides at " + diagnostic.resource + " with " + + write.package_id + "/" + write.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + claimed = true; + break; + } + if (claimed) continue; + for (const ModResolution::Overlay& previous : coalesced_overlays) { + if (!ranges_overlap( + overlay.target, overlay.location, overlay.payload.size(), + previous.target, previous.location, previous.payload.size())) + continue; + if (overlay.target == previous.target && + overlay.location == previous.location && + overlay.payload == previous.payload && + overlay.expected_sha256 == previous.expected_sha256) { + claimed = true; + break; + } + uint64_t mismatch = 0; + if (!overlay_overlap_mismatch(previous, overlay, mismatch)) + continue; + ModResolution::Diagnostic diagnostic; + diagnostic.resource = byte_resource(overlay.target, mismatch); + diagnostic.package_id = overlay.package_id; + diagnostic.feature_id = overlay.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + overlay.package_id + "/" + overlay.feature_id + + " collides at " + diagnostic.resource + " with " + + previous.package_id + "/" + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + claimed = true; + break; + } + if (!claimed) coalesced_overlays.push_back(overlay); + } + result.overlays = std::move(coalesced_overlays); + if (!result.errors.empty()) { + result.ordered.clear(); + result.writes.clear(); + result.overlays.clear(); + result.derived_discs.clear(); + result.plugins.clear(); + return result; + } + result.fingerprint = fingerprint_text( + canonical_resolution( + result.ordered, selections_, result.writes, result.overlays, + result.derived_discs, result.plugins, + disc_sha256)); + result.ok = true; + return result; +} + +} // namespace PSXRecompV4 + +extern "C" int psx_mod_register_vblank_plugin( + const char* id, PSXModVBlankCallback callback) { + return id && + PSXRecompV4::mod_register_vblank_plugin(id, callback) ? 1 : 0; +} + +extern "C" int psx_mod_register_activation_plugin( + const char* id, PSXModActivationCallback callback) { + return id && + PSXRecompV4::mod_register_activation_plugin(id, callback) ? 1 : 0; +} diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp new file mode 100644 index 00000000..5df9a0ec --- /dev/null +++ b/runtime/src/mod_runtime.cpp @@ -0,0 +1,1128 @@ +#include "mod_runtime.h" + +#include "disc_path.h" +#include "mod_packages.h" +#include "psx_sha256.h" + +#if defined(RECOMP_LAUNCHER) +#include "recomp_launcher.h" +#endif + +#include <algorithm> +#include <array> +#include <cctype> +#include <cstdlib> +#include <cstdio> +#include <cstring> +#include <fstream> +#include <iomanip> +#include <map> +#include <sstream> +#include <string> +#include <system_error> +#include <vector> + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include <windows.h> +#else +#include <sys/types.h> +#include <sys/wait.h> +#include <unistd.h> +#endif + +extern "C" uint8_t psx_read_byte(uint32_t addr); +extern "C" void psx_write_byte(uint32_t addr, uint8_t value); +extern "C" void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); +extern "C" int fntrace_is_game_started(void); + +namespace PSXRecompV4 { +namespace { + +struct RuntimeMods { + ModPackageManager manager; + ModResolution plan; + ModResolution validation; + std::map<uint32_t, std::vector<size_t>> raw_disc_index; + std::map<uint32_t, std::vector<size_t>> user_disc_index; + std::map<uint32_t, std::vector<size_t>> raw_overlay_index; + std::map<uint32_t, std::vector<size_t>> user_overlay_index; + std::string game_id; + std::string error; + std::string exe_sha256; + std::string disc_sha256; + std::filesystem::path disc_path; + std::filesystem::path effective_disc_path; + uint32_t entry_phys = 0; + bool initialized = false; + bool main_applied = false; + bool disc_enabled = false; + bool disc_guard_failed = false; +}; + +RuntimeMods& state() { + static RuntimeMods value; + return value; +} + +const ModPackage* selected_package(const std::string& id) { + return state().manager.selected_package(id); +} + +bool package_has_enabled_feature(const ModPackage& package) { + return std::any_of( + package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { + return state().manager.feature_enabled(package.id, feature.id); + }); +} + +std::string selected_value(const ModPackage& package, const ModOption& option) { + const auto selection = state().manager.selections().find(package.id); + if (selection != state().manager.selections().end()) { + const auto value = selection->second.values.find(option.id); + if (value != selection->second.values.end()) return value->second; + } + return option.default_value; +} + +void build_disc_index(RuntimeMods& s) { + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); + for (size_t i = 0; i < s.plan.writes.size(); ++i) { + const ModResolution::Write& write = s.plan.writes[i]; + if (write.target == ModPatchTarget::DiscRaw) + s.raw_disc_index[(uint32_t)(write.location / 2352)].push_back(i); + else if (write.target == ModPatchTarget::DiscUser) + s.user_disc_index[(uint32_t)(write.location / 2048)].push_back(i); + } + for (size_t i = 0; i < s.plan.overlays.size(); ++i) { + const ModResolution::Overlay& overlay = s.plan.overlays[i]; + const uint64_t sector_size = + overlay.target == ModPatchTarget::DiscRaw ? 2352 : 2048; + auto& index = overlay.target == ModPatchTarget::DiscRaw + ? s.raw_overlay_index : s.user_overlay_index; + const uint64_t first = overlay.location / sector_size; + const uint64_t last = + (overlay.location + overlay.payload.size() - 1) / sector_size; + for (uint64_t lba = first; lba <= last; ++lba) + index[(uint32_t)lba].push_back(i); + } +} + +void set_error(const std::string& error) { + state().error = error; +} + +bool sha256_file(const std::filesystem::path& path, std::string& out, + std::string* error) { + out.clear(); + if (path.empty()) return true; + const std::filesystem::path input = resolve_disc_path(path).data; + psx_sha256_ctx hash; + psx_sha256_init(&hash); + std::array<uint8_t, 1024 * 1024> buffer{}; + std::ifstream file(input, std::ios::binary); + if (!file) { + if (error) *error = "cannot fingerprint image: " + input.string(); + return false; + } + while (file) { + file.read((char*)buffer.data(), (std::streamsize)buffer.size()); + const std::streamsize got = file.gcount(); + if (got > 0) psx_sha256_update(&hash, buffer.data(), (size_t)got); + } + if (!file.eof()) { + if (error) *error = + "cannot finish fingerprinting image: " + input.string(); + return false; + } + uint8_t digest[32]; + psx_sha256_final(&hash, digest); + std::ostringstream text; + for (uint8_t byte : digest) + text << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + out = text.str(); + return true; +} + +std::filesystem::path raw_image_path(const std::filesystem::path& path, + std::string* error) { + std::string extension = path.extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + if (extension != ".cue") return path; + std::ifstream cue(path); + if (!cue) { + if (error) *error = "cannot open disc CUE: " + path.string(); + return {}; + } + std::string line; + while (std::getline(cue, line)) { + size_t at = line.find_first_not_of(" \t"); + if (at == std::string::npos || line.size() - at < 4) continue; + std::string keyword = line.substr(at, 4); + std::transform(keyword.begin(), keyword.end(), keyword.begin(), + [](unsigned char c) { return (char)std::toupper(c); }); + if (keyword != "FILE") continue; + at = line.find_first_not_of(" \t", at + 4); + if (at == std::string::npos) continue; + std::string name; + if (line[at] == '"') { + const size_t end = line.find('"', at + 1); + if (end == std::string::npos) continue; + name = line.substr(at + 1, end - at - 1); + } else { + const size_t end = line.find_first_of(" \t", at); + name = line.substr(at, end - at); + } + return (path.parent_path() / name).lexically_normal(); + } + if (error) *error = "disc CUE has no source file: " + path.string(); + return {}; +} + +bool sha256_disc_range(const std::filesystem::path& image, + ModPatchTarget target, uint64_t location, size_t size, + std::string& out, std::string* error) { + const std::filesystem::path source = raw_image_path(image, error); + if (source.empty()) return false; + std::ifstream file(source, std::ios::binary); + if (!file) { + if (error) *error = "cannot open stock image range: " + source.string(); + return false; + } + file.seekg(0, std::ios::end); + const std::streamoff file_size = file.tellg(); + if (file_size < 0) { + if (error) *error = "cannot size stock image: " + source.string(); + return false; + } + psx_sha256_ctx hash; + psx_sha256_init(&hash); + std::array<uint8_t, 2048> bytes{}; + size_t remaining = size; + uint64_t at = location; + const bool raw_source = file_size > 0 && + ((uint64_t)file_size % 2352u) == 0; + while (remaining != 0) { + uint64_t physical = at; + size_t chunk = remaining; + if (target == ModPatchTarget::DiscUser && raw_source) { + const uint64_t lba = at / 2048u; + const size_t within = (size_t)(at % 2048u); + physical = lba * 2352u + 24u + within; + chunk = std::min(chunk, 2048u - within); + } + chunk = std::min(chunk, bytes.size()); + if (physical > (uint64_t)file_size || + chunk > (uint64_t)file_size - physical) { + if (error) *error = "overlay expected range exceeds stock image"; + return false; + } + file.clear(); + file.seekg((std::streamoff)physical); + if (!file.read((char*)bytes.data(), (std::streamsize)chunk)) { + if (error) *error = "cannot read stock image overlay range"; + return false; + } + psx_sha256_update(&hash, bytes.data(), chunk); + at += chunk; + remaining -= chunk; + } + uint8_t digest[32]; + psx_sha256_final(&hash, digest); + std::ostringstream text; + for (uint8_t byte : digest) + text << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + out = text.str(); + return true; +} + +#if defined(_WIN32) +std::wstring quote_windows_argument(const std::wstring& value) { + if (value.find_first_of(L" \t\n\v\"") == std::wstring::npos) return value; + std::wstring out = L"\""; + size_t slashes = 0; + for (wchar_t c : value) { + if (c == L'\\') { + ++slashes; + } else if (c == L'"') { + out.append(slashes * 2 + 1, L'\\'); + out.push_back(L'"'); + slashes = 0; + } else { + out.append(slashes, L'\\'); + slashes = 0; + out.push_back(c); + } + } + out.append(slashes * 2, L'\\'); + out.push_back(L'"'); + return out; +} +#endif + +bool run_xdelta_decode(const std::filesystem::path& executable, + const std::filesystem::path& source, + const std::filesystem::path& patch, + const std::filesystem::path& output, + std::string* error) { +#if defined(_WIN32) + std::wstring command = + quote_windows_argument(executable.wstring()) + L" -f -n -d -s " + + quote_windows_argument(source.wstring()) + L" " + + quote_windows_argument(patch.wstring()) + L" " + + quote_windows_argument(output.wstring()); + std::vector<wchar_t> mutable_command(command.begin(), command.end()); + mutable_command.push_back(L'\0'); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (!CreateProcessW(executable.wstring().c_str(), mutable_command.data(), + nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, + &startup, &process)) { + if (error) *error = "cannot start trusted xdelta3 decoder (Windows error " + + std::to_string((unsigned long)GetLastError()) + ")"; + return false; + } + WaitForSingleObject(process.hProcess, INFINITE); + DWORD exit_code = 1; + GetExitCodeProcess(process.hProcess, &exit_code); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + if (exit_code != 0) { + if (error) *error = + "trusted xdelta3 decoder failed with exit code " + std::to_string(exit_code); + return false; + } + return true; +#else + const pid_t child = fork(); + if (child == 0) { + execl(executable.c_str(), executable.c_str(), "-f", "-n", "-d", "-s", + source.c_str(), patch.c_str(), output.c_str(), (char*)nullptr); + _exit(127); + } + if (child < 0) { + if (error) *error = "cannot start trusted xdelta3 decoder"; + return false; + } + int status = 0; + if (waitpid(child, &status, 0) < 0 || !WIFEXITED(status) || + WEXITSTATUS(status) != 0) { + if (error) *error = "trusted xdelta3 decoder failed"; + return false; + } + return true; +#endif +} + +bool valid_cached_disc(const std::filesystem::path& path, + const ModResolution::DerivedDisc& derived) { + std::error_code ec; + return std::filesystem::is_regular_file(path, ec) && + std::filesystem::file_size(path, ec) == derived.output_size && !ec; +} + +bool materialize_derived_disc(RuntimeMods& s, const ModResolution& plan, + std::filesystem::path& out, std::string* error) { + out.clear(); + if (plan.derived_discs.empty()) return true; + const ModResolution::DerivedDisc& derived = plan.derived_discs.front(); + std::string digest; + if (!sha256_file(derived.patch, digest, error) || + digest != derived.patch_sha256) { + if (error && error->empty()) + *error = derived.package_id + ": derived-disc patch checksum failed"; + else if (error && digest != derived.patch_sha256) + *error = derived.package_id + ": derived-disc patch checksum failed"; + return false; + } + const std::filesystem::path cache_root = s.manager.root() / "cache"; + const std::filesystem::path cached = cache_root / (plan.fingerprint + ".bin"); + if (valid_cached_disc(cached, derived)) { + out = cached; + return true; + } + std::error_code ec; + std::filesystem::create_directories(cache_root, ec); + if (ec) { + if (error) *error = "cannot create derived-disc cache: " + ec.message(); + return false; + } + const char* override_tool = std::getenv("PSXRECOMP_XDELTA3"); + const std::filesystem::path decoder = + override_tool && override_tool[0] + ? std::filesystem::path(override_tool) +#if defined(_WIN32) + : s.manager.root().parent_path() / "xdelta3.exe"; +#else + : s.manager.root().parent_path() / "xdelta3"; +#endif + if (!std::filesystem::is_regular_file(decoder, ec)) { + if (error) *error = + "this mod needs the trusted xdelta3 decoder, but it is missing: " + + decoder.string(); + return false; + } + const std::filesystem::path source = raw_image_path(s.disc_path, error); + if (source.empty()) return false; +#if defined(_WIN32) + const unsigned long process_id = GetCurrentProcessId(); +#else + const unsigned long process_id = (unsigned long)getpid(); +#endif + const std::filesystem::path temporary = + cache_root / (plan.fingerprint + ".tmp." + std::to_string(process_id)); + std::filesystem::remove(temporary, ec); + std::fprintf(stdout, "psxrecomp: building derived disc for %s...\n", + derived.package_id.c_str()); + if (!run_xdelta_decode(decoder, source, derived.patch, temporary, error)) { + std::filesystem::remove(temporary, ec); + return false; + } + if (!valid_cached_disc(temporary, derived)) { + std::filesystem::remove(temporary, ec); + if (error) *error = derived.package_id + + ": derived disc has the wrong output size"; + return false; + } + if (!sha256_file(temporary, digest, error) || digest != derived.output_sha256) { + std::filesystem::remove(temporary, ec); + if (error && digest != derived.output_sha256) + *error = derived.package_id + ": derived disc checksum failed"; + return false; + } + std::filesystem::rename(temporary, cached, ec); + if (ec) { + std::filesystem::remove(cached, ec); + ec.clear(); + std::filesystem::rename(temporary, cached, ec); + } + if (ec) { + std::filesystem::remove(temporary, ec); + if (error) *error = "cannot publish derived-disc cache: " + ec.message(); + return false; + } + std::fprintf(stdout, "psxrecomp: cached derived disc %s\n", cached.string().c_str()); + out = cached; + return true; +} + +#if defined(RECOMP_LAUNCHER) +void copy_text(char* out, size_t capacity, const std::string& value) { + if (!out || capacity == 0) return; + std::snprintf(out, capacity, "%s", value.c_str()); +} + +int provider_package_count(void*) { + return (int)state().manager.packages().size(); +} + +int provider_package_get(void*, int index, RecompLauncherCModPackage* out) { + if (!out || index < 0) return 0; + const auto& packages = state().manager.packages(); + if ((size_t)index >= packages.size()) return 0; + auto item = packages.begin(); + std::advance(item, index); + const ModPackage* package = selected_package(item->first); + if (!package) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), package->id); + copy_text(out->version, sizeof(out->version), package->version); + copy_text(out->name, sizeof(out->name), package->name); + copy_text(out->author, sizeof(out->author), package->author); + copy_text(out->description, sizeof(out->description), package->description); + copy_text(out->license, sizeof(out->license), package->license); + out->enabled = package_has_enabled_feature(*package); + out->option_count = (int)package->options.size(); + out->removable = !out->enabled; + return 1; +} + +int provider_option_get(void*, const char* package_id, int index, + RecompLauncherCModOption* out) { + if (!package_id || !out || index < 0) return 0; + const ModPackage* package = selected_package(package_id); + if (!package || (size_t)index >= package->options.size()) return 0; + const ModOption& option = package->options[(size_t)index]; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), option.id); + copy_text(out->label, sizeof(out->label), option.label); + copy_text(out->description, sizeof(out->description), option.description); + copy_text(out->group, sizeof(out->group), option.group); + copy_text(out->value, sizeof(out->value), selected_value(*package, option)); + copy_text(out->default_value, sizeof(out->default_value), option.default_value); + out->type = option.type == ModOptionType::Boolean ? RECOMP_MOD_OPTION_BOOLEAN : + option.type == ModOptionType::Choice ? RECOMP_MOD_OPTION_CHOICE : + RECOMP_MOD_OPTION_INTEGER; + out->min_value = option.min_value; + out->max_value = option.max_value; + out->step = option.step; + out->choice_count = (int)option.choices.size(); + return 1; +} + +int provider_choice_get(void*, const char* package_id, const char* option_id, + int index, RecompLauncherCModChoice* out) { + if (!package_id || !option_id || !out || index < 0) return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + const auto option = std::find_if(package->options.begin(), package->options.end(), + [&](const ModOption& value) { return value.id == option_id; }); + if (option == package->options.end() || (size_t)index >= option->choices.size()) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->value, sizeof(out->value), option->choices[(size_t)index].value); + copy_text(out->label, sizeof(out->label), option->choices[(size_t)index].label); + return 1; +} + +template <typename Callback> +int mutate(Callback callback); + +bool provider_feature_at(int index, const ModPackage*& package, + const ModFeature*& feature) { + if (index < 0) return false; + for (const auto& [package_id, versions] : state().manager.packages()) { + (void)versions; + const ModPackage* selected = selected_package(package_id); + if (!selected) continue; + for (const ModFeature& candidate : selected->features) { + if (index-- == 0) { + package = selected; + feature = &candidate; + return true; + } + } + } + return false; +} + +std::vector<const ModOption*> provider_feature_options( + const ModPackage& package, const std::string& feature_id) { + std::vector<const ModOption*> out; + for (const ModOption& option : package.options) + if (option.feature_id == feature_id) out.push_back(&option); + return out; +} + +bool diagnostic_matches(const ModResolution::Diagnostic& diagnostic, + const std::string& package_id, + const std::string& feature_id) { + return (diagnostic.package_id == package_id && + diagnostic.feature_id == feature_id) || + (diagnostic.other_package_id == package_id && + diagnostic.other_feature_id == feature_id); +} + +int provider_feature_count(void*) { + int count = 0; + for (const auto& [package_id, versions] : state().manager.packages()) { + (void)versions; + const ModPackage* package = selected_package(package_id); + if (package) count += (int)package->features.size(); + } + return count; +} + +int provider_feature_get(void*, int index, RecompLauncherCModFeature* out) { + if (!out) return 0; + const ModPackage* package = nullptr; + const ModFeature* feature = nullptr; + if (!provider_feature_at(index, package, feature)) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), feature->id); + copy_text(out->package_id, sizeof(out->package_id), package->id); + copy_text(out->package_version, sizeof(out->package_version), package->version); + copy_text(out->package_name, sizeof(out->package_name), package->name); + copy_text(out->name, sizeof(out->name), feature->name); + copy_text(out->author, sizeof(out->author), package->author); + copy_text(out->description, sizeof(out->description), feature->description); + copy_text(out->group, sizeof(out->group), feature->group); + out->enabled = + state().manager.feature_enabled(package->id, feature->id) ? 1 : 0; + out->option_count = + (int)provider_feature_options(*package, feature->id).size(); + for (const ModResolution::Diagnostic& diagnostic : + state().validation.diagnostics) { + if (!diagnostic_matches(diagnostic, package->id, feature->id)) continue; + out->has_error = 1; + copy_text(out->status, sizeof(out->status), diagnostic.message); + break; + } + return 1; +} + +int provider_feature_option_get(void*, const char* package_id, + const char* feature_id, int index, + RecompLauncherCModOption* out) { + if (!package_id || !feature_id || !out || index < 0) return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + const auto options = provider_feature_options(*package, feature_id); + if ((size_t)index >= options.size()) return 0; + const ModOption& option = *options[(size_t)index]; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), option.id); + copy_text(out->label, sizeof(out->label), option.label); + copy_text(out->description, sizeof(out->description), option.description); + copy_text(out->group, sizeof(out->group), option.group); + copy_text(out->value, sizeof(out->value), + state().manager.feature_option_value( + package_id, feature_id, option.id)); + copy_text(out->default_value, sizeof(out->default_value), + option.default_value); + out->type = option.type == ModOptionType::Boolean + ? RECOMP_MOD_OPTION_BOOLEAN + : option.type == ModOptionType::Choice + ? RECOMP_MOD_OPTION_CHOICE : RECOMP_MOD_OPTION_INTEGER; + out->min_value = option.min_value; + out->max_value = option.max_value; + out->step = option.step; + out->choice_count = (int)option.choices.size(); + return 1; +} + +int provider_feature_choice_get(void*, const char* package_id, + const char* feature_id, + const char* option_id, int index, + RecompLauncherCModChoice* out) { + if (!package_id || !feature_id || !option_id || !out || index < 0) + return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + const auto option = std::find_if( + package->options.begin(), package->options.end(), + [&](const ModOption& value) { + return value.feature_id == feature_id && value.id == option_id; + }); + if (option == package->options.end() || + (size_t)index >= option->choices.size()) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->value, sizeof(out->value), + option->choices[(size_t)index].value); + copy_text(out->label, sizeof(out->label), + option->choices[(size_t)index].label); + return 1; +} + +int provider_feature_enable(void*, const char* package_id, + const char* feature_id, int enabled) { + if (!package_id || !feature_id) return 0; + return mutate([&](std::string& error) { + const ModFeature* feature = + state().manager.selected_feature(package_id, feature_id); + if (feature && feature->legacy) + return state().manager.set_enabled( + package_id, enabled != 0, &error); + return state().manager.set_feature_enabled( + package_id, feature_id, enabled != 0, &error); + }); +} + +int provider_feature_set_option(void*, const char* package_id, + const char* feature_id, + const char* option_id, + const char* value) { + if (!package_id || !feature_id || !option_id || !value) return 0; + return mutate([&](std::string& error) { + const ModFeature* feature = + state().manager.selected_feature(package_id, feature_id); + if (feature && feature->legacy) + return state().manager.set_option( + package_id, option_id, value, &error); + return state().manager.set_feature_option( + package_id, feature_id, option_id, value, &error); + }); +} + +int provider_diagnostic_count(void*, const char* package_id, + const char* feature_id) { + if (!package_id || !feature_id) return 0; + return (int)std::count_if( + state().validation.diagnostics.begin(), + state().validation.diagnostics.end(), + [&](const ModResolution::Diagnostic& diagnostic) { + return diagnostic_matches(diagnostic, package_id, feature_id); + }); +} + +int provider_diagnostic_get(void*, const char* package_id, + const char* feature_id, int index, + RecompLauncherCModDiagnostic* out) { + if (!package_id || !feature_id || !out || index < 0) return 0; + for (const ModResolution::Diagnostic& diagnostic : + state().validation.diagnostics) { + if (!diagnostic_matches(diagnostic, package_id, feature_id)) continue; + if (index-- != 0) continue; + std::memset(out, 0, sizeof(*out)); + out->severity = 2; + copy_text(out->resource, sizeof(out->resource), diagnostic.resource); + copy_text(out->message, sizeof(out->message), diagnostic.message); + const bool primary = diagnostic.package_id == package_id && + diagnostic.feature_id == feature_id; + copy_text(out->related_package_id, sizeof(out->related_package_id), + primary ? diagnostic.other_package_id : + diagnostic.package_id); + copy_text(out->related_feature_id, sizeof(out->related_feature_id), + primary ? diagnostic.other_feature_id : + diagnostic.feature_id); + return 1; + } + return 0; +} + +int provider_version_count(void*, const char* package_id) { + if (!package_id) return 0; + const auto package = state().manager.packages().find(package_id); + return package == state().manager.packages().end() ? 0 : (int)package->second.size(); +} + +int provider_version_get(void*, const char* package_id, int index, + RecompLauncherCModVersion* out) { + if (!package_id || !out || index < 0) return 0; + const auto package = state().manager.packages().find(package_id); + if (package == state().manager.packages().end() || + (size_t)index >= package->second.size()) return 0; + auto version = package->second.begin(); + std::advance(version, index); + std::memset(out, 0, sizeof(*out)); + copy_text(out->version, sizeof(out->version), version->first); + const ModPackage* selected = selected_package(package_id); + out->selected = selected && selected->version == version->first; + out->removable = !out->selected || + !selected || !package_has_enabled_feature(*selected); + return 1; +} + +template <typename Callback> +int mutate(Callback callback) { + std::string error; + if (!callback(error)) { + set_error(error); + return 0; + } + if (!state().disc_path.empty()) + state().validation = state().manager.resolve( + state().game_id, state().exe_sha256, state().disc_sha256); + else + state().validation = {}; + state().error.clear(); + return 1; +} + +int provider_install(void*, const char* path) { + if (!path) return 0; + return mutate([&](std::string& error) { + std::string id, version; + if (!state().manager.install_archive(path, &id, &version, &error)) return false; + if (!state().manager.scan(&error)) return false; + return state().manager.select_version(id, version, &error); + }); +} + +int provider_remove(void*, const char* id, const char* version) { + if (!id || !version) return 0; + return mutate([&](std::string& error) { + return state().manager.remove_version(id, version, &error); + }); +} + +int provider_enable(void*, const char* id, int enabled) { + if (!id) return 0; + return mutate([&](std::string& error) { + return state().manager.set_enabled(id, enabled != 0, &error); + }); +} + +int provider_select(void*, const char* id, const char* version) { + if (!id || !version) return 0; + return mutate([&](std::string& error) { + return state().manager.select_version(id, version, &error); + }); +} + +int provider_set_option(void*, const char* id, const char* option, const char* value) { + if (!id || !option || !value) return 0; + return mutate([&](std::string& error) { + return state().manager.set_option(id, option, value, &error); + }); +} + +int provider_commit(void*, const char* image_path) { + std::string error; + if (!mod_runtime_commit(image_path ? std::filesystem::path(image_path) : + std::filesystem::path(), &error)) { + set_error(error); + return 0; + } + state().error.clear(); + return 1; +} + +int provider_commit_netplay(void*, const char* image_path) { + (void)image_path; + std::string error; + if (!mod_runtime_clear_for_netplay(&error)) { + set_error(error); + return 0; + } + state().error.clear(); + return 1; +} + +const char* provider_error(void*) { + return state().error.c_str(); +} + +RecompLauncherCModProvider provider = { + nullptr, + provider_package_count, + provider_package_get, + provider_option_get, + provider_choice_get, + provider_version_count, + provider_version_get, + provider_install, + provider_remove, + provider_enable, + provider_select, + provider_set_option, + provider_commit, + provider_error, + provider_feature_count, + provider_feature_get, + provider_feature_option_get, + provider_feature_choice_get, + provider_feature_enable, + provider_feature_set_option, + provider_diagnostic_count, + provider_diagnostic_get, + nullptr, /* archive_extension — PSX defaults */ + nullptr, /* archive_description */ + provider_commit_netplay, +}; +#endif + +} // namespace + +bool mod_runtime_initialize(const std::filesystem::path& root, + const std::string& game_id, + uint32_t game_entry_pc, + const std::filesystem::path& exe_path, + std::string* error) { + RuntimeMods& s = state(); + s.manager.set_root({}); + s.plan = {}; + s.validation = {}; + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); + s.game_id.clear(); + s.error.clear(); + s.exe_sha256.clear(); + s.disc_sha256.clear(); + s.disc_path.clear(); + s.effective_disc_path.clear(); + s.entry_phys = 0; + s.initialized = false; + s.main_applied = false; + s.disc_enabled = false; + s.disc_guard_failed = false; + s.manager.set_root(root); + s.game_id = game_id; + s.entry_phys = game_entry_pc & 0x1FFFFFFFu; + if (!s.manager.scan(&s.error) || !s.manager.load_state(&s.error)) { + if (error) *error = s.error; + return false; + } + if (!sha256_file(exe_path, s.exe_sha256, &s.error)) { + /* Release installs commonly do not carry a loose PS-X EXE; game-id and + * expected-byte guards remain available in that case. */ + s.exe_sha256.clear(); + s.error.clear(); + } + s.initialized = true; + return true; +} + +bool mod_runtime_clear_for_netplay(std::string* error) { + RuntimeMods& s = state(); + if (!s.initialized) { + if (error) error->clear(); + return true; + } + s.plan = {}; + s.validation = {}; + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); + s.effective_disc_path.clear(); + s.main_applied = false; + s.disc_enabled = false; + s.disc_guard_failed = false; + s.error.clear(); + if (error) error->clear(); + std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n"); + return true; +} + +bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error) { + RuntimeMods& s = state(); + if (!s.initialized) return true; + if (disc_path != s.disc_path) { + std::string hash_error; + std::string digest; + if (!sha256_file(disc_path, digest, &hash_error)) digest.clear(); + s.disc_path = disc_path; + s.disc_sha256 = std::move(digest); + } + ModResolution plan = + s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); + s.validation = plan; + if (!plan.ok) { + s.error.clear(); + for (const std::string& item : plan.errors) { + if (!s.error.empty()) s.error += "\n"; + s.error += item; + } + if (error) *error = s.error; + return false; + } + for (const ModResolution::Overlay& overlay : plan.overlays) { + if (overlay.expected_sha256.empty()) continue; + std::string actual; + if (!sha256_disc_range( + s.disc_path, overlay.target, overlay.location, + overlay.payload.size(), actual, &s.error) || + actual != overlay.expected_sha256) { + if (s.error.empty()) + s.error = overlay.package_id + "/" + overlay.feature_id + + ": stock overlay range checksum failed"; + if (error) *error = s.error; + return false; + } + } + std::filesystem::path effective_disc; + if (!materialize_derived_disc(s, plan, effective_disc, &s.error)) { + if (error) *error = s.error; + return false; + } + if (!s.manager.save_state(&s.error)) { + if (error) *error = s.error; + return false; + } + s.plan = std::move(plan); + build_disc_index(s); + s.effective_disc_path = std::move(effective_disc); + s.main_applied = false; + s.error.clear(); + return true; +} + +const std::string& mod_runtime_fingerprint() { + return state().plan.fingerprint; +} + +const std::filesystem::path& mod_runtime_effective_disc_path() { + return state().effective_disc_path; +} + +#if defined(RECOMP_LAUNCHER) +const RecompLauncherCModProvider* mod_runtime_launcher_provider() { + return &provider; +} +#endif + +} // namespace PSXRecompV4 + +extern "C" void mod_runtime_on_dispatch(uint32_t target) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || s.main_applied || + (target & 0x1FFFFFFFu) != s.entry_phys) return; + + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + for (size_t i = 0; i < write.expected.size(); ++i) { + if (psx_read_byte((uint32_t)write.location + (uint32_t)i) != + write.expected[i]) { + std::fprintf(stderr, + "psxrecomp: mod plan %s rejected at 0x%08X " + "(expected-byte guard failed; booting unmodified)\n", + s.plan.fingerprint.c_str(), + (unsigned)((uint32_t)write.location + (uint32_t)i)); + s.main_applied = true; + return; + } + } + } + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + if (write.fields.empty()) { + for (size_t i = 0; i < write.replacement.size(); ++i) + psx_write_byte((uint32_t)write.location + (uint32_t)i, + write.replacement[i]); + dirty_ram_mark_executable_range( + (uint32_t)write.location & 0x1FFFFFFFu, + (uint32_t)write.replacement.size()); + } else { + for (const ModResolution::Write::Field& field : write.fields) { + for (size_t i = 0; i < field.replacement.size(); ++i) + psx_write_byte( + (uint32_t)write.location + + (uint32_t)field.offset + (uint32_t)i, + field.replacement[i]); + dirty_ram_mark_executable_range( + ((uint32_t)write.location + + (uint32_t)field.offset) & 0x1FFFFFFFu, + (uint32_t)field.replacement.size()); + } + } + } + s.main_applied = true; + if (!s.plan.writes.empty()) + std::fprintf(stdout, "psxrecomp: applied mod plan %s\n", + s.plan.fingerprint.c_str()); +} + +extern "C" void mod_runtime_enable_disc_patches(void) { + PSXRecompV4::state().disc_enabled = true; +} + +extern "C" void mod_runtime_activate_plugins(void) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return; + for (const ModResolution::Plugin& plugin : s.plan.plugins) + mod_invoke_activation_plugin(plugin.id); +} + +extern "C" void mod_runtime_on_vblank(void) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return; + for (const ModResolution::Plugin& plugin : s.plan.plugins) + mod_invoke_vblank_plugin(plugin.id); +} + +extern "C" int psx_mod_game_started(void) { + return fntrace_is_game_started(); +} + +extern "C" int psx_mod_option_value(const char* package_id, + const char* feature_id, + const char* option_id, + char* out, uint32_t out_size) { + using namespace PSXRecompV4; + if (out && out_size) out[0] = '\0'; + if (!package_id || !feature_id || !option_id || !out || out_size == 0) + return 0; + RuntimeMods& s = state(); + /* Activation runs after the final plan commit, so a committed plan is the + * precondition for a meaningful answer. Without one there is no selection + * to read and the caller must fall back to its own default rather than + * treat an empty string as a value. */ + if (!s.initialized || !s.plan.ok) return 0; + const std::string value = s.manager.feature_option_value( + package_id, feature_id, option_id); + if (value.empty()) return 0; + if (value.size() + 1 > (size_t)out_size) return 0; + std::memcpy(out, value.c_str(), value.size() + 1); + return 1; +} + +extern "C" uint8_t psx_mod_read_byte(uint32_t address) { + return psx_read_byte(address); +} + +extern "C" void psx_mod_write_byte(uint32_t address, uint8_t value) { + psx_write_byte(address, value); +} + +extern "C" void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, + uint8_t* bytes, uint32_t size) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || !s.disc_enabled || s.disc_guard_failed || + !bytes || size == 0) return; + /* Raw Mode2 Form1 reads are also the source of the 2048-byte logical + * stream consumed by the emulated CD controller. Apply raw claims to the + * complete sector, then user-data claims to its payload window. Form2/XA + * and CDDA sectors deliberately do not receive disc_user overlays. */ + const bool has_mode2_form1_user_data = + raw_sector && size >= 2072 && bytes[15] == 2 && + (bytes[18] & 0x20u) == 0; + const ModPatchTarget target = + raw_sector ? ModPatchTarget::DiscRaw : ModPatchTarget::DiscUser; + const uint64_t base = (uint64_t)lba * size; + const uint64_t end = base + size; + const auto& index = raw_sector ? s.raw_disc_index : s.user_disc_index; + const auto sector = index.find(lba); + const auto& overlay_index = + raw_sector ? s.raw_overlay_index : s.user_overlay_index; + const auto overlay_sector = overlay_index.find(lba); + if (sector == index.end() && overlay_sector == overlay_index.end()) { + if (has_mode2_form1_user_data) + mod_runtime_patch_disc_sector(lba, 0, bytes + 24, 2048); + return; + } + if (sector != index.end()) { + for (size_t write_index : sector->second) { + const ModResolution::Write& write = s.plan.writes[write_index]; + if (write.target != target || write.location < base || + write.location + write.expected.size() > end) continue; + const size_t offset = (size_t)(write.location - base); + if (std::memcmp(bytes + offset, write.expected.data(), + write.expected.size()) != 0) { + std::fprintf(stderr, + "psxrecomp: disc mod plan %s rejected at LBA %u+%zu " + "(expected-byte guard failed; disc overlay disabled)\n", + s.plan.fingerprint.c_str(), lba, offset); + s.disc_guard_failed = true; + return; + } + } + for (size_t write_index : sector->second) { + const ModResolution::Write& write = s.plan.writes[write_index]; + if (write.target != target || write.location < base || + write.location + write.expected.size() > end) continue; + const size_t offset = (size_t)(write.location - base); + if (write.fields.empty()) { + std::memcpy(bytes + offset, write.replacement.data(), + write.replacement.size()); + } else { + for (const ModResolution::Write::Field& field : + write.fields) + std::memcpy( + bytes + offset + + static_cast<size_t>(field.offset), + field.replacement.data(), + field.replacement.size()); + } + } + } + if (overlay_sector != overlay_index.end()) { + for (size_t overlay_index_value : overlay_sector->second) { + const ModResolution::Overlay& overlay = + s.plan.overlays[overlay_index_value]; + const uint64_t overlay_end = + overlay.location + overlay.payload.size(); + const uint64_t copy_begin = std::max(base, overlay.location); + const uint64_t copy_end = std::min(end, overlay_end); + if (copy_begin >= copy_end) continue; + const size_t destination = (size_t)(copy_begin - base); + const size_t source = (size_t)(copy_begin - overlay.location); + const size_t count = (size_t)(copy_end - copy_begin); + std::memcpy(bytes + destination, + overlay.payload.data() + source, count); + } + } + if (has_mode2_form1_user_data && !s.disc_guard_failed) + mod_runtime_patch_disc_sector(lba, 0, bytes + 24, 2048); +} diff --git a/runtime/src/psx_bios_backend.c b/runtime/src/psx_bios_backend.c new file mode 100644 index 00000000..fdfbdf1f --- /dev/null +++ b/runtime/src/psx_bios_backend.c @@ -0,0 +1,98 @@ +/* psx_bios_backend.c — routing between the runtime and the active BIOS. + * + * A build links every recompiled BIOS it ships (bundled OpenBIOS plus a retail + * image). Each exports one descriptor; this file picks one, publishes it, and + * owns the symbols that used to be defined by the generated dispatch back when + * exactly one BIOS was linked. + * + * Nothing else had to change: psx_dispatch()/psx_dispatch_call() keep their + * names here as forwarders, so the game's generated C and all existing runtime + * call sites are untouched, and psx_bios_image keeps working as a global + * because it is assigned on selection. + * + * Selection policy itself lives in main.cpp (it needs the player's choice and + * the game config); this file only provides the mechanism. + */ + +#include <string.h> + +#include "cpu_state.h" +#include "psx_bios_backend.h" + +/* ── State published to the rest of the runtime ─────────────────────────── */ + +const PsxBiosBackend *psx_bios_active = 0; + +/* Assigned from the active backend. Zeroed until then, which reads as + * "structurally unavailable" for every field — the documented meaning of 0 in + * psx_bios_image.h — rather than as stale data from some other image. */ +PsxBiosImageInfo psx_bios_image; + +const PsxKernelBody *psx_bios_kernel_bodies = 0; +uint32_t psx_bios_kernel_body_count = 0; + +/* Dispatch nesting depth. Shared dispatch state, not per-image: each generated + * dispatch used to define its own copy, which is precisely why two of them + * could not be linked together. */ +int g_psx_dispatch_depth = 0; + +/* ── Forwarders ───────────────────────────────────────────────────────────── + * + * The generated BIOS C defines <STEM>_psx_dispatch; these keep the unprefixed + * names that ~66 runtime call sites and the game's generated C already use. */ + +void psx_dispatch(CPUState *cpu, uint32_t addr) +{ + psx_bios_active->dispatch(cpu, addr); +} + +void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr) +{ + psx_bios_active->dispatch_call(cpu, addr, return_addr); +} + +/* ── Selection ──────────────────────────────────────────────────────────── */ + +const PsxBiosBackend *psx_bios_find(const char *image_id) +{ + if (!image_id) return 0; + for (uint32_t i = 0; i < psx_bios_registry_count; i++) { + const PsxBiosBackend *b = psx_bios_registry[i]; + if (b && b->image && b->image->image_id && + strcmp(b->image->image_id, image_id) == 0) + return b; + } + return 0; +} + +const PsxBiosBackend *psx_bios_bundled(void) +{ + for (uint32_t i = 0; i < psx_bios_registry_count; i++) { + const PsxBiosBackend *b = psx_bios_registry[i]; + if (b && b->image && b->image->image_bundled) return b; + } + return 0; +} + +int psx_bios_activate(const PsxBiosBackend *backend) +{ + if (!backend || !backend->image) return 0; + psx_bios_active = backend; + psx_bios_image = *backend->image; + psx_bios_kernel_bodies = backend->kernel_bodies; + psx_bios_kernel_body_count = backend->kernel_body_count; + return 1; +} + +/* Is there any image the player could supply, i.e. a linked backend that is + * NOT the bundled one? Drives whether the launcher shows its BIOS row: with + * both a bundled and a retail image linked, a player must be able to opt into + * the retail one (and clear back). */ +int psx_bios_has_selectable(void) +{ + for (uint32_t i = 0; i < psx_bios_registry_count; i++) { + const PsxBiosBackend *b = psx_bios_registry[i]; + if (b && b->image && !b->image->image_bundled) return 1; + } + return 0; +} diff --git a/runtime/src/psx_sdl_audio.cpp b/runtime/src/psx_sdl_audio.cpp new file mode 100644 index 00000000..03c64590 --- /dev/null +++ b/runtime/src/psx_sdl_audio.cpp @@ -0,0 +1,173 @@ +#include "psx_sdl_audio.h" + +#include <algorithm> +#include <cstring> + +#if defined(PSX_SDL3) + +static SDL_AudioStream *s_audio_stream; +static PsxSdlAudioCallback s_audio_callback; +static void *s_audio_userdata; + +static void SDLCALL psx_sdl3_audio_feed( + void *, SDL_AudioStream *stream, int additional_amount, int) +{ + Uint8 buffer[16384]; + while (s_audio_callback && additional_amount > 0) { + const int chunk = std::min(additional_amount, (int)sizeof(buffer)); + s_audio_callback(s_audio_userdata, buffer, chunk); + if (!SDL_PutAudioStreamData(stream, buffer, chunk)) break; + additional_amount -= chunk; + } +} + +extern "C" SDL_AudioDeviceID psx_sdl_audio_open( + const PsxSdlAudioSpec *want, PsxSdlAudioSpec *have) +{ + if (!want || s_audio_stream) return 0; + + SDL_AudioSpec requested; + SDL_zero(requested); + requested.freq = want->freq; + requested.format = want->format; + requested.channels = want->channels; + + s_audio_callback = want->callback; + s_audio_userdata = want->userdata; + s_audio_stream = SDL_OpenAudioDeviceStream( + SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, + &requested, + s_audio_callback ? psx_sdl3_audio_feed : nullptr, + nullptr); + if (!s_audio_stream) { + s_audio_callback = nullptr; + s_audio_userdata = nullptr; + return 0; + } + + const SDL_AudioDeviceID device = SDL_GetAudioStreamDevice(s_audio_stream); + SDL_AudioSpec obtained = requested; + (void)SDL_GetAudioDeviceFormat(device, &obtained, nullptr); + if (have) { + std::memset(have, 0, sizeof(*have)); + have->freq = obtained.freq; + have->format = obtained.format; + have->channels = obtained.channels; + have->samples = want->samples; + } + + return device; +} + +extern "C" int psx_sdl_audio_resume(SDL_AudioDeviceID) +{ + return s_audio_stream && SDL_ResumeAudioStreamDevice(s_audio_stream) + ? 0 + : -1; +} + +extern "C" void psx_sdl_audio_close(SDL_AudioDeviceID) +{ + if (s_audio_stream) SDL_DestroyAudioStream(s_audio_stream); + s_audio_stream = nullptr; + s_audio_callback = nullptr; + s_audio_userdata = nullptr; +} + +extern "C" void psx_sdl_audio_clear(SDL_AudioDeviceID) +{ + if (s_audio_stream) (void)SDL_ClearAudioStream(s_audio_stream); +} + +extern "C" Uint32 psx_sdl_audio_queued_size(SDL_AudioDeviceID) +{ + if (!s_audio_stream) return 0; + const int queued = SDL_GetAudioStreamQueued(s_audio_stream); + return queued > 0 ? (Uint32)queued : 0; +} + +extern "C" int psx_sdl_audio_queue( + SDL_AudioDeviceID, const void *data, Uint32 bytes) +{ + return s_audio_stream && + SDL_PutAudioStreamData(s_audio_stream, data, (int)bytes) + ? 0 + : -1; +} + +extern "C" void psx_sdl_audio_lock(SDL_AudioDeviceID) +{ + if (s_audio_stream) (void)SDL_LockAudioStream(s_audio_stream); +} + +extern "C" void psx_sdl_audio_unlock(SDL_AudioDeviceID) +{ + if (s_audio_stream) (void)SDL_UnlockAudioStream(s_audio_stream); +} + +#else + +extern "C" SDL_AudioDeviceID psx_sdl_audio_open( + const PsxSdlAudioSpec *want, PsxSdlAudioSpec *have) +{ + SDL_AudioSpec requested; + SDL_AudioSpec obtained; + SDL_zero(requested); + SDL_zero(obtained); + requested.freq = want->freq; + requested.format = want->format; + requested.channels = want->channels; + requested.samples = want->samples; + requested.callback = want->callback; + requested.userdata = want->userdata; + const SDL_AudioDeviceID device = SDL_OpenAudioDevice( + nullptr, 0, &requested, &obtained, + want->allow_frequency_change ? SDL_AUDIO_ALLOW_FREQUENCY_CHANGE : 0); + if (device && have) { + std::memset(have, 0, sizeof(*have)); + have->freq = obtained.freq; + have->format = obtained.format; + have->channels = obtained.channels; + have->samples = obtained.samples; + } + return device; +} + +extern "C" int psx_sdl_audio_resume(SDL_AudioDeviceID device) +{ + SDL_PauseAudioDevice(device, 0); + return 0; +} + +extern "C" void psx_sdl_audio_close(SDL_AudioDeviceID device) +{ + SDL_CloseAudioDevice(device); +} + +extern "C" void psx_sdl_audio_clear(SDL_AudioDeviceID device) +{ + SDL_ClearQueuedAudio(device); +} + +extern "C" Uint32 psx_sdl_audio_queued_size(SDL_AudioDeviceID device) +{ + return SDL_GetQueuedAudioSize(device); +} + +extern "C" int psx_sdl_audio_queue( + SDL_AudioDeviceID device, const void *data, Uint32 bytes) +{ + return SDL_QueueAudio(device, data, bytes); +} + +extern "C" void psx_sdl_audio_lock(SDL_AudioDeviceID device) +{ + SDL_LockAudioDevice(device); +} + +extern "C" void psx_sdl_audio_unlock(SDL_AudioDeviceID device) +{ + SDL_UnlockAudioDevice(device); +} + +#endif diff --git a/runtime/src/psx_sha256.c b/runtime/src/psx_sha256.c new file mode 100644 index 00000000..5a4b7092 --- /dev/null +++ b/runtime/src/psx_sha256.c @@ -0,0 +1,89 @@ +/* One-shot SHA-256, based directly on FIPS 180-4. Public domain. */ +#include "psx_sha256.h" +#include <string.h> + +static const uint32_t K[64] = { + 0x428a2f98u,0x71374491u,0xb5c0fbcfu,0xe9b5dba5u,0x3956c25bu,0x59f111f1u,0x923f82a4u,0xab1c5ed5u, + 0xd807aa98u,0x12835b01u,0x243185beu,0x550c7dc3u,0x72be5d74u,0x80deb1feu,0x9bdc06a7u,0xc19bf174u, + 0xe49b69c1u,0xefbe4786u,0x0fc19dc6u,0x240ca1ccu,0x2de92c6fu,0x4a7484aau,0x5cb0a9dcu,0x76f988dau, + 0x983e5152u,0xa831c66du,0xb00327c8u,0xbf597fc7u,0xc6e00bf3u,0xd5a79147u,0x06ca6351u,0x14292967u, + 0x27b70a85u,0x2e1b2138u,0x4d2c6dfcu,0x53380d13u,0x650a7354u,0x766a0abbu,0x81c2c92eu,0x92722c85u, + 0xa2bfe8a1u,0xa81a664bu,0xc24b8b70u,0xc76c51a3u,0xd192e819u,0xd6990624u,0xf40e3585u,0x106aa070u, + 0x19a4c116u,0x1e376c08u,0x2748774cu,0x34b0bcb5u,0x391c0cb3u,0x4ed8aa4au,0x5b9cca4fu,0x682e6ff3u, + 0x748f82eeu,0x78a5636fu,0x84c87814u,0x8cc70208u,0x90befffau,0xa4506cebu,0xbef9a3f7u,0xc67178f2u +}; + +static uint32_t rotr32(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); } + +static void block(uint32_t h[8], const uint8_t b[64]) { + uint32_t w[64]; + for (int i=0;i<16;i++) w[i]=((uint32_t)b[i*4]<<24)|((uint32_t)b[i*4+1]<<16)| + ((uint32_t)b[i*4+2]<<8)|(uint32_t)b[i*4+3]; + for (int i=16;i<64;i++) { + uint32_t s0=rotr32(w[i-15],7)^rotr32(w[i-15],18)^(w[i-15]>>3); + uint32_t s1=rotr32(w[i-2],17)^rotr32(w[i-2],19)^(w[i-2]>>10); + w[i]=w[i-16]+s0+w[i-7]+s1; + } + uint32_t a=h[0],c0=h[1],c=h[2],d=h[3],e=h[4],f=h[5],g=h[6],x=h[7]; + for (int i=0;i<64;i++) { + uint32_t s1=rotr32(e,6)^rotr32(e,11)^rotr32(e,25); + uint32_t t1=x+s1+((e&f)^((~e)&g))+K[i]+w[i]; + uint32_t s0=rotr32(a,2)^rotr32(a,13)^rotr32(a,22); + uint32_t t2=s0+((a&c0)^(a&c)^(c0&c)); + x=g;g=f;f=e;e=d+t1;d=c;c=c0;c0=a;a=t1+t2; + } + h[0]+=a;h[1]+=c0;h[2]+=c;h[3]+=d;h[4]+=e;h[5]+=f;h[6]+=g;h[7]+=x; +} + +void psx_sha256_init(psx_sha256_ctx* ctx) { + static const uint32_t initial[8] = { + 0x6a09e667u,0xbb67ae85u,0x3c6ef372u,0xa54ff53au, + 0x510e527fu,0x9b05688cu,0x1f83d9abu,0x5be0cd19u}; + memcpy(ctx->h, initial, sizeof(initial)); + ctx->total = 0; + ctx->buffered = 0; +} + +void psx_sha256_update(psx_sha256_ctx* ctx, const uint8_t* data, size_t len) { + ctx->total += len; + if (ctx->buffered) { + size_t take = 64 - ctx->buffered; + if (take > len) take = len; + memcpy(ctx->buffer + ctx->buffered, data, take); + ctx->buffered += take; data += take; len -= take; + if (ctx->buffered == 64) { + block(ctx->h, ctx->buffer); + ctx->buffered = 0; + } + } + while (len >= 64) { + block(ctx->h, data); + data += 64; + len -= 64; + } + if (len) { + memcpy(ctx->buffer, data, len); + ctx->buffered = len; + } +} + +void psx_sha256_final(psx_sha256_ctx* ctx, uint8_t out[32]) { + uint8_t tail[128]; + size_t n=ctx->buffered,total=(n<56)?64:128; + memcpy(tail,ctx->buffer,n); tail[n]=0x80; + memset(tail+n+1,0,total-n-1-8); + uint64_t bits=ctx->total*8; + for (int i=0;i<8;i++) tail[total-1-i]=(uint8_t)(bits>>(i*8)); + block(ctx->h,tail); if (total==128) block(ctx->h,tail+64); + for (int i=0;i<8;i++) { + out[i*4]=(uint8_t)(ctx->h[i]>>24); out[i*4+1]=(uint8_t)(ctx->h[i]>>16); + out[i*4+2]=(uint8_t)(ctx->h[i]>>8); out[i*4+3]=(uint8_t)ctx->h[i]; + } +} + +void psx_sha256_compute(const uint8_t *data, size_t len, uint8_t out[32]) { + psx_sha256_ctx ctx; + psx_sha256_init(&ctx); + psx_sha256_update(&ctx, data, len); + psx_sha256_final(&ctx, out); +} diff --git a/runtime/src/psx_stick.c b/runtime/src/psx_stick.c new file mode 100644 index 00000000..22ef4783 --- /dev/null +++ b/runtime/src/psx_stick.c @@ -0,0 +1,73 @@ +#include "psx_stick.h" + +#include <math.h> + +static int clamp_axis_setting(int value) +{ + if (value < 0) + return 0; + if (value > 32767) + return 32767; + return value; +} + +static uint8_t signed_axis_to_byte(int value) +{ + int byte = (value + 32768) >> 8; + if (byte < 0) + byte = 0; + if (byte > 255) + byte = 255; + return (uint8_t)byte; +} + +void psx_stick_to_dualshock(int16_t x, int16_t y, + int deadzone, int anti_deadzone, + uint8_t *out_x, uint8_t *out_y) +{ + const double raw_x = (double)x; + const double raw_y = (double)y; + const double magnitude = sqrt(raw_x * raw_x + raw_y * raw_y); + double source_magnitude; + double travel; + double output_magnitude; + double scale; + int scaled_x; + int scaled_y; + + deadzone = clamp_axis_setting(deadzone); + anti_deadzone = clamp_axis_setting(anti_deadzone); + + if (deadzone == 32767 || + magnitude <= (double)deadzone || magnitude <= 0.0) { + *out_x = 0x80; + *out_y = 0x80; + return; + } + + source_magnitude = magnitude > 32767.0 ? 32767.0 : magnitude; + travel = (source_magnitude - (double)deadzone) / + (32767.0 - (double)deadzone); + if (travel < 0.0) + travel = 0.0; + if (travel > 1.0) + travel = 1.0; + + output_magnitude = (double)anti_deadzone + + travel * (32767.0 - (double)anti_deadzone); + scale = output_magnitude / magnitude; + scaled_x = (int)lround(raw_x * scale); + scaled_y = (int)lround(raw_y * scale); + + if (scaled_x < -32768) + scaled_x = -32768; + if (scaled_x > 32767) + scaled_x = 32767; + if (scaled_y < -32768) + scaled_y = -32768; + if (scaled_y > 32767) + scaled_y = 32767; + + *out_x = signed_axis_to_byte(scaled_x); + *out_y = signed_axis_to_byte(scaled_y); +} diff --git a/runtime/src/ws_aspect_cone_math.c b/runtime/src/ws_aspect_cone_math.c new file mode 100644 index 00000000..aca28999 --- /dev/null +++ b/runtime/src/ws_aspect_cone_math.c @@ -0,0 +1,50 @@ +#include "ws_aspect_cone_math.h" + +#include <math.h> + +uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels) { + if (extent_pixels <= 0) return vanilla; + if (vanilla == 0) return 0; + if (vanilla >= 1024u) return 1023u; + + const double tau = 6.283185307179586476925286766559; + const double angle = (double)vanilla * tau / 4096.0; + const double scale = (160.0 + (double)extent_pixels) / 160.0; + const double widened = atan(tan(angle) * scale); + long result = lround(widened * 4096.0 / tau); + if (result < 1) result = 1; + if (result > 1023) result = 1023; + return (uint32_t)result; +} + +int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, + int32_t fx, int32_t fz, int32_t fy, + uint32_t threshold, int extent_pixels) { + if (threshold == 0 || threshold >= 1024 || extent_pixels < 0) + return 0; + + const double fhd = hypot((double)fx, (double)fz); + if (fhd < 1.0) return 0; + + /* Camera-local coordinates. The Q12 scale cancels from the comparisons. + * The right vector is horizontal in world space; the derived up vector is + * perpendicular to both right and the pitched camera-forward vector. */ + const double forward = + ((double)fx * x + (double)fz * z + (double)fy * y) / 4096.0; + if (forward <= 0.0) return 0; + const double horizontal = + ((double)fz * x - (double)fx * z) / fhd; + const double vertical_num = + (double)fx * fy * x - + fhd * fhd * y + + (double)fz * fy * z; + const double vertical = vertical_num / (fhd * 4096.0); + + const double cosine = (double)threshold / 1024.0; + const double tangent = + sqrt((1.0 - cosine * cosine) / (cosine * cosine)); + if (fabs(vertical) > forward * tangent) return 0; + + const double aspect_scale = (160.0 + extent_pixels) / 160.0; + return fabs(horizontal) <= forward * tangent * aspect_scale; +} diff --git a/runtime/src/ws_ui_group.c b/runtime/src/ws_ui_group.c new file mode 100644 index 00000000..33f30480 --- /dev/null +++ b/runtime/src/ws_ui_group.c @@ -0,0 +1,76 @@ +#include "ws_ui_group.h" + +#include <stdlib.h> + +int32_t ws_ui_anchor_for_bounds(int32_t x, int32_t width, + int32_t display_width) { + const int32_t twice_center = 2 * x + width; + if (3 * twice_center < 2 * display_width) return 0; + if (3 * twice_center > 4 * display_width) return display_width; + return display_width / 2; +} + +static int32_t interval_gap(const WsUiGroupItem *a, + const WsUiGroupItem *b) { + int32_t a_right = a->x + a->width; + int32_t b_right = b->x + b->width; + if (a_right < b->x) return b->x - a_right; + if (b_right < a->x) return a->x - b_right; + return 0; +} + +static size_t root_of(size_t *parent, size_t index) { + while (parent[index] != index) { + parent[index] = parent[parent[index]]; + index = parent[index]; + } + return index; +} + +void ws_ui_group_assign(WsUiGroupItem *items, size_t count, + int32_t display_width, int dense_menu) { + if (!items || count == 0 || display_width <= 0) return; + if (dense_menu) { + for (size_t i = 0; i < count; i++) + items[i].anchor = display_width / 2; + return; + } + + size_t *parent = (size_t *)malloc(count * sizeof(*parent)); + if (!parent) { + for (size_t i = 0; i < count; i++) { + items[i].anchor = ws_ui_anchor_for_bounds( + items[i].x, items[i].width, display_width); + } + return; + } + for (size_t i = 0; i < count; i++) parent[i] = i; + + /* Transitive union is important for text: each adjacent pair is close, + * even when the complete string spans more than one thirds region. */ + for (size_t i = 0; i < count; i++) { + for (size_t j = i + 1; j < count; j++) { + if (items[i].key != items[j].key || + interval_gap(&items[i], &items[j]) > WS_UI_GROUP_JOIN_GAP) + continue; + size_t ri = root_of(parent, i); + size_t rj = root_of(parent, j); + if (ri != rj) parent[rj] = ri; + } + } + + for (size_t i = 0; i < count; i++) { + size_t root = root_of(parent, i); + int32_t min_x = items[i].x; + int32_t max_x = items[i].x + items[i].width; + for (size_t j = 0; j < count; j++) { + if (root_of(parent, j) != root) continue; + if (items[j].x < min_x) min_x = items[j].x; + if (items[j].x + items[j].width > max_x) + max_x = items[j].x + items[j].width; + } + items[i].anchor = + ws_ui_anchor_for_bounds(min_x, max_x - min_x, display_width); + } + free(parent); +} diff --git a/runtime/tests/test_bios_hle_plan.c b/runtime/tests/test_bios_hle_plan.c new file mode 100644 index 00000000..8f25ff6c --- /dev/null +++ b/runtime/tests/test_bios_hle_plan.c @@ -0,0 +1,153 @@ +/* Decision-matrix test for psx_bios_hle_plan (runtime/src/bios_hle_plan.c). + * + * The property under test is PARITY: the player-facing "skip the BIOS and go + * straight to the game" flag must resolve to the same boot behaviour on every + * linked BIOS image that exports a shell entry — retail SCPH-1001 (which also + * exports a DeliverEvent anchor, so it gets the kernel-call HLE tier too) and + * the bundled OpenBIOS (which deliberately does not, so its kernel calls stay + * LLE). The regression this pins: boot_skip used to be derived from the + * already-anchor-gated call-HLE decision, so choosing OpenBIOS silently left + * the player sitting in the boot animation. + */ +#include "bios_hle_plan.h" + +#include <stdio.h> + +static int s_fails = 0; + +static void expect(int cond, const char *what) { + if (!cond) { + fprintf(stderr, "FAIL: %s\n", what); + s_fails++; + } +} + +/* The two images this build actually links. Only the anchors differ. */ +#define RETAIL_ANCHORS /* deliver_event_ret */ 1, /* shell_entry */ 1 +#define OPENBIOS_ANCHORS /* deliver_event_ret */ 0, /* shell_entry */ 1 + +static PsxBiosHleRequest req(int bios_hle, int keep_intro, int fast_boot, + int have_der, int have_shell, int have_game) { + PsxBiosHleRequest r; + r.bios_hle = bios_hle; + r.keep_intro = keep_intro; + r.fast_boot = fast_boot; + r.have_deliver_event_ret = have_der; + r.have_shell_entry = have_shell; + r.have_game_entry = have_game; + return r; +} + +int main(void) { + /* ---- 1. PARITY: bios_hle=true skips the boot on BOTH images ---------- */ + PsxBiosHlePlan retail = + psx_bios_hle_plan(req(1, 0, 0, RETAIL_ANCHORS, 1)); + PsxBiosHlePlan openbios = + psx_bios_hle_plan(req(1, 0, 0, OPENBIOS_ANCHORS, 1)); + + expect(retail.boot_skip == 1, "retail: bios_hle=true skips the boot"); + expect(openbios.boot_skip == 1, "OpenBIOS: bios_hle=true skips the boot"); + expect(retail.boot_skip == openbios.boot_skip, + "boot behaviour is identical across BIOS images (the whole point)"); + expect(openbios.boot_skip_denied == 0, + "OpenBIOS: a shell-entry image never reports a boot-skip denial"); + + /* ...while the kernel-call axis still differs, loudly, as designed. */ + expect(retail.call_hle == 1, "retail: kernel-call HLE granted"); + expect(retail.call_hle_denied == 0, "retail: no call-HLE denial"); + expect(openbios.call_hle == 0, + "OpenBIOS: kernel-call HLE refused (no DeliverEvent anchor)"); + expect(openbios.call_hle_denied == 1, + "OpenBIOS: the call-HLE refusal is reported, not silent"); + + /* ---- 2. Opting out still works, identically on both images ----------- */ + for (int i = 0; i < 2; i++) { + const int der = (i == 0) ? 1 : 0; /* retail, then OpenBIOS */ + const char *who = (i == 0) ? "retail" : "OpenBIOS"; + + PsxBiosHlePlan off = psx_bios_hle_plan(req(0, 0, 0, der, 1, 1)); + expect(off.boot_skip == 0 && off.call_hle == 0, + "bios_hle=false = pure LLE, real intro"); + expect(off.call_hle_denied == 0 && off.boot_skip_denied == 0, + "bios_hle=false denies nothing (nothing was asked for)"); + + PsxBiosHlePlan keep = psx_bios_hle_plan(req(1, 1, 0, der, 1, 1)); + expect(keep.boot_skip == 0, "keep_intro=true plays the real intro"); + + /* The deprecated fast_boot alias is boot-only and independent of the + * call tier — it must skip the boot even with bios_hle off. */ + PsxBiosHlePlan fb = psx_bios_hle_plan(req(0, 0, 1, der, 1, 1)); + expect(fb.boot_skip == 1, "fast_boot alias skips the boot"); + expect(fb.call_hle == 0, "fast_boot never enables kernel-call HLE"); + + /* keep_intro suppresses the bios_hle-implied skip, not an explicit + * fast_boot request. */ + PsxBiosHlePlan fb_keep = psx_bios_hle_plan(req(1, 1, 1, der, 1, 1)); + expect(fb_keep.boot_skip == 1, + "explicit fast_boot wins over keep_intro"); + (void)who; + } + + /* ---- 3. Structural limits ------------------------------------------- */ + /* No game loaded: the shell IS the product, so there is nothing to skip + * to, and that is not a denial. */ + PsxBiosHlePlan bios_only = psx_bios_hle_plan(req(1, 0, 0, 1, 1, 0)); + expect(bios_only.boot_skip == 0, + "BIOS-only run: no game entry, no boot-skip"); + expect(bios_only.boot_skip_denied == 0, + "BIOS-only run: absent game is not a boot-skip denial"); + expect(bios_only.call_hle == 1, + "BIOS-only run: kernel-call HLE is unaffected by the game's absence"); + + /* An image with no shell entry at all cannot skip, and says so. */ + PsxBiosHlePlan no_shell = psx_bios_hle_plan(req(1, 0, 0, 1, 0, 1)); + expect(no_shell.boot_skip == 0, + "no shell-entry anchor: boot-skip structurally unavailable"); + expect(no_shell.boot_skip_denied == 1, + "no shell-entry anchor: the refusal is reported"); + + /* Neither axis available: everything off, both refusals reported. */ + PsxBiosHlePlan bare = psx_bios_hle_plan(req(1, 0, 0, 0, 0, 1)); + expect(bare.call_hle == 0 && bare.boot_skip == 0, "bare image: pure LLE"); + expect(bare.call_hle_denied == 1 && bare.boot_skip_denied == 1, + "bare image: both refusals reported"); + + /* ---- 4. Purity: exhaustive sweep, no cross-axis contamination -------- */ + for (int bits = 0; bits < 64; bits++) { + PsxBiosHleRequest r = req((bits >> 0) & 1, (bits >> 1) & 1, + (bits >> 2) & 1, (bits >> 3) & 1, + (bits >> 4) & 1, (bits >> 5) & 1); + PsxBiosHlePlan p = psx_bios_hle_plan(r); + + /* boot_skip must NEVER depend on the DeliverEvent anchor: flipping + * only that bit may change call_hle, never the boot decision. */ + PsxBiosHleRequest flipped = r; + flipped.have_deliver_event_ret = !r.have_deliver_event_ret; + PsxBiosHlePlan q = psx_bios_hle_plan(flipped); + expect(p.boot_skip == q.boot_skip, + "boot-skip is independent of the DeliverEvent anchor"); + expect(p.boot_skip_denied == q.boot_skip_denied, + "boot-skip denial is independent of the DeliverEvent anchor"); + + /* call_hle must never depend on the shell entry or the game. */ + PsxBiosHleRequest flip2 = r; + flip2.have_shell_entry = !r.have_shell_entry; + flip2.have_game_entry = !r.have_game_entry; + PsxBiosHlePlan s = psx_bios_hle_plan(flip2); + expect(p.call_hle == s.call_hle, + "call-HLE is independent of the shell entry / game presence"); + + /* A granted axis is never simultaneously reported as denied. */ + expect(!(p.call_hle && p.call_hle_denied), + "call-HLE never both granted and denied"); + expect(!(p.boot_skip && p.boot_skip_denied), + "boot-skip never both granted and denied"); + } + + if (s_fails) { + fprintf(stderr, "bios_hle_plan_test: %d failure(s)\n", s_fails); + return 1; + } + printf("bios_hle_plan_test: OK\n"); + return 0; +} diff --git a/runtime/tests/test_bios_rom_alias.cpp b/runtime/tests/test_bios_rom_alias.cpp new file mode 100644 index 00000000..30420c14 --- /dev/null +++ b/runtime/tests/test_bios_rom_alias.cpp @@ -0,0 +1,120 @@ +// test_bios_rom_alias.cpp — both BIOS filename conventions resolve, both ways. +// +// Regression anchor for the multi-BIOS rename: master's SCPH*.toml name +// "US-PSX-SCPH1001.BIN" while every pre-existing dump folder, bios.cfg and +// tools/regen_bios.sh default name "SCPH1001.BIN". A config and a folder that +// disagree used to fatal with "cannot open BIOS file" despite the right ROM +// being present. + +#include "bios_rom_alias.h" + +#include <cstdio> +#include <cstdlib> +#include <filesystem> +#include <fstream> + +namespace fs = std::filesystem; +using PSXRecompV4::bios_model_token; +using PSXRecompV4::resolve_bios_rom; + +static int g_checks = 0; + +static void check(bool cond, const char* what) { + ++g_checks; + if (!cond) { std::fprintf(stderr, "FAIL: %s\n", what); std::abort(); } +} + +static void touch(const fs::path& p) { + fs::create_directories(p.parent_path()); + std::ofstream(p, std::ios::binary).put('\0'); +} + +static bool same(const fs::path& a, const fs::path& b) { + return a.lexically_normal() == b.lexically_normal(); +} + +int main() { + // ---- model-token extraction ------------------------------------------- + check(bios_model_token("SCPH1001.BIN") == "SCPH1001", "bare stem token"); + check(bios_model_token("US-PSX-SCPH1001.BIN") == "SCPH1001", "qualified stem token"); + check(bios_model_token("US-PSOne-SCPH101.BIN") == "SCPH101", "PSOne token"); + check(bios_model_token("EUR-PSX-SCPH5552.bin") == "SCPH5552", "lowercase-ext token"); + check(bios_model_token("scph1001.bin") == "SCPH1001", "token is case-folded"); + + const fs::path root = fs::temp_directory_path() / "psxrecomp-bios-alias-test"; + fs::remove_all(root); + + // ---- new config name, old folder layout -------------------------------- + { + const fs::path dir = root / "old-folder" / "bios"; + touch(dir / "SCPH1001.BIN"); + const fs::path got = resolve_bios_rom(dir / "US-PSX-SCPH1001.BIN"); + check(same(got, dir / "SCPH1001.BIN"), "qualified request finds bare ROM"); + } + + // ---- old config name, new folder layout -------------------------------- + { + const fs::path dir = root / "new-folder" / "bios"; + touch(dir / "US-PSX-SCPH1001.BIN"); + const fs::path got = resolve_bios_rom(dir / "SCPH1001.BIN"); + check(same(got, dir / "US-PSX-SCPH1001.BIN"), "bare request finds qualified ROM"); + } + + // ---- exact match always wins over an alias ----------------------------- + { + const fs::path dir = root / "both" / "bios"; + touch(dir / "SCPH1001.BIN"); + touch(dir / "US-PSX-SCPH1001.BIN"); + check(same(resolve_bios_rom(dir / "SCPH1001.BIN"), dir / "SCPH1001.BIN"), + "exact bare wins"); + check(same(resolve_bios_rom(dir / "US-PSX-SCPH1001.BIN"), dir / "US-PSX-SCPH1001.BIN"), + "exact qualified wins"); + } + + // ---- lowercase extension + a different region prefix ------------------- + { + const fs::path dir = root / "eur" / "bios"; + touch(dir / "EUR-PSX-SCPH5552.bin"); + check(same(resolve_bios_rom(dir / "SCPH5552.BIN"), dir / "EUR-PSX-SCPH5552.bin"), + "lowercase .bin + EUR prefix resolves"); + } + + // ---- a config that omits the bios/ directory (master's SCPH5552.toml) -- + { + const fs::path gameroot = root / "no-prefix"; + touch(gameroot / "bios" / "EUR-PSX-SCPH5552.bin"); + check(same(resolve_bios_rom(gameroot / "EUR-PSX-SCPH5552.bin"), + gameroot / "bios" / "EUR-PSX-SCPH5552.bin"), + "missing bios/ prefix still resolves"); + } + + // ---- a different model must NOT be substituted ------------------------- + { + const fs::path dir = root / "wrong-model" / "bios"; + touch(dir / "US-PSX-SCPH1001.BIN"); + const fs::path asked = dir / "SCPH101.BIN"; + check(same(resolve_bios_rom(asked), asked), + "SCPH101 is not satisfied by SCPH1001"); + } + + // ---- non-BIOS files in the folder are ignored -------------------------- + { + const fs::path dir = root / "noise" / "bios"; + touch(dir / "SCPH1001.json"); + touch(dir / "openbios.bin"); + const fs::path asked = dir / "SCPH1001.BIN"; + check(same(resolve_bios_rom(asked), asked), "wrong extension is not a match"); + } + + // ---- nothing found: return the request unchanged so the caller's own + // diagnostic still names what was asked for ------------------------- + { + const fs::path asked = root / "empty" / "bios" / "SCPH1001.BIN"; + check(same(resolve_bios_rom(asked), asked), "unresolvable request passes through"); + check(resolve_bios_rom({}).empty(), "empty path passes through"); + } + + fs::remove_all(root); + std::printf("bios_rom_alias: %d checks passed\n", g_checks); + return 0; +} diff --git a/runtime/tests/test_dirty_text_continuation_guards.py b/runtime/tests/test_dirty_text_continuation_guards.py new file mode 100644 index 00000000..4cf1a24e --- /dev/null +++ b/runtime/tests/test_dirty_text_continuation_guards.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Keep dirty-text continuation handoff behavior from regressing.""" + +from pathlib import Path +import sys + + +def main() -> int: + root = Path(__file__).resolve().parents[2] + memory = (root / "runtime/src/memory.c").read_text(encoding="utf-8") + interp = (root / "runtime/src/dirty_ram_interp.c").read_text(encoding="utf-8") + + start = memory.index("int dirty_ram_text_native_ok_ranges_from(") + end = memory.index("\n/* Preserve the generated-code ABI", start) + range_guard = memory[start:end] + + required_range_fragments = ( + "uint32_t exec_pc", + "if (phys + len <= at) continue;", + "len -= at - phys;", + "if (!any)", + ) + for fragment in required_range_fragments: + if fragment not in range_guard: + raise AssertionError(f"missing continuation range guard: {fragment}") + if "text_diverged_bitmap" in range_guard: + raise AssertionError("exact-range mismatch still sticky-poisons a whole page") + if "dirty_ram_text_native_ok_ranges_from(lo_len_pairs, count, 0u)" not in memory: + raise AssertionError("legacy generated-code ABI is not preserved") + + handoff = "clean_game_text_miss && interp_enter_compiled(cpu, " + if interp.count(handoff) != 2: + raise AssertionError("expected transfer and call-return continuation handoffs") + if "interp_enter_compiled(cpu, target)" not in interp: + raise AssertionError("missing transfer-boundary continuation handoff") + if "interp_enter_compiled(cpu, pc)" not in interp: + raise AssertionError("missing call-return continuation handoff") + + print("dirty-text continuation guards: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/test_disc_path_resolve.cpp b/runtime/tests/test_disc_path_resolve.cpp new file mode 100644 index 00000000..80d2e7ee --- /dev/null +++ b/runtime/tests/test_disc_path_resolve.cpp @@ -0,0 +1,223 @@ +// test_disc_path_resolve.cpp — either member of a disc dump is a valid pick. +// +// The contract under test (runtime/include/disc_path.h): whichever file the +// player hands us — the .cue or the .bin — we mount the same disc and read +// identity/CRC from the same data track. Regression anchor for the old +// behavior, which swapped a .cue for its same-named .bin unconditionally and +// silently dropped the cue's CD-DA tracks. + +#include "cue_sheet.h" +#include "disc_path.h" +#include "iso_reader.h" + +#include <cassert> +#include <cstdint> +#include <cstdio> +#include <filesystem> +#include <fstream> +#include <string> +#include <vector> + +namespace fs = std::filesystem; +using PSXRecompV4::resolve_disc_path; + +namespace { + +int g_checks = 0; + +void check(bool cond, const char* what) { + ++g_checks; + if (!cond) { + std::fprintf(stderr, "FAIL: %s\n", what); + std::abort(); + } +} + +void write_sectors(const fs::path& path, int count, uint8_t marker) { + std::ofstream out(path, std::ios::binary); + std::vector<uint8_t> sector(2352, marker); + for (int i = 0; i < count; ++i) + out.write(reinterpret_cast<const char*>(sector.data()), (std::streamsize)sector.size()); +} + +void write_text(const fs::path& path, const std::string& body) { + std::ofstream out(path); + out << body; +} + +bool same(const fs::path& a, const fs::path& b) { + return a.lexically_normal() == b.lexically_normal(); +} + +// ---- 1. single-file dump: cue and bin are interchangeable ------------------- +void test_single_file_dump(const fs::path& root) { + const fs::path dir = root / "single"; + fs::create_directories(dir); + write_sectors(dir / "game.bin", 4, 0x11); + write_text(dir / "game.cue", + "FILE \"game.bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n"); + + const auto from_cue = resolve_disc_path(dir / "game.cue"); + check(from_cue.from_cue, "single: picking the cue mounts the cue"); + check(same(from_cue.mount, dir / "game.cue"), "single: cue mount path"); + check(same(from_cue.data, dir / "game.bin"), "single: cue -> bin data track"); + + const auto from_bin = resolve_disc_path(dir / "game.bin"); + check(from_bin.upgraded_to_cue, "single: picking the bin finds the owning cue"); + check(same(from_bin.mount, dir / "game.cue"), "single: bin upgrades to the cue"); + + // The whole point: both picks agree on what to read identity/CRC from. + check(same(from_cue.data, from_bin.data), "single: both picks share a data track"); +} + +// ---- 2. redump multi-track: picking any track file finds the cue ------------ +void test_multi_track_dump(const fs::path& root) { + const fs::path dir = root / "multi"; + fs::create_directories(dir); + write_sectors(dir / "Game (Track 1).bin", 4, 0x11); + write_sectors(dir / "Game (Track 2).bin", 3, 0x22); + write_text(dir / "Game.cue", + "FILE \"Game (Track 1).bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n" + "FILE \"Game (Track 2).bin\" BINARY\n" + " TRACK 02 AUDIO\n" + " INDEX 00 00:00:00\n" + " INDEX 01 00:00:01\n"); + + const auto from_cue = resolve_disc_path(dir / "Game.cue"); + check(from_cue.from_cue, "multi: cue mounts as a cue"); + check(same(from_cue.data, dir / "Game (Track 1).bin"), "multi: data track is track 1"); + + // Picking the DATA track's bin: must upgrade to the cue, or the audio track + // disappears. This is the case the old normalize_disc_path_for_launch got + // wrong in the other direction. + const auto from_data_bin = resolve_disc_path(dir / "Game (Track 1).bin"); + check(from_data_bin.upgraded_to_cue, "multi: data bin upgrades to the cue"); + check(same(from_data_bin.mount, dir / "Game.cue"), "multi: data bin -> Game.cue"); + + // Picking an AUDIO track's bin still resolves identity to the data track, + // so the verify verdict does not depend on which file was clicked. + const auto from_audio_bin = resolve_disc_path(dir / "Game (Track 2).bin"); + check(from_audio_bin.upgraded_to_cue, "multi: audio bin upgrades to the cue"); + check(same(from_audio_bin.data, dir / "Game (Track 1).bin"), + "multi: audio bin still identifies via the data track"); + + // And the mounted TOC really does carry both tracks, for every pick. + for (const fs::path& pick : {dir / "Game.cue", + dir / "Game (Track 1).bin", + dir / "Game (Track 2).bin"}) { + PS1::ISOReader reader; + check(reader.Open(resolve_disc_path(pick).mount.string()), "multi: mount opens"); + check(reader.TrackCount() == 2, "multi: both tracks present regardless of pick"); + check(reader.TrackIsAudio(2), "multi: track 2 is audio regardless of pick"); + reader.Close(); + } +} + +// ---- 3. broken cue: fall back to the sibling image instead of failing ------- +void test_cue_with_missing_bin(const fs::path& root) { + const fs::path dir = root / "broken"; + fs::create_directories(dir); + write_sectors(dir / "game.bin", 4, 0x11); + // The cue names a payload that is not there (renamed dump, partial copy). + write_text(dir / "game.cue", + "FILE \"game (Track 01).bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n"); + + const auto r = resolve_disc_path(dir / "game.cue"); + check(r.cue_fallback, "broken cue: reports the fallback"); + check(same(r.mount, dir / "game.bin"), "broken cue: falls back to the sibling bin"); + check(same(r.data, dir / "game.bin"), "broken cue: identity reads the sibling bin"); + check(!r.note.empty(), "broken cue: explains itself"); +} + +// ---- 4. no cue at all: a bare image mounts as itself ------------------------ +void test_bare_image(const fs::path& root) { + const fs::path dir = root / "bare"; + fs::create_directories(dir); + write_sectors(dir / "game.bin", 4, 0x11); + + const auto r = resolve_disc_path(dir / "game.bin"); + check(!r.from_cue, "bare: no cue to upgrade to"); + check(same(r.mount, dir / "game.bin"), "bare: mounts itself"); + check(same(r.data, dir / "game.bin"), "bare: identity reads itself"); + check(r.note.empty(), "bare: nothing to report"); + + // .iso is equally acceptable. + write_sectors(dir / "game2.iso", 4, 0x33); + const auto iso = resolve_disc_path(dir / "game2.iso"); + check(same(iso.mount, dir / "game2.iso"), "bare: iso mounts itself"); +} + +// ---- 5. surface-syntax tolerance in the shared cue parser ------------------- +void test_cue_syntax_tolerance(const fs::path& root) { + const fs::path dir = root / "syntax"; + fs::create_directories(dir); + write_sectors(dir / "game.bin", 4, 0x11); + + // Lowercase keywords, unquoted filename, CRLF line endings. + write_text(dir / "lower.cue", + "file game.bin binary\r\n" + " track 01 mode2/2352\r\n" + " index 01 00:00:00\r\n"); + const auto lower = PSXRecompV4::parse_cue_sheet(dir / "lower.cue"); + check(lower.usable(), "syntax: lowercase + unquoted + CRLF parses"); + check(same(lower.files.front().path, dir / "game.bin"), "syntax: resolves the payload"); + + // THE BLACK-SCREEN CASE. The old mount-side parser matched the literal + // uppercase "BINARY" and required a quoted filename, so a cue like the one + // above made ISOReader::Open() return false -- while the verify side + // (identify_disc) uppercased the line, found the payload, and happily + // reported "Disc verified". The launcher therefore showed a green badge and + // the runtime then booted with an EMPTY DRIVE: a black screen that went + // away if you picked the .bin instead. Both sides share one parser now, so + // pin that they agree: this cue must MOUNT, not just parse. + { + PS1::ISOReader r; + check(r.Open((dir / "lower.cue").string()), + "syntax: a cue the verify side accepts must also MOUNT (black-screen regression)"); + check(r.TrackCount() >= 1, "syntax: mounted cue exposes its data track"); + r.Close(); + } + + // A WAVE payload has no fixed sector geometry — refuse rather than guess. + write_text(dir / "wave.cue", + "FILE \"game.bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n" + "FILE \"track2.wav\" WAVE\n" + " TRACK 02 AUDIO\n" + " INDEX 01 00:00:00\n"); + const auto wave = PSXRecompV4::parse_cue_sheet(dir / "wave.cue"); + check(wave.has_non_binary_file(), "syntax: WAVE payload is flagged"); + check(!wave.usable(), "syntax: WAVE cue is not mountable"); + + PS1::ISOReader reader; + check(!reader.Open((dir / "wave.cue").string()), "syntax: ISOReader refuses a WAVE cue"); + + // ...and the resolver recovers to the sibling bin rather than dying. + const auto r = resolve_disc_path(dir / "wave.cue"); + check(same(r.mount, dir / "game.bin"), "syntax: WAVE cue falls back to the bin"); +} + +} // namespace + +int main() { + const fs::path root = fs::temp_directory_path() / "psxrecomp-disc-path-test"; + fs::remove_all(root); + fs::create_directories(root); + + test_single_file_dump(root); + test_multi_track_dump(root); + test_cue_with_missing_bin(root); + test_bare_image(root); + test_cue_syntax_tolerance(root); + + fs::remove_all(root); + std::printf("disc_path_resolve: %d checks passed\n", g_checks); + return 0; +} diff --git a/runtime/tests/test_fmv_quiet_guards.py b/runtime/tests/test_fmv_quiet_guards.py new file mode 100644 index 00000000..c5913cc6 --- /dev/null +++ b/runtime/tests/test_fmv_quiet_guards.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Structural guards for the high-frequency FMV trace fast paths.""" + +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + debug = (ROOT / "runtime/src/debug_server.c").read_text(encoding="utf-8") + mdec = (ROOT / "runtime/src/mdec.c").read_text(encoding="utf-8") + required = ( + (debug, "if (s_fmv_quiet) return;\n ls_suppress_begin();", + "FMV quiet mode does not gate function-entry trace work"), + (mdec, "#ifdef PSX_NO_DEBUG_TOOLS\n (void)kind;", + "release MDEC events still write the diagnostic ring"), + (mdec, "if (debug_server_fmv_quiet()) return;", + "FMV quiet mode does not gate MDEC trace events"), + ) + failures = [message for source, needle, message in required if needle not in source] + if failures: + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + return 1 + print("PASS: FMV quiet mode gates high-frequency diagnostic traces.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/test_fps_telemetry_opt_in.py b/runtime/tests/test_fps_telemetry_opt_in.py new file mode 100644 index 00000000..a38562da --- /dev/null +++ b/runtime/tests/test_fps_telemetry_opt_in.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Structural guard: FPS title/stderr telemetry stays explicitly opt-in.""" + +from pathlib import Path + + +main_cpp = (Path(__file__).parents[1] / "src" / "main.cpp").read_text( + encoding="utf-8" +) + +required = ( + 'std::getenv("PSX_FPS_TELEMETRY")', + "fps_telemetry_enabled() && !psx_netplay_in_load_barrier()", + "sdl_window && s_fps_base_title.empty()", +) +for needle in required: + if needle not in main_cpp: + raise AssertionError(f"missing FPS telemetry opt-in guard: {needle}") + +print("PASS: FPS telemetry remains opt-in and title-stable") diff --git a/runtime/tests/test_frame_interpolation_display_rate.py b/runtime/tests/test_frame_interpolation_display_rate.py new file mode 100644 index 00000000..59e42b7b --- /dev/null +++ b/runtime/tests/test_frame_interpolation_display_rate.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Guard the mod API's zero sentinel: follow the display, never busy-loop.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") +HEADER = (ROOT / "runtime" / "include" / "mod_plugins.h").read_text( + encoding="utf-8" +) + +EXPECTED_ASSIGNMENT = """g_frame_interpolation_fps = + frames_per_second ? (int)frames_per_second : 0;""" + +assert EXPECTED_ASSIGNMENT in MAIN, ( + "psx_mod_set_frame_interpolation(0) must preserve the 0 sentinel so " + "gl_renderer_set_interpolation resolves it to the measured host refresh" +) +assert "frames_per_second ? (int)frames_per_second : -1" not in MAIN, ( + "the -1 sentinel selects the uncapped interpolation busy loop" +) +assert "zero follows the measured host-display refresh rate" in HEADER, ( + "the public plugin contract must document the display-rate sentinel" +) + +print("frame interpolation display-rate sentinel guard passed") diff --git a/runtime/tests/test_hybrid_dev_any_input.py b/runtime/tests/test_hybrid_dev_any_input.py new file mode 100644 index 00000000..83ea6ac6 --- /dev/null +++ b/runtime/tests/test_hybrid_dev_any_input.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Guard Hybrid mode's physical-controller detection in dev-any routing.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") + +assert "hybrid_stick_active(const PlayerInput& p, bool dev_any)" in MAIN +assert "hybrid_dpad_active(const PlayerInput& p, int player, bool dev_any)" in MAIN +assert "hybrid_stick_active(p, dev_here)" in MAIN +assert "hybrid_dpad_active(p, player, dev_here)" in MAIN + +stick_body = MAIN.split( + "static bool hybrid_stick_active(const PlayerInput& p, bool dev_any)", 1 +)[1].split("static bool hybrid_dpad_active", 1)[0] +dpad_body = MAIN.split( + "static bool hybrid_dpad_active(const PlayerInput& p, int player, bool dev_any)", + 1, +)[1].split("/* Sample each player's live device state", 1)[0] + +for name, body, detector in ( + ("stick", stick_body, "controller_stick_active(handle)"), + ("D-pad", dpad_body, "controller_dpad_active(handle)"), +): + assert "SDL_NumJoysticks()" in body, ( + f"Hybrid {name} detection must inspect all dev-any controllers" + ) + assert "SDL_GameControllerFromInstanceID" in body + assert detector in body + +print("Hybrid dev-any physical-controller guard passed") diff --git a/runtime/tests/test_launcher_device_roundtrip.cpp b/runtime/tests/test_launcher_device_roundtrip.cpp new file mode 100644 index 00000000..2f1657c8 --- /dev/null +++ b/runtime/tests/test_launcher_device_roundtrip.cpp @@ -0,0 +1,24 @@ +#include "launcher_device.h" + +#include <cassert> + +int main() { + using PSXRecompV4::launcher_device_from_source; + using PSXRecompV4::launcher_source_from_device; + + assert(launcher_source_from_device("") == 0); + assert(launcher_source_from_device(" none ") == 0); + assert(launcher_source_from_device("Keyboard") == 1); + assert(launcher_source_from_device("gamepad") == 2); + assert(launcher_source_from_device("030000005e0400008e02000000000000") == 2); + + assert(launcher_device_from_source(0, "keyboard") == "none"); + assert(launcher_device_from_source(1, "none") == "keyboard"); + assert(launcher_device_from_source(2, "keyboard") == "gamepad"); + assert(launcher_device_from_source(2, "none") == "gamepad"); + assert(launcher_device_from_source(2, " auto ") == "auto"); + assert(launcher_device_from_source( + 2, " 030000005e0400008e02000000000000 ") == + "030000005e0400008e02000000000000"); + return 0; +} diff --git a/runtime/tests/test_mod_controller_override.py b/runtime/tests/test_mod_controller_override.py new file mode 100644 index 00000000..9e71aacd --- /dev/null +++ b/runtime/tests/test_mod_controller_override.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Guard the game-owned controller-mode override lifecycle.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") +HEADER = (ROOT / "runtime" / "include" / "mod_plugins.h").read_text( + encoding="utf-8" +) + +for symbol in ( + "PSX_MOD_CONTROLLER_HYBRID", + "PSX_MOD_CONTROLLER_ANALOG", + "PSX_MOD_CONTROLLER_DIGITAL", + "psx_mod_set_controller_mode_override", +): + assert symbol in HEADER, f"missing trusted-plugin controller API: {symbol}" + +reset = """g_mod_controller_mode_override[0] = -1; + g_mod_controller_mode_override[1] = -1; + mod_runtime_activate_plugins();""" +apply = """if (g_mod_controller_mode_override[0] >= 0) + p1_mode = g_mod_controller_mode_override[0]; + if (g_mod_controller_mode_override[1] >= 0) + p2_mode = g_mod_controller_mode_override[1];""" + +assert reset in MAIN, "controller overrides must reset before every activation pass" +assert apply in MAIN, "controller overrides must replace the resolved launch modes" +assert MAIN.index(reset) < MAIN.index(apply), "reset/activate must precede application" + +print("mod controller override lifecycle guard passed") diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp new file mode 100644 index 00000000..04ff3909 --- /dev/null +++ b/runtime/tests/test_mod_packages.cpp @@ -0,0 +1,1281 @@ +#include "mod_packages.h" +#include "psx_sha256.h" + +#include <algorithm> +#include <filesystem> +#include <fstream> +#include <iostream> +#include <string> +#include <vector> +#include <cstdlib> + +namespace fs = std::filesystem; +using namespace PSXRecompV4; + +static int failures; + +static void check(bool value, const char* message) { + if (!value) { + std::cerr << "FAIL: " << message << "\n"; + failures++; + } +} + +static void write_text(const fs::path& path, const std::string& text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +static void write_bytes(const fs::path& path, const std::vector<uint8_t>& bytes) { + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write((const char*)bytes.data(), (std::streamsize)bytes.size()); +} + +static std::string sha256_hex(const std::vector<uint8_t>& bytes) { + uint8_t digest[32]; + psx_sha256_compute(bytes.data(), bytes.size(), digest); + static const char hex[] = "0123456789abcdef"; + std::string out(64, '0'); + for (size_t i = 0; i < 32; ++i) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 15]; + } + return out; +} + +static void write_deflated_package(const fs::path& path) { + static const char* compressed_hex = + "4bcb2fca4d2c892f4b2d2acecccf53b05530e4ca4c01524a5599057ab9f929" + "4a5c082925433d033d0325aebcc4dc541037ca3340c117a4a428b5383f07a80" + "e2498929a9c93589458925996aac4151d5d9258949e5a121bcb950ed4140f313" + "ad827345837c4353844890b00"; + std::vector<uint8_t> compressed; + for (const char* p = compressed_hex; *p; p += 2) + compressed.push_back((uint8_t)std::stoul(std::string(p, 2), nullptr, 16)); + std::vector<uint8_t> zip; + auto le16 = [&](uint16_t v) { + zip.push_back((uint8_t)v); zip.push_back((uint8_t)(v >> 8)); + }; + auto le32 = [&](uint32_t v) { + le16((uint16_t)v); le16((uint16_t)(v >> 16)); + }; + const std::string name = "manifest.toml"; + le32(0x04034b50); le16(20); le16(0); le16(8); le16(0); le16(0); + le32(0x7d8454e1); le32((uint32_t)compressed.size()); le32(127); + le16((uint16_t)name.size()); le16(0); + zip.insert(zip.end(), name.begin(), name.end()); + zip.insert(zip.end(), compressed.begin(), compressed.end()); + const uint32_t central_offset = (uint32_t)zip.size(); + le32(0x02014b50); le16(20); le16(20); le16(0); le16(8); le16(0); le16(0); + le32(0x7d8454e1); le32((uint32_t)compressed.size()); le32(127); + le16((uint16_t)name.size()); le16(0); le16(0); le16(0); le16(0); + le32(0); le32(0); + zip.insert(zip.end(), name.begin(), name.end()); + const uint32_t central_size = (uint32_t)zip.size() - central_offset; + le32(0x06054b50); le16(0); le16(0); le16(1); le16(1); + le32(central_size); le32(central_offset); le16(0); + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write((const char*)zip.data(), (std::streamsize)zip.size()); +} + +static std::string manifest(const std::string& id, const std::string& version, + const std::string& extra = {}) { + return + "format_version = 1\n" + "id = \"" + id + "\"\n" + "version = \"" + version + "\"\n" + "name = \"" + id + "\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + extra; +} + +int main() { + const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-package-test"; + std::error_code ec; + fs::remove_all(root, ec); + + { + const uint8_t abc[] = {'a', 'b', 'c'}; + uint8_t one_shot[32], streamed[32]; + psx_sha256_compute(abc, sizeof(abc), one_shot); + psx_sha256_ctx hash; + psx_sha256_init(&hash); + psx_sha256_update(&hash, abc, 1); + psx_sha256_update(&hash, abc + 1, 2); + psx_sha256_final(&hash, streamed); + check(std::equal(one_shot, one_shot + 32, streamed), + "streaming SHA-256 must match one-shot hashing"); + } + + write_text(root / "packages/base.mod/1.0.0/manifest.toml", + manifest("base.mod", "1.0.0", + "\n[[option]]\n" + "id = \"difficulty\"\n" + "label = \"Difficulty\"\n" + "type = \"choice\"\n" + "default = \"normal\"\n" + "[[option.choice]]\nvalue = \"normal\"\nlabel = \"Normal\"\n" + "[[option.choice]]\nvalue = \"hard\"\nlabel = \"Hard\"\n" + "[[patch]]\n" + "target = \"main_exe\"\n" + "address = 2147487744\n" + "expected = \"01 02 03 04\"\n" + "replace = \"05 06 07 08\"\n" + "when_option = \"difficulty\"\n" + "when_value = \"hard\"\n" + "[[derived_disc]]\n" + "kind = \"vcdiff\"\n" + "patch = \"assets/base.xdelta3\"\n" + "patch_sha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n" + "output_size = 123456\n" + "output_sha256 = \"1111111111111111111111111111111111111111111111111111111111111111\"\n" + "when_option = \"difficulty\"\n" + "when_value = \"hard\"\n")); + write_text(root / "packages/base.mod/1.0.0/assets/base.xdelta3", "test"); + write_text(root / "packages/addon.mod/2.0.0/manifest.toml", + manifest("addon.mod", "2.0.0", + "\n[[dependency]]\nid = \"base.mod\"\nversion = \"^1.0.0\"\n")); + + ModPackageManager manager(root); + std::string error; + check(manager.scan(&error), error.c_str()); + write_deflated_package(root / "zip.psxmod"); + check(manager.install_archive(root / "zip.psxmod", nullptr, nullptr, &error), + error.c_str()); + check(manager.packages().count("zip.mod") == 1, + "deflated .psxmod must install"); + if (const char* external = std::getenv("PSXMOD_TEST_ARCHIVE"); + external && external[0]) { + std::string installed_id, installed_version; + check(manager.install_archive(external, &installed_id, &installed_version, + &error), + error.c_str()); + check(!installed_id.empty() && !installed_version.empty(), + "external package must report installed identity"); + } + check(manager.load_state(&error), error.c_str()); + check(manager.set_enabled("addon.mod", true, &error), error.c_str()); + ModResolution missing = manager.resolve("SLUS-TEST"); + check(!missing.ok, "missing dependency must fail resolution"); + check(manager.set_enabled("base.mod", true, &error), error.c_str()); + check(manager.set_option("base.mod", "difficulty", "hard", &error), error.c_str()); + check(!manager.set_option("base.mod", "difficulty", "impossible", &error), + "invalid choice must be rejected"); + + ModResolution resolved = manager.resolve("SLUS-TEST"); + check(resolved.ok, "valid dependency graph must resolve"); + check(resolved.ordered.size() == 2, "two packages should resolve"); + check(resolved.ordered.size() == 2 && resolved.ordered[0]->id == "base.mod", + "dependency must precede dependent"); + check(resolved.writes.size() == 1, "selected declarative patch must resolve"); + check(resolved.writes.size() == 1 && + resolved.writes[0].location == 0x80001000ull && + resolved.writes[0].replacement[0] == 5, + "resolved write must retain guest address and bytes"); + check(resolved.derived_discs.size() == 1 && + resolved.derived_discs[0].output_size == 123456, + "selected derived-disc recipe must resolve"); + check(resolved.fingerprint.size() == 64, "plan fingerprint must be SHA-256 hex"); + const std::string fingerprint = resolved.fingerprint; + + check(manager.save_state(&error), error.c_str()); + ModPackageManager reload(root); + check(reload.scan(&error), error.c_str()); + check(reload.load_state(&error), error.c_str()); + check(reload.resolve("SLUS-TEST").fingerprint == fingerprint, + "saved state must resolve deterministically"); + check(!reload.remove_version("base.mod", "1.0.0", &error), + "active package cannot be removed"); + check(reload.set_enabled("base.mod", false, &error), error.c_str()); + check(!reload.remove_version("base.mod", "1.0.0", &error), + "enabled dependent must protect required version"); + check(reload.set_enabled("addon.mod", false, &error), error.c_str()); + check(reload.remove_version("base.mod", "1.0.0", &error), error.c_str()); + + write_text(root / "packages/conflict.a/1.0.0/manifest.toml", + "format_version = 1\n" + "id = \"conflict.a\"\n" + "version = \"1.0.0\"\n" + "name = \"conflict.a\"\n" + "resolver = \"declarative\"\n" + "conflicts = [\"conflict.b\"]\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n"); + write_text(root / "packages/conflict.b/1.0.0/manifest.toml", + manifest("conflict.b", "1.0.0")); + check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("conflict.a", true, &error), error.c_str()); + check(reload.set_enabled("conflict.b", true, &error), error.c_str()); + check(reload.selections().at("conflict.a").enabled && + reload.selections().at("conflict.b").enabled, + "enabling a package must not silently disable another package"); + check(!reload.resolve("SLUS-TEST").ok, + "declared conflicts must fail resolution"); + + write_text(root / "packages/matrix.mod/1.0.0/manifest.toml", + manifest("matrix.mod", "1.0.0", + "\n[[option]]\n" + "id = \"title\"\n" + "label = \"Title\"\n" + "type = \"choice\"\n" + "default = \"mega\"\n" + "[[option.choice]]\n" + "value = \"mega\"\n" + "label = \"Mega\"\n" + "[[option.choice]]\n" + "value = \"rockman\"\n" + "label = \"Rockman\"\n" + "\n[[option]]\n" + "id = \"script\"\n" + "label = \"Script\"\n" + "type = \"choice\"\n" + "default = \"original\"\n" + "[[option.choice]]\n" + "value = \"original\"\n" + "label = \"Original\"\n" + "[[option.choice]]\n" + "value = \"retranslation\"\n" + "label = \"Retranslation\"\n" + "\n[[derived_disc]]\n" + "kind = \"vcdiff\"\n" + "patch = \"assets/matrix.xdelta3\"\n" + "patch_sha256 = \"2222222222222222222222222222222222222222222222222222222222222222\"\n" + "output_size = 222222\n" + "output_sha256 = \"3333333333333333333333333333333333333333333333333333333333333333\"\n" + "when = { title = \"rockman\", script = \"retranslation\" }\n")); + write_text(root / "packages/matrix.mod/1.0.0/assets/matrix.xdelta3", "test"); + check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("conflict.a", false, &error), error.c_str()); + check(reload.set_enabled("conflict.b", false, &error), error.c_str()); + check(reload.set_enabled("matrix.mod", true, &error), error.c_str()); + check(reload.set_option("matrix.mod", "title", "rockman", &error), error.c_str()); + check(reload.set_option("matrix.mod", "script", "retranslation", &error), error.c_str()); + ModResolution matrix = reload.resolve("SLUS-TEST"); + check(matrix.ok && matrix.derived_discs.size() == 1 && + matrix.derived_discs[0].output_size == 222222, + "multi-option derived-disc condition must match selected values"); + + mod_clear_builtin_resolvers_for_tests(); + bool resolver_context_seen = false; + check(mod_register_builtin_resolver( + "context-test", + [&](const ModPackage& package, const ModSelection& selection, + const ModBuiltinResolverContext& context, + std::vector<ModResolution::Write>& writes, + std::vector<std::string>& errors) { + (void)writes; + (void)errors; + resolver_context_seen = + package.id == "context.consumer" && + selection.enabled && + context.active_packages && + context.selections && + context.active_packages->count("context.provider") == 1 && + context.active_packages->count("context.consumer") == 1 && + context.selections->at("context.provider").enabled && + context.selections->at("context.consumer").enabled; + return resolver_context_seen; + }), + "test resolver must register"); + write_text(root / "packages/context.provider/1.0.0/manifest.toml", + manifest("context.provider", "1.0.0")); + write_text(root / "packages/context.consumer/1.0.0/manifest.toml", + "format_version = 1\n" + "id = \"context.consumer\"\n" + "version = \"1.0.0\"\n" + "name = \"context.consumer\"\n" + "resolver = \"builtin:context-test\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n"); + check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("matrix.mod", false, &error), error.c_str()); + check(reload.set_enabled("context.provider", true, &error), error.c_str()); + check(reload.set_enabled("context.consumer", true, &error), error.c_str()); + ModResolution context_resolution = reload.resolve("SLUS-TEST"); + check(context_resolution.ok && resolver_context_seen, + "built-in resolver must receive active package selection context"); + check(reload.set_enabled("context.consumer", false, &error), error.c_str()); + check(reload.set_enabled("context.provider", false, &error), error.c_str()); + mod_clear_builtin_resolvers_for_tests(); + + mod_clear_plugins_for_tests(); + check(mod_register_vblank_plugin( + "test.vblank", +[]() {}), + "test plugin must register"); + write_text(root / "packages/plugin.mod/1.0.0/manifest.toml", + "format_version = 5\n" + "id = \"plugin.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Plugin Mod\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"vblank\"\n" + "name = \"VBlank Plugin\"\n" + "[[plugin]]\n" + "feature = \"vblank\"\n" + "id = \"test.vblank\"\n"); + check(reload.scan(&error), error.c_str()); + check(reload.set_feature_enabled( + "plugin.mod", "vblank", true, &error), error.c_str()); + ModResolution plugin_resolution = reload.resolve("SLUS-TEST"); + check(plugin_resolution.ok && plugin_resolution.plugins.size() == 1 && + plugin_resolution.plugins[0].id == "test.vblank" && + plugin_resolution.plugins[0].package_id == "plugin.mod" && + plugin_resolution.plugins[0].feature_id == "vblank", + "enabled trusted plugin must resolve with feature ownership"); + const std::string plugin_fingerprint = plugin_resolution.fingerprint; + check(reload.set_feature_enabled( + "plugin.mod", "vblank", false, &error), error.c_str()); + ModResolution plugin_disabled = reload.resolve("SLUS-TEST"); + check(plugin_disabled.ok && plugin_disabled.plugins.empty() && + plugin_disabled.fingerprint != plugin_fingerprint, + "disabling a plugin feature must remove its activation and change " + "the fingerprint"); + check(reload.set_feature_enabled( + "plugin.mod", "vblank", true, &error), error.c_str()); + mod_clear_plugins_for_tests(); + ModResolution plugin_unavailable = reload.resolve("SLUS-TEST"); + check(!plugin_unavailable.ok && + std::any_of( + plugin_unavailable.errors.begin(), + plugin_unavailable.errors.end(), + [](const std::string& item) { + return item.find( + "trusted plugin is unavailable: test.vblank") != + std::string::npos; + }), + "enabled plugin must fail closed when its trusted implementation " + "is unavailable"); + check(reload.set_feature_enabled( + "plugin.mod", "vblank", false, &error), error.c_str()); + write_text(root / "packages/features.mod/1.0.0/manifest.toml", + manifest("features.mod", "1.0.0", + "\n[[feature]]\n" + "id = \"title-screen\"\n" + "name = \"Title Screen\"\n" + "group = \"Localization\"\n" + "\n[[feature]]\n" + "id = \"retranslation\"\n" + "name = \"Retranslation\"\n" + "group = \"Localization\"\n" + "\n[[feature]]\n" + "id = \"title-collision\"\n" + "name = \"Title Collision\"\n" + "\n[[feature]]\n" + "id = \"title-identical\"\n" + "name = \"Title Identical\"\n" + "\n[[feature]]\n" + "id = \"title-partial-compatible\"\n" + "name = \"Title Partial Compatible\"\n" + "\n[[feature]]\n" + "id = \"title-partial-conflict\"\n" + "name = \"Title Partial Conflict\"\n" + "\n[[option]]\n" + "feature = \"title-screen\"\n" + "id = \"variant\"\n" + "label = \"Variant\"\n" + "type = \"choice\"\n" + "default = \"usa\"\n" + "[[option.choice]]\n" + "value = \"usa\"\n" + "label = \"Mega Man X6\"\n" + "[[option.choice]]\n" + "value = \"japan\"\n" + "label = \"Rockman X6\"\n" + "\n[[option]]\n" + "feature = \"retranslation\"\n" + "id = \"variant\"\n" + "label = \"Variant\"\n" + "type = \"boolean\"\n" + "default = \"true\"\n" + "\n[[patch]]\n" + "feature = \"title-screen\"\n" + "target = \"main_exe\"\n" + "address = 2147495936\n" + "expected = \"0102\"\n" + "replace = \"a1a2\"\n" + "when = { variant = \"japan\" }\n" + "\n[[patch]]\n" + "feature = \"retranslation\"\n" + "target = \"disc_raw\"\n" + "offset = 23520\n" + "expected = \"03\"\n" + "replace = \"b3\"\n" + "when = { variant = \"true\" }\n" + "\n[[patch]]\n" + "feature = \"title-collision\"\n" + "target = \"main_exe\"\n" + "address = 2147495937\n" + "expected = \"02\"\n" + "replace = \"ff\"\n" + "\n[[patch]]\n" + "feature = \"title-identical\"\n" + "target = \"main_exe\"\n" + "address = 2147495936\n" + "expected = \"0102\"\n" + "replace = \"a1a2\"\n" + "\n[[patch]]\n" + "feature = \"title-partial-compatible\"\n" + "target = \"main_exe\"\n" + "address = 2147495937\n" + "expected = \"0209\"\n" + "replace = \"a2c9\"\n" + "\n[[patch]]\n" + "feature = \"title-partial-conflict\"\n" + "target = \"main_exe\"\n" + "address = 2147495937\n" + "expected = \"ff09\"\n" + "replace = \"a2c9\"\n")); + check(reload.scan(&error), error.c_str()); + check(!reload.set_enabled("features.mod", true, &error), + "feature-style package must not expose package enablement"); + check(reload.set_feature_option( + "features.mod", "title-screen", "variant", "japan", &error), + error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "title-screen", true, &error), error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "retranslation", true, &error), error.c_str()); + ModResolution features = reload.resolve("SLUS-TEST"); + check(features.ok && features.writes.size() == 2, + "independently enabled features must compose their operations"); + check(features.ok && features.writes[0].feature_id == "title-screen" && + features.writes[1].feature_id == "retranslation", + "resolved writes must retain feature ownership"); + check(reload.set_feature_enabled( + "features.mod", "title-collision", true, &error), error.c_str()); + ModResolution collision = reload.resolve("SLUS-TEST"); + check(!collision.ok && collision.diagnostics.size() == 1, + "overlapping feature writes must produce a structured diagnostic"); + check(!collision.diagnostics.empty() && + collision.diagnostics[0].feature_id == "title-collision" && + collision.diagnostics[0].other_feature_id == "title-screen" && + !collision.diagnostics[0].resource.empty(), + "collision diagnostic must identify both features and the resource"); + check(reload.set_feature_enabled( + "features.mod", "title-collision", false, &error), error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "title-identical", true, &error), error.c_str()); + ModResolution identical = reload.resolve("SLUS-TEST"); + check(identical.ok && identical.writes.size() == 2, + "truly identical writes must coalesce deterministically"); + check(reload.set_feature_enabled( + "features.mod", "title-partial-compatible", true, &error), + error.c_str()); + ModResolution partial_compatible = reload.resolve("SLUS-TEST"); + check(partial_compatible.ok && partial_compatible.writes.size() == 3, + "partially overlapping writes with matching expected and replacement " + "bytes must compose"); + check(reload.set_feature_enabled( + "features.mod", "title-partial-conflict", true, &error), + error.c_str()); + ModResolution partial_conflict = reload.resolve("SLUS-TEST"); + check(!partial_conflict.ok && !partial_conflict.diagnostics.empty() && + partial_conflict.diagnostics[0].resource == + "main_exe:0x80003001-0x80003002", + "one differing expected byte in a partial overlap must identify " + "the exact contested byte"); + check(reload.set_feature_enabled( + "features.mod", "title-partial-conflict", false, &error), + error.c_str()); + check(reload.save_state(&error), error.c_str()); + ModPackageManager feature_reload(root); + check(feature_reload.scan(&error), error.c_str()); + check(feature_reload.load_state(&error), error.c_str()); + check(feature_reload.feature_enabled("features.mod", "title-screen") && + feature_reload.feature_enabled("features.mod", "retranslation") && + !feature_reload.feature_enabled("features.mod", "title-collision"), + "per-feature enabled state must survive save/reload"); + check(feature_reload.feature_option_value( + "features.mod", "title-screen", "variant") == "japan", + "feature-scoped option values must survive save/reload"); + check(feature_reload.resolve("SLUS-TEST").fingerprint == + partial_compatible.fingerprint, + "feature state must resolve deterministically after reload"); + + write_text(root / "packages/parametric.mod/1.0.0/manifest.toml", + "format_version = 3\n" + "id = \"parametric.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Parametric\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"numeric\"\n" + "name = \"Numeric\"\n" + "[[feature]]\n" + "id = \"numeric-collision\"\n" + "name = \"Numeric Collision\"\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"byte\"\n" + "label = \"Byte\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 255\n" + "step = 1\n" + "default = 7\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"word\"\n" + "label = \"Word\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 65534\n" + "step = 1\n" + "default = 4660\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"dword\"\n" + "label = \"Dword\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 4294967295\n" + "step = 1\n" + "default = 305419896\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"split\"\n" + "label = \"Split\"\n" + "type = \"integer\"\n" + "min = 200000\n" + "max = 600000\n" + "step = 1\n" + "default = 425984\n" + "[[option]]\n" + "feature = \"numeric-collision\"\n" + "id = \"byte\"\n" + "label = \"Byte\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 255\n" + "default = 8\n" + "[[constraint]]\n" + "feature = \"numeric\"\n" + "kind = \"ordered_integer\"\n" + "direction = \"nondecreasing\"\n" + "options = [\"byte\", \"word\", \"dword\"]\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500032\n" + "expected = \"00\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500033\n" + "expected = \"07\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500034\n" + "expected = \"0000\"\n" + "replace_from = { option = \"word\", encoding = \"u16le\", addend = 1 }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500036\n" + "expected = \"00000000\"\n" + "replace_from = { option = \"dword\", encoding = \"u32le\" }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500040\n" + "expected = \"0000aabb\"\n" + "replace_from = { option = \"word\", encoding = \"u16le\", offset = 0 }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500048\n" + "expected = \"0600013c00802134\"\n" + "replace_from = { option = \"split\", encoding = \"mips_lui_ori_u32\", omit_when_default = true }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500056\n" + "expected = \"0400013c00202134\"\n" + "replace_from = { option = \"split\", encoding = \"mips_lui_ori_u32\", omit_when_default = true }\n" + "[[patch]]\n" + "feature = \"numeric-collision\"\n" + "target = \"main_exe\"\n" + "address = 2147500032\n" + "expected = \"00\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n"); + check(feature_reload.scan(&error), error.c_str()); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric", true, &error), error.c_str()); + check(!feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "+7", &error), + "integer options must reject a leading plus"); + check(!feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "07", &error), + "integer options must reject noncanonical leading zeroes"); + ModResolution parametric = feature_reload.resolve("SLUS-TEST"); + const auto numeric_write = [&](uint64_t location) + -> const ModResolution::Write* { + const auto found = std::find_if( + parametric.writes.begin(), parametric.writes.end(), + [&](const ModResolution::Write& write) { + return write.package_id == "parametric.mod" && + write.location == location; + }); + return found == parametric.writes.end() ? nullptr : &*found; + }; + const ModResolution::Write* byte_write = numeric_write(0x80004000ull); + const ModResolution::Write* noop_write = numeric_write(0x80004001ull); + const ModResolution::Write* word_write = numeric_write(0x80004002ull); + const ModResolution::Write* dword_write = numeric_write(0x80004004ull); + const ModResolution::Write* guarded_word_write = + numeric_write(0x80004008ull); + check(parametric.ok && byte_write && + byte_write->replacement == std::vector<uint8_t>({7}), + "u8 replace_from must encode the selected value"); + check(!noop_write, + "replace_from equal to the stock guard must elide the no-op write"); + check(word_write && + word_write->replacement == std::vector<uint8_t>({0x35, 0x12}), + "u16le replace_from must apply addend and encode little-endian"); + check(dword_write && + dword_write->replacement == + std::vector<uint8_t>({0x78, 0x56, 0x34, 0x12}), + "u32le replace_from must encode little-endian"); + check(guarded_word_write && + guarded_word_write->replacement == + std::vector<uint8_t>({0x34, 0x12, 0xaa, 0xbb}), + "replace_from must preserve guarded bytes outside its value field"); + check(!numeric_write(0x80004010ull) && + !numeric_write(0x80004018ull), + "omit_when_default must suppress every split-immediate site"); + const std::string parametric_fingerprint = parametric.fingerprint; + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "9", &error), + error.c_str()); + check(!feature_reload.set_feature_option( + "parametric.mod", "numeric", "word", "5", &error), + "enabled ordered integer features must reject inverted values"); + ModResolution changed_parametric = feature_reload.resolve("SLUS-TEST"); + check(changed_parametric.ok && + changed_parametric.fingerprint != parametric_fingerprint, + "changing a generated integer must change the plan fingerprint"); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "split", "200000", &error), + error.c_str()); + ModResolution split_parametric = feature_reload.resolve("SLUS-TEST"); + const auto split_write = [&](uint64_t location) + -> const ModResolution::Write* { + const auto found = std::find_if( + split_parametric.writes.begin(), split_parametric.writes.end(), + [&](const ModResolution::Write& write) { + return write.package_id == "parametric.mod" && + write.location == location; + }); + return found == split_parametric.writes.end() ? nullptr : &*found; + }; + check(split_write(0x80004010ull) && + split_write(0x80004010ull)->replacement == + std::vector<uint8_t>({ + 0x03, 0x00, 0x01, 0x3c, + 0x40, 0x0d, 0x21, 0x34}) && + split_write(0x80004018ull) && + split_write(0x80004018ull)->replacement == + std::vector<uint8_t>({ + 0x03, 0x00, 0x01, 0x3c, + 0x40, 0x0d, 0x21, 0x34}), + "typed MIPS split encodings must update every guarded pair"); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "split", "270336", &error), + error.c_str()); + ModResolution partial_stock_split = + feature_reload.resolve("SLUS-TEST"); + check(std::count_if( + partial_stock_split.writes.begin(), + partial_stock_split.writes.end(), + [](const ModResolution::Write& write) { + return write.package_id == "parametric.mod" && + (write.location == 0x80004010ull || + write.location == 0x80004018ull); + }) == 2, + "a nondefault split value must retain ownership of a pair whose " + "replacement happens to equal stock"); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "split", "425984", &error), + error.c_str()); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric-collision", true, &error), + error.c_str()); + check(!feature_reload.resolve("SLUS-TEST").ok, + "different generated values at one guarded byte must collide"); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric-collision", false, &error), + error.c_str()); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric", false, &error), error.c_str()); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "word", "0", &error), + "disabled features may retain an invalid draft"); + check(!feature_reload.set_feature_enabled( + "parametric.mod", "numeric", true, &error), + "an invalid ordered integer draft must block feature enablement"); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "word", "4660", &error), + error.c_str()); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric", true, &error), error.c_str()); + check(feature_reload.save_state(&error), error.c_str()); + ModPackageManager parametric_reload(root); + check(parametric_reload.scan(&error), error.c_str()); + check(parametric_reload.load_state(&error), error.c_str()); + check(parametric_reload.feature_option_value( + "parametric.mod", "numeric", "byte") == "9" && + parametric_reload.resolve("SLUS-TEST").fingerprint == + changed_parametric.fingerprint, + "generated integer state and fingerprint must survive reload"); + + write_text(root / "packages/requires.mod/1.0.0/manifest.toml", + "format_version = 4\n" + "id = \"requires.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Requires\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"prereq\"\n" + "name = \"Prerequisite\"\n" + "[[feature]]\n" + "id = \"dependent\"\n" + "name = \"Dependent\"\n" + "[[feature]]\n" + "id = \"optioned-dependent\"\n" + "name = \"Optioned Dependent\"\n" + "[[option]]\n" + "feature = \"prereq\"\n" + "id = \"availability\"\n" + "label = \"Available in\"\n" + "type = \"choice\"\n" + "default = \"main\"\n" + "[[option.choice]]\n" + "value = \"main\"\n" + "label = \"Main Stages\"\n" + "[[option.choice]]\n" + "value = \"everywhere\"\n" + "label = \"Everywhere\"\n" + "[[constraint]]\n" + "feature = \"dependent\"\n" + "kind = \"requires_feature\"\n" + "requires_feature = \"prereq\"\n" + "[[constraint]]\n" + "feature = \"optioned-dependent\"\n" + "kind = \"requires_feature\"\n" + "requires_feature = \"prereq\"\n" + "requires_option = \"availability\"\n" + "requires_value = \"everywhere\"\n"); + check(parametric_reload.scan(&error), error.c_str()); + check(parametric_reload.set_feature_enabled( + "requires.mod", "dependent", true, &error), error.c_str()); + check(parametric_reload.feature_enabled("requires.mod", "prereq") && + parametric_reload.feature_enabled("requires.mod", "dependent"), + "enabling a dependent feature must auto-enable its prerequisite"); + check(parametric_reload.set_feature_enabled( + "requires.mod", "optioned-dependent", true, &error), + error.c_str()); + check(parametric_reload.feature_enabled( + "requires.mod", "optioned-dependent") && + parametric_reload.feature_option_value( + "requires.mod", "prereq", "availability") == "everywhere", + "enabling an optioned dependent must auto-select the required " + "prerequisite value"); + check(parametric_reload.set_feature_option( + "requires.mod", "prereq", "availability", "main", &error), + error.c_str()); + check(parametric_reload.feature_enabled("requires.mod", "prereq") && + parametric_reload.feature_enabled("requires.mod", "dependent") && + !parametric_reload.feature_enabled( + "requires.mod", "optioned-dependent"), + "weakening a prerequisite option must disable invalid dependents"); + check(parametric_reload.set_feature_enabled( + "requires.mod", "prereq", false, &error), error.c_str()); + check(!parametric_reload.feature_enabled("requires.mod", "prereq") && + !parametric_reload.feature_enabled("requires.mod", "dependent"), + "disabling a prerequisite must disable downstream dependents"); + + const auto reject_parametric_manifest = + [&](const std::string& name, const std::string& body) { + const fs::path path = root / (name + ".toml"); + write_text(path, body); + ModPackage rejected; + return !ModPackageManager::read_manifest(path, rejected, &error); + }; + const std::string dynamic_prelude = + "id=\"bad.dynamic\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"value\"\nlabel=\"Value\"\n" + "type=\"integer\"\nmin=0\nmax=255\ndefault=1\n"; + check(reject_parametric_manifest( + "dynamic-v1", + "format_version=1\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\"}\n"), + "format 1 manifests must reject replace_from"); + check(reject_parametric_manifest( + "dynamic-both", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\nreplace=\"01\"\n" + "replace_from={option=\"value\",encoding=\"u8\"}\n"), + "a patch must reject simultaneous replace and replace_from"); + check(reject_parametric_manifest( + "dynamic-width", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"0000\"\n" + "replace_from={option=\"value\",encoding=\"u8\",offset=2}\n"), + "replace_from value must stay inside the expected guard"); + check(reject_parametric_manifest( + "dynamic-overflow", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\",addend=1}\n"), + "the full option range plus addend must fit its encoding"); + check(reject_parametric_manifest( + "dynamic-unknown", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\",shift=1}\n"), + "replace_from must reject unknown transform fields"); + check(reject_parametric_manifest( + "dynamic-mips-v2", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"0600013c00802134\"\n" + "replace_from={option=\"value\"," + "encoding=\"mips_lui_ori_u32\"}\n"), + "typed MIPS pairs must require package format 3"); + check(reject_parametric_manifest( + "dynamic-mips-unlinked", + "format_version=3\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"0600013c00802234\"\n" + "replace_from={option=\"value\"," + "encoding=\"mips_lui_ori_u32\"}\n"), + "typed MIPS pairs must reject unlinked registers"); + check(reject_parametric_manifest( + "constraint-inverted-default", + "format_version=3\n" + "id=\"bad.dynamic\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"low\"\nlabel=\"Low\"\n" + "type=\"integer\"\nmin=0\nmax=10\ndefault=8\n" + "[[option]]\nfeature=\"bad\"\nid=\"high\"\nlabel=\"High\"\n" + "type=\"integer\"\nmin=0\nmax=10\ndefault=2\n" + "[[constraint]]\nfeature=\"bad\"\n" + "kind=\"ordered_integer\"\ndirection=\"nondecreasing\"\n" + "options=[\"low\",\"high\"]\n"), + "ordered integer defaults must satisfy their constraint"); + check(reject_parametric_manifest( + "dynamic-step-default", + "format_version=2\n" + "id=\"bad.dynamic\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"value\"\nlabel=\"Value\"\n" + "type=\"integer\"\nmin=0\nmax=10\nstep=2\ndefault=3\n"), + "integer defaults must align to their declared step"); + + write_text(root / "packages/sparse.mod/1.0.0/manifest.toml", + "format_version = 4\n" + "id = \"sparse.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Sparse Fields\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"timing\"\n" + "name = \"Timing\"\n" + "[[feature]]\n" + "id = \"cancellable\"\n" + "name = \"Cancellable\"\n" + "[[feature]]\n" + "id = \"collision\"\n" + "name = \"Collision\"\n" + "[[feature]]\n" + "id = \"guard-mismatch\"\n" + "name = \"Guard Mismatch\"\n" + "[[feature]]\n" + "id = \"predicates\"\n" + "name = \"Predicates\"\n" + "[[option]]\n" + "feature = \"timing\"\n" + "id = \"frames\"\n" + "label = \"Frames\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 99\n" + "default = 2\n" + "[[option]]\n" + "feature = \"predicates\"\n" + "id = \"value\"\n" + "label = \"Value\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 10\n" + "default = 5\n" + "[[patch]]\n" + "feature = \"timing\"\n" + "target = \"main_exe\"\n" + "address = 2147508224\n" + "expected = \"02000132\"\n" + "fields = [{ offset = 0, option = \"frames\", encoding = \"u8\" }]\n" + "when_integer = { option = \"frames\", op = \"gt\", value = 0 }\n" + "[[patch]]\n" + "feature = \"timing\"\n" + "target = \"main_exe\"\n" + "address = 2147508224\n" + "expected = \"02000132\"\n" + "fields = [{ offset = 0, replace = \"01\" }, " + "{ offset = 2, replace = \"00\" }]\n" + "when_integer = { option = \"frames\", op = \"eq\", value = 0 }\n" + "[[patch]]\n" + "feature = \"cancellable\"\n" + "target = \"main_exe\"\n" + "address = 2147508224\n" + "expected = \"02000132\"\n" + "fields = [{ offset = 1, replace = \"42\" }]\n" + "[[patch]]\n" + "feature = \"collision\"\n" + "target = \"main_exe\"\n" + "address = 2147508224\n" + "expected = \"02000132\"\n" + "fields = [{ offset = 0, replace = \"09\" }]\n" + "[[patch]]\n" + "feature = \"guard-mismatch\"\n" + "target = \"main_exe\"\n" + "address = 2147508224\n" + "expected = \"03000132\"\n" + "fields = [{ offset = 3, replace = \"33\" }]\n" + "[[patch]]\n" + "feature = \"predicates\"\n" + "target = \"main_exe\"\n" + "address = 2147508480\n" + "expected = \"00\"\n" + "fields = [{ replace = \"01\" }]\n" + "when_integer = { option = \"value\", op = \"eq\", value = 5 }\n" + "[[patch]]\n" + "feature = \"predicates\"\n" + "target = \"main_exe\"\n" + "address = 2147508481\n" + "expected = \"00\"\n" + "fields = [{ replace = \"01\" }]\n" + "when_integer = { option = \"value\", op = \"ne\", value = 5 }\n" + "[[patch]]\n" + "feature = \"predicates\"\n" + "target = \"main_exe\"\n" + "address = 2147508482\n" + "expected = \"00\"\n" + "fields = [{ replace = \"01\" }]\n" + "when_integer = { option = \"value\", op = \"lt\", value = 6 }\n" + "[[patch]]\n" + "feature = \"predicates\"\n" + "target = \"main_exe\"\n" + "address = 2147508483\n" + "expected = \"00\"\n" + "fields = [{ replace = \"01\" }]\n" + "when_integer = { option = \"value\", op = \"le\", value = 5 }\n" + "[[patch]]\n" + "feature = \"predicates\"\n" + "target = \"main_exe\"\n" + "address = 2147508484\n" + "expected = \"00\"\n" + "fields = [{ replace = \"01\" }]\n" + "when_integer = { option = \"value\", op = \"gt\", value = 4 }\n" + "[[patch]]\n" + "feature = \"predicates\"\n" + "target = \"main_exe\"\n" + "address = 2147508485\n" + "expected = \"00\"\n" + "fields = [{ replace = \"01\" }]\n" + "when_integer = { option = \"value\", op = \"ge\", value = 5 }\n"); + check(feature_reload.scan(&error), error.c_str()); + check(feature_reload.set_feature_enabled( + "sparse.mod", "timing", true, &error), error.c_str()); + check(feature_reload.set_feature_enabled( + "sparse.mod", "cancellable", true, &error), error.c_str()); + check(feature_reload.set_feature_enabled( + "sparse.mod", "predicates", true, &error), error.c_str()); + check(feature_reload.set_feature_option( + "sparse.mod", "timing", "frames", "5", &error), + error.c_str()); + ModResolution sparse_positive = feature_reload.resolve("SLUS-TEST"); + const auto sparse_writes_at = [&](const ModResolution& plan, + uint64_t location) { + return std::count_if( + plan.writes.begin(), plan.writes.end(), + [&](const ModResolution::Write& write) { + return write.package_id == "sparse.mod" && + write.location == location; + }); + }; + check(sparse_positive.ok && + sparse_writes_at(sparse_positive, 0x80006000ull) == 2, + "adjacent sparse fields in one guarded record must compose"); + const auto timing_write = std::find_if( + sparse_positive.writes.begin(), sparse_positive.writes.end(), + [](const ModResolution::Write& write) { + return write.package_id == "sparse.mod" && + write.feature_id == "timing" && + write.location == 0x80006000ull; + }); + check(timing_write != sparse_positive.writes.end() && + timing_write->expected == + std::vector<uint8_t>({2, 0, 1, 0x32}) && + timing_write->replacement.empty() && + timing_write->fields.size() == 1 && + timing_write->fields[0].offset == 0 && + timing_write->fields[0].replacement == + std::vector<uint8_t>({5}), + "sparse resolution must retain the complete guard but own only " + "declared fields"); + check(std::count_if( + sparse_positive.writes.begin(), + sparse_positive.writes.end(), + [](const ModResolution::Write& write) { + return write.package_id == "sparse.mod" && + write.feature_id == "predicates"; + }) == 5, + "eq/ne/lt/le/gt/ge predicates must resolve with typed integer " + "semantics"); + const std::string sparse_positive_fingerprint = + sparse_positive.fingerprint; + check(feature_reload.set_feature_option( + "sparse.mod", "timing", "frames", "0", &error), + error.c_str()); + ModResolution sparse_zero = feature_reload.resolve("SLUS-TEST"); + const auto zero_timing = std::find_if( + sparse_zero.writes.begin(), sparse_zero.writes.end(), + [](const ModResolution::Write& write) { + return write.package_id == "sparse.mod" && + write.feature_id == "timing"; + }); + check(sparse_zero.ok && zero_timing != sparse_zero.writes.end() && + zero_timing->fields.size() == 2 && + zero_timing->fields[0].offset == 0 && + zero_timing->fields[0].replacement == + std::vector<uint8_t>({1}) && + zero_timing->fields[1].offset == 2 && + zero_timing->fields[1].replacement == + std::vector<uint8_t>({0}) && + sparse_zero.fingerprint != sparse_positive_fingerprint, + "zero and nonzero conditional sparse plans must own their exact " + "distinct fields and fingerprints"); + check(feature_reload.set_feature_option( + "sparse.mod", "timing", "frames", "2", &error), + error.c_str()); + ModResolution sparse_stock = feature_reload.resolve("SLUS-TEST"); + check(sparse_stock.ok && + sparse_writes_at(sparse_stock, 0x80006000ull) == 1, + "stock-equal sparse fields must elide only their own no-op while " + "an adjacent feature remains active"); + check(feature_reload.set_feature_option( + "sparse.mod", "timing", "frames", "5", &error), + error.c_str()); + check(feature_reload.set_feature_enabled( + "sparse.mod", "collision", true, &error), error.c_str()); + check(!feature_reload.resolve("SLUS-TEST").ok, + "different sparse replacements for one owned byte must collide"); + check(feature_reload.set_feature_enabled( + "sparse.mod", "collision", false, &error), error.c_str()); + check(feature_reload.set_feature_enabled( + "sparse.mod", "guard-mismatch", true, &error), error.c_str()); + check(!feature_reload.resolve("SLUS-TEST").ok, + "overlapping complete guards with incompatible expected bytes " + "must fail before runtime"); + check(feature_reload.set_feature_enabled( + "sparse.mod", "guard-mismatch", false, &error), + error.c_str()); + + const std::string sparse_prelude = + "id=\"bad.sparse\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"value\"\nlabel=\"Value\"\n" + "type=\"integer\"\nmin=0\nmax=10\nstep=2\ndefault=2\n"; + const std::string sparse_patch = + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00000000\"\n"; + check(reject_parametric_manifest( + "sparse-v3", + "format_version=3\n" + sparse_prelude + sparse_patch + + "fields=[{offset=0,replace=\"01\"}]\n"), + "sparse fields must require format 4"); + check(reject_parametric_manifest( + "sparse-empty", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[]\n"), + "sparse fields must not be empty"); + check(reject_parametric_manifest( + "sparse-both", + "format_version=4\n" + sparse_prelude + sparse_patch + + "replace=\"01000000\"\n" + "fields=[{offset=0,replace=\"01\"}]\n"), + "sparse fields must be mutually exclusive with full replace"); + check(reject_parametric_manifest( + "sparse-overlap", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[{offset=0,replace=\"0102\"}," + "{offset=1,replace=\"03\"}]\n"), + "sparse fields must reject overlapping owned ranges"); + check(reject_parametric_manifest( + "sparse-bounds", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[{offset=4,replace=\"01\"}]\n"), + "sparse fields must stay inside the complete guard"); + check(reject_parametric_manifest( + "sparse-mixed", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[{offset=0,replace=\"01\",option=\"value\"," + "encoding=\"u8\"}]\n"), + "one sparse field must not mix literal and dynamic forms"); + check(reject_parametric_manifest( + "sparse-overflow", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[{offset=0,option=\"value\",encoding=\"u8\"," + "addend=250}]\n"), + "sparse dynamic field ranges plus addends must fit encoding"); + check(reject_parametric_manifest( + "sparse-predicate-op", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[{offset=0,replace=\"01\"}]\n" + "when_integer={option=\"value\",op=\"between\",value=2}\n"), + "typed integer predicates must reject unknown operations"); + check(reject_parametric_manifest( + "sparse-predicate-feature", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[{offset=0,replace=\"01\"}]\n" + "when_integer={option=\"missing\",op=\"eq\",value=2}\n"), + "typed integer predicates must reference same-feature integers"); + check(reject_parametric_manifest( + "sparse-predicate-bounds", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[{offset=0,replace=\"01\"}]\n" + "when_integer={option=\"value\",op=\"gt\",value=11}\n"), + "typed integer predicate constants must stay in option bounds"); + check(reject_parametric_manifest( + "sparse-predicate-step", + "format_version=4\n" + sparse_prelude + sparse_patch + + "fields=[{offset=0,replace=\"01\"}]\n" + "when_integer={option=\"value\",op=\"eq\",value=3}\n"), + "typed equality predicates must use selectable values"); + + const std::vector<uint8_t> overlay_a = {1, 2, 3, 4}; + const std::vector<uint8_t> overlay_b = {8, 9}; + const std::vector<uint8_t> overlay_c = {3, 4, 7}; + const std::string overlay_disc_hash(64, '4'); + write_bytes(root / "packages/overlay.mod/1.0.0/assets/a.bin", overlay_a); + write_bytes(root / "packages/overlay.mod/1.0.0/assets/b.bin", overlay_b); + write_bytes(root / "packages/overlay.mod/1.0.0/assets/c.bin", overlay_c); + write_text(root / "packages/overlay.mod/1.0.0/manifest.toml", + manifest("overlay.mod", "1.0.0", + "disc_sha256 = \"" + overlay_disc_hash + "\"\n" + "[[feature]]\n" + "id = \"asset-a\"\n" + "name = \"Asset A\"\n" + "[[feature]]\n" + "id = \"asset-b\"\n" + "name = \"Asset B\"\n" + "[[feature]]\n" + "id = \"asset-c\"\n" + "name = \"Asset C\"\n" + "[[overlay]]\n" + "feature = \"asset-a\"\n" + "target = \"disc_raw\"\n" + "offset = 100\n" + "file = \"assets/a.bin\"\n" + "sha256 = \"" + sha256_hex(overlay_a) + "\"\n" + "[[overlay]]\n" + "feature = \"asset-b\"\n" + "target = \"disc_raw\"\n" + "offset = 102\n" + "file = \"assets/b.bin\"\n" + "sha256 = \"" + sha256_hex(overlay_b) + "\"\n" + "[[overlay]]\n" + "feature = \"asset-c\"\n" + "target = \"disc_raw\"\n" + "offset = 102\n" + "file = \"assets/c.bin\"\n" + "sha256 = \"" + sha256_hex(overlay_c) + "\"\n")); + check(feature_reload.scan(&error), error.c_str()); + const ModPackage* overlay_package = + feature_reload.selected_package("overlay.mod"); + check(overlay_package && overlay_package->overlays.size() == 3 && + overlay_package->overlays[0].size == overlay_a.size(), + "manifest scan must verify and retain overlay metadata"); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-a", true, &error), error.c_str()); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-c", true, &error), error.c_str()); + ModResolution overlay_compatible = + feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); + check(overlay_compatible.ok && overlay_compatible.overlays.size() == 2, + "partially overlapping file overlays with matching payload bytes " + "must compose"); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-b", true, &error), error.c_str()); + ModResolution overlay_collision = + feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); + check(!overlay_collision.ok && + overlay_collision.diagnostics.size() == 1 && + overlay_collision.diagnostics[0].feature_id == "asset-b" && + overlay_collision.diagnostics[0].other_feature_id == "asset-a", + "overlapping file overlays must identify both owning features"); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-b", false, &error), error.c_str()); + ModResolution one_overlay = + feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); + check(one_overlay.ok && one_overlay.overlays.size() == 2 && + one_overlay.overlays[0].payload == overlay_a && + one_overlay.overlays[1].payload == overlay_c, + "compatible enabled overlay payloads must remain in the plan"); + + write_text(root / "feature-derived.toml", + manifest("bad.derived", "1.0.0", + "\n[[feature]]\n" + "id = \"bad\"\n" + "name = \"Bad\"\n" + "[[derived_disc]]\n" + "patch = \"bad.xdelta3\"\n" + "patch_sha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n" + "output_size = 1\n" + "output_sha256 = \"1111111111111111111111111111111111111111111111111111111111111111\"\n")); + ModPackage feature_derived; + check(!ModPackageManager::read_manifest( + root / "feature-derived.toml", feature_derived, &error), + "feature-style packages must reject derived-disc operations"); + + ModPackage invalid; + write_text(root / "bad.toml", + "format_version=1\nid=\"../bad\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n"); + check(!ModPackageManager::read_manifest(root / "bad.toml", invalid, &error), + "unsafe package id must be rejected"); + + fs::remove_all(root, ec); + if (failures) { + std::cerr << failures << " mod package test(s) failed\n"; + return 1; + } + std::cout << "mod package tests passed\n"; + return 0; +} diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp new file mode 100644 index 00000000..377b1865 --- /dev/null +++ b/runtime/tests/test_mod_runtime.cpp @@ -0,0 +1,399 @@ +#include "mod_runtime.h" +#include "mod_packages.h" +#include "psx_sha256.h" + +#include <array> +#include <cstdint> +#include <filesystem> +#include <fstream> +#include <iostream> +#include <string> +#include <vector> + +namespace fs = std::filesystem; + +static std::array<uint8_t, 2 * 1024 * 1024> ram; +static int failures; +static int activation_calls; +static int plugin_calls; + +extern "C" uint8_t psx_read_byte(uint32_t address) { + return ram[address & 0x1fffffu]; +} + +extern "C" void psx_write_byte(uint32_t address, uint8_t value) { + ram[address & 0x1fffffu] = value; +} + +extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} +extern "C" int fntrace_is_game_started(void) { return 1; } + +static void test_vblank_plugin(void) { + plugin_calls++; +} + +static void test_activation_plugin(void) { + activation_calls++; +} + +static void check(bool value, const char* message) { + if (!value) { + std::cerr << "FAIL: " << message << "\n"; + failures++; + } +} + +static void write_text(const fs::path& path, const std::string& text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +static void write_bytes(const fs::path& path, const std::vector<uint8_t>& bytes) { + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write((const char*)bytes.data(), (std::streamsize)bytes.size()); +} + +static std::string sha256_hex(const std::vector<uint8_t>& bytes) { + uint8_t digest[32]; + psx_sha256_compute(bytes.data(), bytes.size(), digest); + static const char hex[] = "0123456789abcdef"; + std::string out(64, '0'); + for (size_t i = 0; i < 32; ++i) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 15]; + } + return out; +} + +int main() { + const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test"; + std::error_code ec; + fs::remove_all(root, ec); + const std::vector<uint8_t> stock(8 * 2352, 0); + std::vector<uint8_t> overlay(3000); + for (size_t i = 0; i < overlay.size(); ++i) + overlay[i] = (uint8_t)(i * 17u + 3u); + const fs::path stock_path = root / "stock.bin"; + write_bytes(stock_path, stock); + write_bytes(root / "audio.bin", std::vector<uint8_t>(2 * 2352, 0x5a)); + const fs::path cue_path = root / "stock.cue"; + write_text(cue_path, + "FILE \"stock.bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n" + "FILE \"audio.bin\" BINARY\n" + " TRACK 02 AUDIO\n" + " INDEX 01 00:00:00\n"); + write_bytes(root / "packages/runtime.test/1.0.0/assets/overlay.bin", + overlay); + write_text(root / "packages/runtime.test/1.0.0/manifest.toml", + "format_version = 5\n" + "id = \"runtime.test\"\n" + "version = \"1.0.0\"\n" + "name = \"Runtime Test\"\n" + "[[target]]\n" + "game_id = \"SLUS-RUNTIME\"\n" + "disc_sha256 = \"" + sha256_hex(stock) + "\"\n" + "[[feature]]\n" + "id = \"main-code\"\n" + "name = \"Main Code\"\n" + "[[feature]]\n" + "id = \"disc-byte\"\n" + "name = \"Disc Byte\"\n" + "[[feature]]\n" + "id = \"asset-overlay\"\n" + "name = \"Asset Overlay\"\n" + "[[feature]]\n" + "id = \"user-byte\"\n" + "name = \"User Byte\"\n" + "[[feature]]\n" + "id = \"dynamic-main\"\n" + "name = \"Dynamic Main\"\n" + "[[feature]]\n" + "id = \"sparse-main\"\n" + "name = \"Sparse Main\"\n" + "[[feature]]\n" + "id = \"sparse-flag\"\n" + "name = \"Sparse Flag\"\n" + "[[feature]]\n" + "id = \"sparse-disc\"\n" + "name = \"Sparse Disc\"\n" + "[[feature]]\n" + "id = \"vblank-plugin\"\n" + "name = \"VBlank Plugin\"\n" + "[[option]]\n" + "feature = \"dynamic-main\"\n" + "id = \"count\"\n" + "label = \"Count\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 254\n" + "default = 42\n" + "[[option]]\n" + "feature = \"sparse-main\"\n" + "id = \"frames\"\n" + "label = \"Frames\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 99\n" + "default = 2\n" + "[[patch]]\n" + "feature = \"main-code\"\n" + "target = \"main_exe\"\n" + "address = 2147487744\n" + "expected = \"01020304\"\n" + "replace = \"a1a2a3a4\"\n" + "[[patch]]\n" + "feature = \"disc-byte\"\n" + "target = \"disc_raw\"\n" + "offset = 4714\n" + "expected = \"aa\"\n" + "replace = \"bb\"\n" + "[[patch]]\n" + "feature = \"user-byte\"\n" + "target = \"disc_user\"\n" + "offset = 6154\n" + "expected = \"cc\"\n" + "replace = \"dd\"\n" + "[[patch]]\n" + "feature = \"dynamic-main\"\n" + "target = \"main_exe\"\n" + "address = 2147488000\n" + "expected = \"0000\"\n" + "replace_from = { option = \"count\", encoding = \"u16le\" }\n" + "[[patch]]\n" + "feature = \"dynamic-main\"\n" + "target = \"main_exe\"\n" + "address = 2147488002\n" + "expected = \"0100\"\n" + "replace_from = { option = \"count\", encoding = \"u16le\", addend = 1 }\n" + "[[patch]]\n" + "feature = \"sparse-main\"\n" + "target = \"main_exe\"\n" + "address = 2147488256\n" + "expected = \"02000132\"\n" + "fields = [{ offset = 0, option = \"frames\", encoding = \"u8\" }]\n" + "when_integer = { option = \"frames\", op = \"gt\", value = 0 }\n" + "[[patch]]\n" + "feature = \"sparse-main\"\n" + "target = \"main_exe\"\n" + "address = 2147488256\n" + "expected = \"02000132\"\n" + "fields = [{ offset = 0, replace = \"01\" }, " + "{ offset = 2, replace = \"00\" }]\n" + "when_integer = { option = \"frames\", op = \"eq\", value = 0 }\n" + "[[patch]]\n" + "feature = \"sparse-flag\"\n" + "target = \"main_exe\"\n" + "address = 2147488256\n" + "expected = \"02000132\"\n" + "fields = [{ offset = 1, replace = \"42\" }]\n" + "[[patch]]\n" + "feature = \"sparse-disc\"\n" + "target = \"disc_user\"\n" + "offset = 6164\n" + "expected = \"11223344\"\n" + "fields = [{ offset = 0, replace = \"aa\" }, " + "{ offset = 2, replace = \"bb\" }]\n" + "[[overlay]]\n" + "feature = \"asset-overlay\"\n" + "target = \"disc_raw\"\n" + "offset = 11408\n" + "file = \"assets/overlay.bin\"\n" + "sha256 = \"" + sha256_hex(overlay) + "\"\n" + "expected_sha256 = \"" + + sha256_hex(std::vector<uint8_t>(overlay.size(), 0)) + "\"\n" + "[[plugin]]\n" + "feature = \"vblank-plugin\"\n" + "id = \"runtime.test-vblank\"\n"); + write_text(root / "state.toml", + "format_version = 2\n" + "[[package]]\n" + "id = \"runtime.test\"\n" + "version = \"1.0.0\"\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"main-code\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"disc-byte\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"asset-overlay\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"user-byte\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"dynamic-main\"\n" + "enabled = true\n" + "[feature.values]\n" + "count = 42\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"sparse-main\"\n" + "enabled = true\n" + "[feature.values]\n" + "frames = 0\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"sparse-flag\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"sparse-disc\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"vblank-plugin\"\n" + "enabled = true\n"); + + std::string error; + PSXRecompV4::mod_clear_plugins_for_tests(); + check(PSXRecompV4::mod_register_activation_plugin( + "runtime.test-vblank", test_activation_plugin), + "runtime test activation hook must register"); + check(PSXRecompV4::mod_register_vblank_plugin( + "runtime.test-vblank", test_vblank_plugin), + "runtime test plugin must register"); + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit(cue_path, &error), + "CUE and its data-track BIN must have the same mod target identity"); + mod_runtime_activate_plugins(); + check(activation_calls == 1, + "resolved trusted plugin must activate before runtime startup"); + mod_runtime_on_vblank(); + check(plugin_calls == 1, + "resolved trusted plugin must run on guest VBlank"); + + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 1; ram[0x1103] = 0; + ram[0x1200] = 2; ram[0x1201] = 0; + ram[0x1202] = 1; ram[0x1203] = 0x32; + mod_runtime_on_dispatch(0x80001000); + check(ram[0x1000] == 1, "patch must wait for the configured entry point"); + mod_runtime_on_dispatch(0x80002000); + check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4, + "main-EXE patch must apply before entry execution"); + check(ram[0x1100] == 42 && ram[0x1101] == 0 && + ram[0x1102] == 43 && ram[0x1103] == 0, + "dynamic main-EXE patches must encode all sites before entry"); + check(ram[0x1200] == 1 && ram[0x1201] == 0x42 && + ram[0x1202] == 0 && ram[0x1203] == 0x32, + "adjacent sparse fields must compose while preserving guard-only " + "bytes"); + + std::array<uint8_t, 2352> sector{}; + sector[10] = 0xaa; + mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size()); + check(sector[10] == 0xaa, "disc overlay must stay off during reference reads"); + mod_runtime_enable_disc_patches(); + mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size()); + check(sector[10] == 0xbb, "raw disc overlay must patch matching sectors"); + + std::array<uint8_t, 2352> overlay_sector{}; + mod_runtime_patch_disc_sector( + 4, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector[1999] == 0 && + overlay_sector[2000] == overlay[0] && + overlay_sector[2351] == overlay[351], + "file overlay must patch the tail of its first sector"); + overlay_sector.fill(0); + mod_runtime_patch_disc_sector( + 5, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector.front() == overlay[352] && + overlay_sector.back() == overlay[2703], + "file overlay must patch complete middle sectors"); + overlay_sector.fill(0); + mod_runtime_patch_disc_sector( + 6, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector[0] == overlay[2704] && + overlay_sector[295] == overlay[2999] && + overlay_sector[296] == 0, + "file overlay must patch the head of its final sector"); + + std::array<uint8_t, 2352> mode2_sector{}; + mode2_sector[15] = 2; + mode2_sector[18] = 0; + mode2_sector[24 + 10] = 0xcc; + mode2_sector[24 + 20] = 0x11; + mode2_sector[24 + 21] = 0x22; + mode2_sector[24 + 22] = 0x33; + mode2_sector[24 + 23] = 0x44; + mod_runtime_patch_disc_sector( + 3, 1, mode2_sector.data(), (uint32_t)mode2_sector.size()); + check(mode2_sector[24 + 10] == 0xdd, + "disc_user operations must apply to raw Mode2 Form1 user data"); + check(mode2_sector[24 + 20] == 0xaa && + mode2_sector[24 + 21] == 0x22 && + mode2_sector[24 + 22] == 0xbb && + mode2_sector[24 + 23] == 0x44, + "sparse disc writes must validate a complete guard and modify only " + "owned fields"); + std::array<uint8_t, 2352> audio_sector{}; + audio_sector[24 + 10] = 0xcc; + mod_runtime_patch_disc_sector( + 3, 1, audio_sector.data(), (uint32_t)audio_sector.size()); + check(audio_sector[24 + 10] == 0xcc, + "disc_user operations must not modify CDDA/non-data sectors"); + + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 2; ram[0x1103] = 0; /* second dynamic guard is wrong */ + mod_runtime_on_dispatch(0x80002000); + check(ram[0x1000] == 1 && ram[0x1003] == 4 && + ram[0x1100] == 0 && ram[0x1101] == 0, + "one failed generated guard must leave the complete main plan untouched"); + + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 1; ram[0x1103] = 0; + ram[0x1200] = 2; ram[0x1201] = 0; + ram[0x1202] = 1; ram[0x1203] = 0x33; /* guard-only byte is wrong */ + mod_runtime_on_dispatch(0x80002000); + check(ram[0x1000] == 1 && ram[0x1003] == 4 && + ram[0x1200] == 2 && ram[0x1201] == 0 && + ram[0x1202] == 1 && ram[0x1203] == 0x33, + "a failed sparse guard-only byte must leave the complete main plan " + "untouched"); + + mod_runtime_enable_disc_patches(); + std::array<uint8_t, 2352> bad_sparse_disc{}; + bad_sparse_disc[15] = 2; + bad_sparse_disc[18] = 0; + bad_sparse_disc[24 + 10] = 0xcc; + bad_sparse_disc[24 + 20] = 0x11; + bad_sparse_disc[24 + 21] = 0x99; /* guard-only byte is wrong */ + bad_sparse_disc[24 + 22] = 0x33; + bad_sparse_disc[24 + 23] = 0x44; + mod_runtime_patch_disc_sector( + 3, 1, bad_sparse_disc.data(), + (uint32_t)bad_sparse_disc.size()); + check(bad_sparse_disc[24 + 10] == 0xcc && + bad_sparse_disc[24 + 20] == 0x11 && + bad_sparse_disc[24 + 22] == 0x33, + "a failed sparse disc guard must leave every write in the sector " + "untouched"); + + fs::remove_all(root, ec); + if (failures) return 1; + std::cout << "mod runtime tests passed\n"; + return 0; +} diff --git a/runtime/tests/test_psx_cyc_batch.c b/runtime/tests/test_psx_cyc_batch.c new file mode 100644 index 00000000..d704c4e8 --- /dev/null +++ b/runtime/tests/test_psx_cyc_batch.c @@ -0,0 +1,77 @@ +#include "psx_cyc.h" + +#include <assert.h> +#include <stdint.h> + +uint64_t psx_cycle_count = 0; +uint64_t psx_next_service_cycle = 0; +uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; +int g_psx_cyc_bb_defer = 0; +int psx_in_device_service = 0; +int g_event_step_conservative = 0; +int g_ls_replay_active = 0; +int g_ls_mode = 0; +volatile int g_ds_recording = 0; +uint8_t *g_psx_ram = 0; +int g_psx_load_delay = 1; + +static int service_count; + +void psx_devices_service_to_now(void) { + service_count++; + psx_next_service_cycle = psx_cycle_count + 1000u; +} + +void psx_advance_cycles_slow(uint32_t cycles) { + psx_cycle_count += cycles; +} + +static void reset_clock(uint64_t deadline) { + psx_cycle_count = 0; + psx_next_service_cycle = deadline; + g_psx_cyc_batch = 0; + g_psx_cyc_batch_limit = 0; + g_psx_cyc_bb_defer = 0; + service_count = 0; +} + +int main(void) { + reset_clock(5u); + for (int i = 0; i < 4; i++) psx_cyc_charge(1u); + assert(psx_cycle_count == 0u); + assert(g_psx_cyc_batch == 4u); + assert(service_count == 0); + + psx_cyc_charge(1u); + assert(psx_cycle_count == 5u); + assert(g_psx_cyc_batch == 0u); + assert(service_count == 1); + + reset_clock(1000u); + for (int i = 0; i < 63; i++) psx_cyc_charge(1u); + assert(psx_cycle_count == 0u); + assert(g_psx_cyc_batch == 63u); + psx_cyc_charge(1u); + assert(psx_cycle_count == 64u); + assert(g_psx_cyc_batch == 0u); + assert(service_count == 0); + + reset_clock(5u); + psx_cyc_bb_defer_begin(); + for (int i = 0; i < 10; i++) psx_cyc_charge(1u); + assert(psx_cycle_count == 0u); + assert(g_psx_cyc_batch == 10u); + psx_cyc_bb_defer_flush(); + assert(psx_cycle_count == 10u); + assert(service_count == 1); + assert(g_psx_cyc_bb_defer == 1); + psx_cyc_bb_defer_end(); + assert(g_psx_cyc_bb_defer == 0); + + reset_clock(0u); + psx_cyc_charge(1u); + assert(psx_cycle_count == 1u); + assert(service_count == 1); + return 0; +} diff --git a/runtime/tests/test_vk_build_default.py b/runtime/tests/test_vk_build_default.py new file mode 100644 index 00000000..242b69c3 --- /dev/null +++ b/runtime/tests/test_vk_build_default.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + + +root = Path(__file__).parents[2] +runtime_cmake = (root / "runtime" / "runtime.cmake").read_text() +config_loader = (root / "recompiler" / "src" / "config_loader.h").read_text() + +option = re.search( + r'option\(PSX_ENABLE_VULKAN\s+"[^"]*"\s+(ON|OFF)\)', runtime_cmake +) +assert option, "PSX_ENABLE_VULKAN option not found" +assert option.group(1) == "ON", "Vulkan backend is not compiled by default" +assert "DEFAULT_VIDEO_RENDERER = VIDEO_RENDERER_OPENGL" in config_loader, ( + "enabling the Vulkan build changed the runtime renderer default away from OpenGL" +) + +print("Vulkan build/default-renderer policy test passed") diff --git a/runtime/tests/test_vk_draw_area_batch_boundary.py b/runtime/tests/test_vk_draw_area_batch_boundary.py new file mode 100644 index 00000000..3d7c7a4d --- /dev/null +++ b/runtime/tests/test_vk_draw_area_batch_boundary.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + + +source = (Path(__file__).parents[1] / "src" / "gpu_vk_renderer.c").read_text() +setter = re.search( + r"static void vkb_set_draw_area\(.*?\n\}", source, flags=re.DOTALL +) +assert setter, "vkb_set_draw_area not found" +body = setter.group(0) + +tex_flush = body.find("flush_tex_batch();") +geo_flush = body.find("flush_geometry();") +state_write = body.find("s_da_x1 = x1;") + +assert tex_flush >= 0, "draw-area change does not drain textured primitives" +assert geo_flush >= 0, "draw-area change does not drain untextured primitives" +assert tex_flush < state_write and geo_flush < state_write, ( + "draw-area state changes before pending primitives are drained" +) + +print("Vulkan draw-area batch-boundary test passed") diff --git a/runtime/tests/test_ws_aspect_cone_math.c b/runtime/tests/test_ws_aspect_cone_math.c new file mode 100644 index 00000000..30ca90a4 --- /dev/null +++ b/runtime/tests/test_ws_aspect_cone_math.c @@ -0,0 +1,68 @@ +#include "ws_aspect_cone_math.h" + +#include <stdio.h> + +static int failures; + +static void check(int condition, const char *name) { + if (condition) { + printf("PASS %s\n", name); + } else { + fprintf(stderr, "FAIL %s\n", name); + failures++; + } +} + +int main(void) { + /* Camera looking along +X, with an approximately 30-degree vanilla cone. */ + const int32_t q12 = 4096; + const uint32_t cos_q10 = 880; + + check(psx_ws_aspect_cone_contains( + 1000, 500, 0, q12, 0, 0, cos_q10, 0), + "vanilla horizontal interior is accepted"); + check(!psx_ws_aspect_cone_contains( + 1000, 650, 0, q12, 0, 0, cos_q10, 0), + "vanilla horizontal exterior is rejected"); + check(psx_ws_aspect_cone_contains( + 1000, 650, 0, q12, 0, 0, cos_q10, 80), + "wide extent expands horizontal acceptance"); + check(!psx_ws_aspect_cone_contains( + 1000, 0, 650, q12, 0, 0, cos_q10, 80), + "wide extent preserves the vertical boundary"); + check(!psx_ws_aspect_cone_contains( + -1000, 0, 0, q12, 0, 0, cos_q10, 160), + "points behind the camera are rejected"); + + /* A 45-degree pitched forward vector still produces orthogonal forward, + * horizontal, and vertical tests. */ + check(psx_ws_aspect_cone_contains( + 1000, 0, 1000, 2896, 0, 2896, cos_q10, 0), + "pitched camera accepts a point on its forward axis"); + check(!psx_ws_aspect_cone_contains( + 1000, 0, -1000, 2896, 0, 2896, cos_q10, 160), + "horizontal expansion does not bypass pitched vertical rejection"); + + check(!psx_ws_aspect_cone_contains( + 1000, 0, 0, q12, 0, 0, 0, 0), + "invalid zero threshold is inert"); + check(!psx_ws_aspect_cone_contains( + 1000, 0, 0, 0, 0, q12, cos_q10, 0), + "degenerate horizontal camera basis is inert"); + + check(psx_ws_widen_angle_q12(341, 0) == 341, + "terrain angle is exact at 4:3"); + check(psx_ws_widen_angle_q12(341, 136) == 533, + "terrain angle follows 21:9 plus guard geometry"); + check(psx_ws_widen_angle_q12(455, 136) == 651, + "wider vanilla terrain angle scales geometrically"); + check(psx_ws_widen_angle_q12(300, 136) == 484, + "lava terrain angle uses the same projection geometry"); + check(psx_ws_widen_angle_q12(318, 80) < + psx_ws_widen_angle_q12(318, 136), + "terrain angle tracks dynamic horizontal extent"); + check(psx_ws_widen_angle_q12(1023, 256) <= 1023, + "terrain angle remains below a quarter turn"); + + return failures ? 1 : 0; +} diff --git a/runtime/tests/test_ws_ui_group.c b/runtime/tests/test_ws_ui_group.c new file mode 100644 index 00000000..4c74f58c --- /dev/null +++ b/runtime/tests/test_ws_ui_group.c @@ -0,0 +1,56 @@ +#include "ws_ui_group.h" + +#include <assert.h> +#include <stdint.h> + +static int32_t scale_about(int32_t x, int32_t anchor, + int32_t numerator, int32_t denominator) { + int32_t delta = x - anchor; + int32_t scaled = + (delta * numerator + + (delta >= 0 ? denominator / 2 : -denominator / 2)) / denominator; + return anchor + scaled; +} + +int main(void) { + const int display_width = 384; + + WsUiGroupItem text[] = { + {1, 120, 16, 0}, {1, 136, 16, 0}, {1, 152, 16, 0}, + {1, 168, 16, 0}, {1, 184, 16, 0}, {1, 200, 16, 0}, + {1, 216, 16, 0}, {1, 232, 16, 0}, {1, 248, 16, 0}, + }; + ws_ui_group_assign(text, 9, display_width, 0); + for (int i = 0; i < 9; i++) assert(text[i].anchor == 192); + assert(scale_about(184, text[3].anchor, 4, 7) == + scale_about(184, text[4].anchor, 4, 7)); + + WsUiGroupItem monkeys[] = { + {2, 300, 16, 0}, {2, 320, 16, 0}, + {2, 340, 16, 0}, {2, 360, 16, 0}, + }; + ws_ui_group_assign(monkeys, 4, display_width, 0); + for (int i = 0; i < 4; i++) assert(monkeys[i].anchor == 384); + for (int i = 0; i < 3; i++) { + int32_t a = scale_about(monkeys[i].x, monkeys[i].anchor, 3, 4); + int32_t b = scale_about(monkeys[i + 1].x, + monkeys[i + 1].anchor, 3, 4); + assert(b - a == 15); + } + + WsUiGroupItem edge_runs[] = { + {3, 20, 16, 0}, {3, 36, 16, 0}, + {3, 330, 16, 0}, {3, 346, 16, 0}, + }; + ws_ui_group_assign(edge_runs, 4, display_width, 0); + assert(edge_runs[0].anchor == 0 && edge_runs[1].anchor == 0); + assert(edge_runs[2].anchor == 384 && edge_runs[3].anchor == 384); + + WsUiGroupItem dense[] = {{4, 8, 16, 0}, {5, 340, 16, 0}}; + ws_ui_group_assign(dense, 2, display_width, 1); + assert(dense[0].anchor == 192 && dense[1].anchor == 192); + + assert(ws_ui_anchor_for_bounds(8, 32, display_width) == 0); + assert(ws_ui_anchor_for_bounds(344, 32, display_width) == 384); + return 0; +} diff --git a/tools/gen_bios_seeds_from_elf.py b/tools/gen_bios_seeds_from_elf.py new file mode 100644 index 00000000..550b817a --- /dev/null +++ b/tools/gen_bios_seeds_from_elf.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""gen_bios_seeds_from_elf.py — BIOS discovery seeds from ELF symbol tables. + +For an open-source BIOS built from source (OpenBIOS), the linker knows every +function start exactly — no Ghidra pass over a proprietary blob needed. This +tool reads STT_FUNC symbols out of one or more ELFs (the main BIOS ELF plus +any separately-linked embedded payloads, e.g. OpenBIOS's shell.elf) and emits +the seeds JSON that psxrecomp-bios --emit-full consumes. + +Symbols whose virtual address lies in RAM (relocated code: OpenBIOS's +.ramtext blob at 0x500, the shell at 0x80030000) are folded BACK to their ROM +source through the BIOS profile's [recompiler.address_model] copy table — the +tool takes the profile as input precisely so it cannot invent an address +model of its own. Symbols that fold nowhere are dropped and reported. + +Output-format contract (the consumer is the hand-rolled brace scanner in +recompiler/src/main_bios.cpp load_seeds(), NOT a JSON library): + 1. no '[' or ']' inside any emitted string (depth counting ignores quotes); + 2. per-object field order is address, label, rationale (later fields are + only found if they appear after "address" within the object); + 3. ASCII only, no '"' or backslash in strings (no escape handling). + +Usage: + gen_bios_seeds_from_elf.py --profile bios/OpenBIOS.toml \ + --elf path/to/openbios.elf [--elf path/to/shell.elf ...] \ + --out recompiler/seeds/openbios_elf_seeds.json +""" + +import argparse +import json +import os +import re +import struct +import sys + +KSEG_MASK = 0x1FFFFFFF + + +def parse_profile(path): + """Crude single-purpose parse of the profile: load_address, text_size and + the [[recompiler.address_model.copy]] entries. The tracked profiles are + authored as simple `key = "value"` lines, which is all this reads.""" + text = open(path, encoding="utf-8").read() + + def top_value(key, default=None): + m = re.search(r'^\s*%s\s*=\s*"([^"]+)"' % key, text, re.M) + return m.group(1) if m else default + + load_address = int(top_value("load_address", "0xBFC00000"), 16) + text_size = int(top_value("text_size", "0x80000"), 16) + + copies = [] + for block in re.findall( + r'\[\[recompiler\.address_model\.copy\]\](.*?)(?=\n\[|\Z)', + text, re.S): + def v(key, blk=block): + m = re.search(r'^\s*%s\s*=\s*"([^"]+)"' % key, blk, re.M) + return m.group(1) if m else None + copies.append({ + "name": v("name") or "?", + "rom_lo": int(v("rom_lo"), 16), + "rom_hi": int(v("rom_hi"), 16), + "ram_lo": int(v("ram_lo"), 16), + }) + return load_address & KSEG_MASK, text_size, copies + + +def read_elf(path): + """Parse an ELF32-LE (EM_MIPS): returns (data, sections) with each + section's name/type/flags/addr/offset/size/link/entsize.""" + data = open(path, "rb").read() + if data[:4] != b"\x7fELF" or data[4] != 1 or data[5] != 1: + raise SystemExit(f"{path}: not a little-endian ELF32") + (e_machine,) = struct.unpack_from("<H", data, 18) + if e_machine != 8: + raise SystemExit(f"{path}: e_machine={e_machine}, expected EM_MIPS(8)") + e_shoff, = struct.unpack_from("<I", data, 32) + e_shentsize, e_shnum, e_shstrndx = struct.unpack_from("<HHH", data, 46) + + sections = [] + for i in range(e_shnum): + off = e_shoff + i * e_shentsize + name, stype, flags, addr, offset, size, link, info, align, entsize = \ + struct.unpack_from("<10I", data, off) + sections.append(dict(name=name, type=stype, flags=flags, addr=addr, + offset=offset, size=size, link=link, + entsize=entsize)) + return data, sections + + +def read_elf_funcs(path): + """Yield (vaddr, size, name) for defined STT_FUNC symbols.""" + data, sections = read_elf(path) + for sec in sections: + if sec["type"] != 2: # SHT_SYMTAB + continue + strtab = sections[sec["link"]] + count = sec["size"] // (sec["entsize"] or 16) + for i in range(count): + off = sec["offset"] + i * (sec["entsize"] or 16) + st_name, st_value, st_size, st_info, st_other, st_shndx = \ + struct.unpack_from("<IIIBBH", data, off) + if st_info & 0xF != 2: # STT_FUNC + continue + if st_shndx == 0 or st_value == 0: # undefined + continue + end = data.index(b"\0", strtab["offset"] + st_name) + name = data[strtab["offset"] + st_name:end].decode( + "ascii", errors="replace") + yield st_value, st_size, name + + +SHF_ALLOC, SHF_EXECINSTR = 0x2, 0x4 + + +def read_elf_code_pointers(path): + """Yield vaddrs of words in allocated NON-code sections that point into + a code section. Catches computed-goto label tables and function-pointer + tables (e.g. GCC's vxprintf format dispatch), which have no symbols and + which the Sony-era jump-table pattern matcher does not recognize. A data + word that merely looks like a code address seeds a bogus walk; discovery + and the strict translator skip what does not decode, so the cost is a + dead stub, not a miscompile.""" + data, sections = read_elf(path) + code_ranges = [(s["addr"], s["addr"] + s["size"]) for s in sections + if s["flags"] & SHF_EXECINSTR and s["size"]] + for sec in sections: + if sec["type"] != 1: # SHT_PROGBITS + continue + if not (sec["flags"] & SHF_ALLOC) or (sec["flags"] & SHF_EXECINSTR): + continue + for off in range(sec["offset"], sec["offset"] + (sec["size"] & ~3), 4): + (w,) = struct.unpack_from("<I", data, off) + if w & 3: + continue + if any(lo <= w < hi for lo, hi in code_ranges): + yield w + + +def sanitize(s): + """Enforce the brace-scanner string contract.""" + s = s.encode("ascii", errors="replace").decode("ascii") + return re.sub(r'[\[\]"\\]', "_", s) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--profile", required=True) + ap.add_argument("--elf", action="append", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--extra", help="empirical dispatch-miss seeds JSON to merge " + "(same shape; the SCPH1001 dispatch_miss_seeds.json loop)") + args = ap.parse_args() + + rom_base, rom_size, copies = parse_profile(args.profile) + + def fold(vaddr): + phys = vaddr & KSEG_MASK + if phys & 3: + return None + if rom_base <= phys < rom_base + rom_size: + return phys + for c in copies: + ram_hi = c["ram_lo"] + (c["rom_hi"] - c["rom_lo"]) + if c["ram_lo"] <= phys < ram_hi: + return c["rom_lo"] + (phys - c["ram_lo"]) + return None + + seeds = {} + dropped = [] + # Each ELF's e_entry is an authoritative code entry point even when the + # symbol at it is untyped assembly (shell.elf's _start): discovery walks + # the whole crt0 call chain from it, reaching helpers that carry no + # symbols at all. + for elf in args.elf: + data, _secs = read_elf(elf) + (e_entry,) = struct.unpack_from("<I", data, 24) + rom_phys = fold(e_entry) + if rom_phys is not None: + addr = 0xA0000000 | rom_phys + base = sanitize(elf.replace(chr(92), "/").split("/")[-1]).replace(".", "_") + seeds.setdefault(addr, (f"elf_entry_{base}", elf, "entry")) + for elf in args.elf: + for vaddr, size, name in read_elf_funcs(elf): + rom_phys = fold(vaddr) + if rom_phys is None: + dropped.append((elf, vaddr, name, "outside ROM and every copy window")) + continue + addr = 0xA0000000 | rom_phys # KSEG1, matches 0xBFCxxxxx + if addr in seeds: + continue # first name wins + seeds[addr] = (sanitize(name), elf, "sym") + + # Second pass: code pointers found in data sections — computed-goto label + # tables and function-pointer tables without symbols. Dedup against the + # symbol seeds so a real function keeps its name. + ptr_seeds = 0 + for elf in args.elf: + for vaddr in read_elf_code_pointers(elf): + rom_phys = fold(vaddr) + if rom_phys is None: + continue + addr = 0xA0000000 | rom_phys + if addr in seeds: + continue + seeds[addr] = (f"code_ptr_{addr:08X}", elf, "ptr") + ptr_seeds += 1 + + merge_note = "" + if args.extra: + if os.path.exists(args.extra): + doc = json.load(open(args.extra, encoding="utf-8")) + merged = 0 + for e in doc.get("seeds", []): + addr = int(e["address"], 16) + if addr not in seeds: + seeds[addr] = (sanitize(e.get("label", f"miss_{addr:08X}")), + args.extra, "extra") + merged += 1 + print(f" merged {merged} empirical seeds from {args.extra}") + if merged: + extra_base = sanitize( + args.extra.replace(chr(92), "/").split("/")[-1]) + merge_note = f" + {merged} empirical seeds from {extra_base}" + + RATIONALE = { + "entry": "ELF entry point (e_entry) of ", + "sym": "ELF STT_FUNC symbol from ", + "ptr": "code pointer in a data section of ", + "extra": "empirical dispatch-miss seed merged from ", + } + entries = [] + for addr in sorted(seeds): + name, src, kind = seeds[addr] + entries.append({ + "address": f"0x{addr:08X}", + "label": name, + "rationale": RATIONALE[kind] + + sanitize(src.replace(chr(92), "/").split("/")[-1]), + }) + + doc = { + "schema": "psxrecomp phase2 seeds", + "source": f"gen_bios_seeds_from_elf.py over {', '.join(sanitize(e.replace(chr(92), '/').split('/')[-1]) for e in args.elf)}{merge_note}", + "seed_count": len(entries), + "seeds": entries, + } + with open(args.out, "w", encoding="ascii", newline="\n") as f: + json.dump(doc, f, indent=2) + f.write("\n") + + print(f"gen_bios_seeds_from_elf: {len(entries)} seeds " + f"({ptr_seeds} from data-section code pointers) -> {args.out}") + if dropped: + print(f" dropped {len(dropped)}:") + for elf, vaddr, name, why in dropped[:20]: + print(f" 0x{vaddr:08X} {name}: {why}") + if len(dropped) > 20: + print(f" ... and {len(dropped) - 20} more") + + +if __name__ == "__main__": + main() diff --git a/tools/gen_tcp_commands.py b/tools/gen_tcp_commands.py new file mode 100644 index 00000000..c35fb8de --- /dev/null +++ b/tools/gen_tcp_commands.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Generate the complete TCP debug-command index in TCP_COMMANDS.md. + +WHY THIS EXISTS +--------------- +TCP_COMMANDS.md carried the instruction "update this file when you add a +handler". As of 2026-07-27 it described 47 commands while the two debug servers +registered 280 between them -- 17% coverage. A hand-maintained inventory of that +size does not stay correct, and a stale reference is worse than an absent one +because people trust it. + +The curated table under "## Command inventory" is still hand-written and still +valuable: it carries parameters, prose, and the beetle/native split for the +commands people actually reach for. This script does NOT touch it. It regenerates +only the block between the AUTOGEN markers -- a complete index of every +registered command, flagging which ones the curated table already covers. + +Usage: + python tools/gen_tcp_commands.py # rewrite the generated block + python tools/gen_tcp_commands.py --check # exit 1 if the block is stale + +--check is what belongs in CI: it makes "the reference drifted" a build failure +instead of something discovered months later. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +NATIVE_SRC = REPO / "runtime" / "src" / "debug_server.c" +BEETLE_SRC = REPO / "runtime" / "src" / "beetle_debug_server.c" +DOC = REPO / "TCP_COMMANDS.md" + +BEGIN = "<!-- BEGIN AUTOGENERATED COMMAND INDEX -- edit tools/gen_tcp_commands.py, not this block -->" +END = "<!-- END AUTOGENERATED COMMAND INDEX -->" + +# `{ "name", handler },` inside the table. Tolerates arbitrary whitespace. +ENTRY_RE = re.compile(r'\{\s*"([A-Za-z0-9_]+)"\s*,\s*[A-Za-z0-9_]+\s*\}') + + +def extract_table(path: Path, table_decl: str) -> list[str]: + """Pull command names out of a `static const CmdEntry <table_decl>` array. + + Reads from the declaration to the closing `};` so unrelated brace-initialised + literals elsewhere in these very large files cannot leak in. + """ + if not path.exists(): + raise SystemExit(f"gen_tcp_commands: missing source file {path}") + + text = path.read_text(encoding="utf-8", errors="replace") + start = text.find(table_decl) + if start < 0: + raise SystemExit( + f"gen_tcp_commands: could not find '{table_decl}' in {path.name}. " + "The command table was renamed or restructured; update this script " + "rather than deleting the check." + ) + end = text.find("};", start) + if end < 0: + raise SystemExit(f"gen_tcp_commands: unterminated table in {path.name}") + + names = ENTRY_RE.findall(text[start:end]) + if not names: + raise SystemExit(f"gen_tcp_commands: parsed 0 commands from {path.name}") + return names + + +def documented_in_curated_table(doc_text: str) -> set[str]: + """Commands the hand-written parts of the doc already describe. + + Picks up markdown table rows (`| `name` | ...`) and `## `name`` headings. + A row may name aliases -- "`ping` / `frame`", "`get_registers` (`regs`)" -- + so every backticked token on a matching line counts. + """ + covered: set[str] = set() + for line in doc_text.splitlines(): + stripped = line.strip() + if stripped.startswith("|") and "`" in stripped: + covered.update(re.findall(r"`([A-Za-z0-9_]+)`", stripped)) + elif stripped.startswith("#") and "`" in stripped: + covered.update(re.findall(r"`([A-Za-z0-9_]+)`", stripped)) + return covered + + +def build_block(native: list[str], beetle: list[str], covered: set[str]) -> str: + all_names = sorted(set(native) | set(beetle)) + nset, bset = set(native), set(beetle) + + documented = [n for n in all_names if n in covered] + undocumented = [n for n in all_names if n not in covered] + + lines: list[str] = [BEGIN, ""] + lines.append("## Complete command index (generated)") + lines.append("") + lines.append( + f"**{len(all_names)} commands registered** — {len(native)} on the native " + f"server (`runtime/src/debug_server.c`), {len(beetle)} on the Beetle " + f"server (`runtime/src/beetle_debug_server.c`)." + ) + lines.append("") + lines.append( + f"{len(documented)} of {len(all_names)} have prose above; " + f"**{len(undocumented)} are index-only**. An index-only command still " + "works — it just has no description here yet. Send it `{\"cmd\":\"<name>\"}` " + "and read the reply, or find its `handle_*` function in the server source." + ) + lines.append("") + lines.append( + "Regenerate with `python tools/gen_tcp_commands.py`; " + "`--check` fails if this block has drifted from the code." + ) + lines.append("") + lines.append("| Command | Native | Beetle | Described above |") + lines.append("|---|:--:|:--:|:--:|") + for name in all_names: + lines.append( + f"| `{name}` " + f"| {'✓' if name in nset else ''} " + f"| {'✓' if name in bset else ''} " + f"| {'✓' if name in covered else ''} |" + ) + lines.append("") + lines.append(END) + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--check", + action="store_true", + help="exit 1 if the generated block is out of date (for CI)", + ) + args = ap.parse_args() + + native = extract_table(NATIVE_SRC, "s_commands[]") + beetle = extract_table(BEETLE_SRC, "CMDS[]") + + doc_text = DOC.read_text(encoding="utf-8") + + # Coverage is judged against the hand-written parts only, so a previously + # generated block can never count itself as documentation. + curated = doc_text + if BEGIN in curated: + head, _, rest = curated.partition(BEGIN) + _, _, tail = rest.partition(END) + curated = head + tail + + block = build_block(native, beetle, documented_in_curated_table(curated)) + + if BEGIN in doc_text and END in doc_text: + head, _, rest = doc_text.partition(BEGIN) + _, _, tail = rest.partition(END) + updated = head + block + tail + else: + updated = doc_text.rstrip("\n") + "\n\n" + block + "\n" + + if args.check: + if updated != doc_text: + print( + "TCP_COMMANDS.md is out of date with the debug-server command " + "tables.\nRun: python tools/gen_tcp_commands.py", + file=sys.stderr, + ) + return 1 + print(f"TCP_COMMANDS.md is current ({len(set(native) | set(beetle))} commands).") + return 0 + + if updated == doc_text: + print(f"TCP_COMMANDS.md already current ({len(set(native) | set(beetle))} commands).") + return 0 + + DOC.write_text(updated, encoding="utf-8") + print(f"TCP_COMMANDS.md updated ({len(set(native) | set(beetle))} commands).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/overlay_xref.py b/tools/overlay_xref.py new file mode 100644 index 00000000..7761e0e3 --- /dev/null +++ b/tools/overlay_xref.py @@ -0,0 +1,709 @@ +#!/usr/bin/env python3 +"""Cross-reference overlay code bytes captured by the runtime. + +The runtime writes one JSON per distinct overlay body it has ever seen into +`<build>/overlay_captures.json.d/<contenthash>.json` (schema +"psxrecomp overlay capture v2": `load_addr`, `size`, `bytes_b64`, and the set of +`executed_pcs` observed inside that body). Because the set is content-hashed and +additive across every run, it is an always-on record of every overlay variant the +game has ever mapped -- exactly what is needed before touching an address-guarded +`[widescreen.cull]` site, where a second variant at the same virtual address with +the same opcode but different operands would be silently mispatched. + +This tool is game-agnostic: point it at any title's capture directory. + +Commands +-------- + index summarise the capture set + word VA... every distinct word observed at each VA, with the + capture ids and whether the VA was ever executed + dis VA [-n N] disassemble N instructions from VA, one listing per + distinct variant + func VA [-n N] disassemble backwards to the enclosing function + prologue, then forwards to its return + scan --op OP [--imm-min M] [--imm-max M] [--exec-only] + every site whose opcode matches, optionally + filtered by immediate range + imm VALUE every site anywhere whose immediate equals VALUE + callers VA every jal/j site targeting VA + cover VA... which captures contain each VA (load ranges only) + +Examples +-------- + overlay_xref.py -d build/overlay_captures.json.d word 0x80069B84 + overlay_xref.py -d build/overlay_captures.json.d dis 0x80069B6C -n 80 + overlay_xref.py -d build/overlay_captures.json.d scan --op sltiu --imm-min 0x100 + overlay_xref.py -d build/overlay_captures.json.d callers 0x80069B6C +""" +from __future__ import annotations + +import argparse +import base64 +import glob +import json +import os +import struct +import sys +from collections import defaultdict + +# --------------------------------------------------------------------------- +# MIPS R3000A disassembler (mirrors tools/disasm_helper.py, widened for cop2) +# --------------------------------------------------------------------------- + +REGS = ['zr', 'at', 'v0', 'v1', 'a0', 'a1', 'a2', 'a3', + 't0', 't1', 't2', 't3', 't4', 't5', 't6', 't7', + 's0', 's1', 's2', 's3', 's4', 's5', 's6', 's7', + 't8', 't9', 'k0', 'k1', 'gp', 'sp', 'fp', 'ra'] + +SPECIAL = { + 0x00: 'sll', 0x02: 'srl', 0x03: 'sra', 0x04: 'sllv', 0x06: 'srlv', + 0x07: 'srav', 0x08: 'jr', 0x09: 'jalr', 0x0c: 'syscall', 0x0d: 'break', + 0x10: 'mfhi', 0x11: 'mthi', 0x12: 'mflo', 0x13: 'mtlo', 0x18: 'mult', + 0x19: 'multu', 0x1a: 'div', 0x1b: 'divu', 0x20: 'add', 0x21: 'addu', + 0x22: 'sub', 0x23: 'subu', 0x24: 'and', 0x25: 'or', 0x26: 'xor', + 0x27: 'nor', 0x2a: 'slt', 0x2b: 'sltu', +} + +IMMOPS = { + 4: 'beq', 5: 'bne', 6: 'blez', 7: 'bgtz', 8: 'addi', 9: 'addiu', + 10: 'slti', 11: 'sltiu', 12: 'andi', 13: 'ori', 14: 'xori', 15: 'lui', + 32: 'lb', 33: 'lh', 34: 'lwl', 35: 'lw', 36: 'lbu', 37: 'lhu', 38: 'lwr', + 40: 'sb', 41: 'sh', 42: 'swl', 43: 'sw', 46: 'swr', + 0x32: 'lwc2', 0x3a: 'swc2', +} + + +def _r(n: int) -> str: + return REGS[n & 0x1f] + + +def mnemonic(w: int) -> str: + """Just the opcode name, for filtering.""" + if w == 0: + return 'nop' + op = (w >> 26) & 0x3f + if op == 0: + return SPECIAL.get(w & 0x3f, f'spec{w & 0x3f:02x}') + if op == 1: + rt = (w >> 16) & 0x1f + return ('bltz' if (rt & 1) == 0 else 'bgez') + ('al' if rt & 0x10 else '') + if op == 2: + return 'j' + if op == 3: + return 'jal' + if op == 0x10: + return 'cop0' + if op == 0x12: + return 'cop2' + return IMMOPS.get(op, f'op{op:02x}') + + +def dis(pc: int, w: int) -> str: + if w == 0: + return 'nop' + op = (w >> 26) & 0x3f + rs = (w >> 21) & 0x1f + rt = (w >> 16) & 0x1f + rd = (w >> 11) & 0x1f + sh = (w >> 6) & 0x1f + fn = w & 0x3f + imm = w & 0xffff + simm = imm - 0x10000 if imm & 0x8000 else imm + tgt = (w & 0x3ffffff) << 2 + if op == 0: + n = SPECIAL.get(fn, f'spec{fn:02x}') + if fn == 0x08: + return f'jr ${_r(rs)}' + if fn == 0x09: + return f'jalr ${_r(rd)},${_r(rs)}' + if fn in (0x0c, 0x0d): + return n + if fn in (0x10, 0x12): + return f'{n:<7} ${_r(rd)}' + if fn in (0x11, 0x13): + return f'{n:<7} ${_r(rs)}' + if fn in (0x18, 0x19, 0x1a, 0x1b): + return f'{n:<7} ${_r(rs)},${_r(rt)}' + if fn in (0x00, 0x02, 0x03): + return f'{n:<7} ${_r(rd)},${_r(rt)},{sh}' + return f'{n:<7} ${_r(rd)},${_r(rs)},${_r(rt)}' + if op == 1: + n = ('bltz' if (rt & 1) == 0 else 'bgez') + ('al' if rt & 0x10 else '') + return f'{n:<7} ${_r(rs)},0x{(pc + 4 + (simm << 2)) & 0xffffffff:08X}' + if op in (2, 3): + n = 'j' if op == 2 else 'jal' + return f'{n:<7} 0x{(pc & 0xf0000000) | tgt:08X}' + n = IMMOPS.get(op, f'op{op:02x}') + if op == 15: + return f'lui ${_r(rt)},0x{imm:04X}' + if op in (4, 5): + return f'{n:<7} ${_r(rs)},${_r(rt)},0x{(pc + 4 + (simm << 2)) & 0xffffffff:08X}' + if op in (6, 7): + return f'{n:<7} ${_r(rs)},0x{(pc + 4 + (simm << 2)) & 0xffffffff:08X}' + if op in (8, 9, 10, 11): + return f'{n:<7} ${_r(rt)},${_r(rs)},0x{imm:04X} # {simm}' + if op in (12, 13, 14): + return f'{n:<7} ${_r(rt)},${_r(rs)},0x{imm:04X}' + if op == 0x10: + return f'cop0 0x{w & 0x3ffffff:07X}' + if op == 0x12: + return f'cop2 0x{w & 0x3ffffff:07X}' + if op >= 32: + return f'{n:<7} ${_r(rt)},{simm}(${_r(rs)})' + return f'raw 0x{w:08X}' + + +def imm_of(w: int): + """16-bit immediate field for every i-type opcode that has one, else None. + + Covers the ALU immediates (addi..lui) *and* the load/store displacements -- + a gate's threshold is an ALU immediate, but the struct-field offset that + identifies which coordinate is being tested is a load displacement, so both + have to be searchable. + """ + op = (w >> 26) & 0x3f + if op in (8, 9, 10, 11, 12, 13, 14, 15): # addi/addiu/slti/sltiu/andi/ori/xori/lui + return w & 0xffff + if 32 <= op <= 46 and op not in (39, 44, 45): # lb..swr (loads and stores) + return w & 0xffff + return None + + +# --------------------------------------------------------------------------- +# Capture set +# --------------------------------------------------------------------------- + +class Capture: + __slots__ = ('cid', 'lo', 'size', 'data', 'exec_pcs') + + def __init__(self, cid, lo, size, data, exec_pcs): + self.cid = cid + self.lo = lo + self.size = size + self.data = data + self.exec_pcs = exec_pcs + + @property + def hi(self): + return self.lo + self.size + + def word(self, va): + off = va - self.lo + if off < 0 or off + 4 > len(self.data): + return None + return struct.unpack_from('<I', self.data, off)[0] + + +CACHE_VERSION = 2 + + +class CaptureSet: + def __init__(self, directory, want_exec=True, cache=True): + self.dir = directory + self.caps: list[Capture] = [] + files = sorted(glob.glob(os.path.join(directory, '*.json'))) + if not files: + raise SystemExit(f'no capture JSONs under {directory}') + cache_path = os.path.join(directory, '.overlay_xref.cache') + if cache and self._load_cache(cache_path, files): + self._index_phys() + return + self._parse(files, want_exec) + if cache: + self._save_cache(cache_path, files) + self._index_phys() + + # -- cache ------------------------------------------------------------- + def _fingerprint(self, files): + return (CACHE_VERSION, len(files), + max((os.path.getmtime(f) for f in files), default=0.0), + sum(os.path.getsize(f) for f in files)) + + def _load_cache(self, path, files): + if not os.path.exists(path): + return False + try: + import pickle + with open(path, 'rb') as fh: + fp, caps = pickle.load(fh) + except Exception: + return False + if fp != self._fingerprint(files): + return False + self.caps = [Capture(*t) for t in caps] + return True + + def _save_cache(self, path, files): + try: + import pickle + payload = (self._fingerprint(files), + [(c.cid, c.lo, c.size, c.data, c.exec_pcs) + for c in self.caps]) + tmp = path + '.tmp' + with open(tmp, 'wb') as fh: + pickle.dump(payload, fh, protocol=4) + os.replace(tmp, path) + except Exception as exc: + print(f'# cache write failed: {exc}', file=sys.stderr) + + def _index_phys(self): + self._by_phys = defaultdict(list) + for c in self.caps: + self._by_phys[c.lo & 0x1fffffff].append(c) + + def _parse(self, files, want_exec): + for path in files: + cid = os.path.splitext(os.path.basename(path))[0] + try: + entries = json.load(open(path)) + except Exception as exc: # corrupt/partial write + print(f'# skipping {cid}: {exc}', file=sys.stderr) + continue + if isinstance(entries, dict): + entries = [entries] + for e in entries: + try: + lo = int(e['load_addr'], 16) + data = base64.b64decode(e['bytes_b64']) + except (KeyError, ValueError): + continue + pcs = frozenset() + if want_exec: + acc = set() + for p in e.get('executed_pcs', ()): + try: + acc.add(int(p, 16) & 0x1fffffff) + except ValueError: + pass + pcs = frozenset(acc) + self.caps.append(Capture(cid, lo, int(e.get('size', len(data))), + data, pcs)) + + def covering(self, va): + phys = va & 0x1fffffff + out = [] + for c in self.caps: + off = phys - (c.lo & 0x1fffffff) + if 0 <= off and off + 4 <= len(c.data): + out.append(c) + return out + + def variants(self, va): + """{word: [captures]} for every capture that covers va.""" + out = defaultdict(list) + for c in self.covering(va): + w = struct.unpack_from('<I', c.data, + (va & 0x1fffffff) - (c.lo & 0x1fffffff))[0] + out[w].append(c) + return out + + def executed(self, va): + phys = va & 0x1fffffff + return [c for c in self.caps if phys in c.exec_pcs] + + def iter_words(self, exec_only=False): + """Yield (va, word, capture) over every code word in every capture.""" + for c in self.caps: + n = len(c.data) & ~3 + words = struct.unpack_from(f'<{n // 4}I', c.data, 0) + seg = c.lo & ~0x1fffffff + if exec_only: + for p in sorted(c.exec_pcs): + off = p - (c.lo & 0x1fffffff) + if 0 <= off and off + 4 <= n: + yield seg | p, words[off // 4], c + else: + for i, w in enumerate(words): + yield c.lo + i * 4, w, c + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_index(cs, args): + print(f'captures : {len(cs.caps)}') + lows = sorted({c.lo for c in cs.caps}) + print(f'distinct bases: {len(lows)}') + exec_total = sum(len(c.exec_pcs) for c in cs.caps) + print(f'executed pcs : {exec_total} (non-unique across captures)') + print() + print(f'{"base":<12}{"variants":>9}{"size":>9}{"execd":>8}') + for lo in lows: + group = [c for c in cs.caps if c.lo == lo] + sizes = sorted({c.size for c in group}) + nex = sum(1 for c in group if c.exec_pcs) + sz = sizes[0] if len(sizes) == 1 else f'{sizes[0]}..{sizes[-1]}' + print(f'0x{lo:08X}{len(group):>9}{str(sz):>9}{nex:>8}') + + +def _fmt_caps(caps, limit=6): + ids = [c.cid[:8] for c in caps] + if len(ids) > limit: + return ', '.join(ids[:limit]) + f', +{len(ids) - limit} more' + return ', '.join(ids) + + +def cmd_word(cs, args): + for va in args.vas: + vs = cs.variants(va) + ex = cs.executed(va) + print(f'=== 0x{va:08X} ===') + if not vs: + print(' NOT COVERED by any capture') + continue + print(f' covered by {sum(len(v) for v in vs.values())} capture(s), ' + f'{len(vs)} distinct word(s), executed in {len(ex)} capture(s)') + phys = va & 0x1fffffff + for w, caps in sorted(vs.items(), key=lambda kv: -len(kv[1])): + nex = sum(1 for c in caps if phys in c.exec_pcs) + print(f' 0x{w:08X} n={len(caps):<4} exec={nex:<4} {dis(va, w)}') + print(f' {_fmt_caps(caps)}') + print() + + +def cmd_cover(cs, args): + for va in args.vas: + caps = cs.covering(va) + print(f'0x{va:08X}: {len(caps)} capture(s)') + bases = defaultdict(int) + for c in caps: + bases[(c.lo, c.size)] += 1 + for (lo, size), n in sorted(bases.items()): + print(f' base 0x{lo:08X} size {size:<8} x{n}') + + +def _listing(cs, va, n, variant_words=None): + """Print one listing per distinct byte-sequence covering [va, va+4n).""" + caps = cs.covering(va) + if not caps: + print(f'0x{va:08X}: NOT COVERED') + return + groups = defaultdict(list) + for c in caps: + off = (va & 0x1fffffff) - (c.lo & 0x1fffffff) + blob = c.data[off:off + 4 * n] + groups[blob].append(c) + for blob, gcaps in sorted(groups.items(), key=lambda kv: -len(kv[1])): + print(f'--- variant: {len(gcaps)} capture(s) ' + f'[{_fmt_caps(gcaps, 4)}] base 0x{gcaps[0].lo:08X} ---') + execd = set() + for c in gcaps: + execd |= c.exec_pcs + cnt = len(blob) // 4 + words = struct.unpack_from(f'<{cnt}I', blob, 0) + for i, w in enumerate(words): + pc = va + i * 4 + mark = 'x' if (pc & 0x1fffffff) in execd else ' ' + print(f' {mark} 0x{pc:08X}: {w:08X} {dis(pc, w)}') + print() + + +def cmd_dis(cs, args): + for va in args.vas: + print(f'=== dis 0x{va:08X} n={args.n} ===') + _listing(cs, va, args.n) + + +def cmd_func(cs, args): + """Walk back to a plausible prologue, then forward past the return.""" + for va in args.vas: + caps = cs.covering(va) + if not caps: + print(f'0x{va:08X}: NOT COVERED') + continue + c = max(caps, key=lambda x: len(x.exec_pcs)) + base = c.lo + off = (va & 0x1fffffff) - (base & 0x1fffffff) + words = struct.unpack_from(f'<{len(c.data) // 4}I', c.data, 0) + i = off // 4 + # backwards: first `addiu $sp,$sp,-N` (0x27BD****, negative imm) + start = i + for j in range(i, max(0, i - args.back), -1): + w = words[j] + if (w & 0xffff0000) == 0x27bd0000 and (w & 0x8000): + start = j + break + else: + start = max(0, i - args.back) + # forwards: first `jr $ra` plus its delay slot + end = min(len(words) - 1, i + args.n) + for j in range(i, min(len(words), i + args.n * 4)): + if words[j] == 0x03e00008: + end = j + 1 + break + print(f'=== func containing 0x{va:08X} -> ' + f'0x{base + start * 4:08X}..0x{base + end * 4:08X} ===') + _listing(cs, base + start * 4, end - start + 1) + + +def cmd_scan(cs, args): + want = set(args.op) + lo = args.imm_min + hi = args.imm_max + seen = defaultdict(lambda: defaultdict(int)) + for va, w, c in cs.iter_words(exec_only=args.exec_only): + if mnemonic(w) not in want: + continue + im = imm_of(w) + if lo is not None and (im is None or im < lo): + continue + if hi is not None and (im is None or im > hi): + continue + seen[va][w] += 1 + print(f'{len(seen)} matching site(s)') + for va in sorted(seen): + for w, n in sorted(seen[va].items(), key=lambda kv: -kv[1]): + print(f'0x{va:08X} {w:08X} n={n:<5} {dis(va, w)}') + + +def cmd_imm(cs, args): + target = args.value + seen = defaultdict(lambda: defaultdict(int)) + for va, w, c in cs.iter_words(exec_only=args.exec_only): + if imm_of(w) == target: + seen[va][w] += 1 + print(f'{len(seen)} site(s) with immediate 0x{target:04X}') + for va in sorted(seen): + for w, n in sorted(seen[va].items(), key=lambda kv: -kv[1]): + print(f'0x{va:08X} {w:08X} n={n:<5} {dis(va, w)}') + + +def cmd_window(cs, args): + """Find the centered-unsigned-window idiom. + + A PS1 visibility/activation gate is almost always written as + + subu rT, rRef, rObj # signed coordinate delta + addiu rT, rT, BIAS # recentre it <- bias site + andi rT, rT, 0xFFFF # truncate back to 16-bit (optional) + sltiu rD, rT, SPAN # one unsigned compare == |delta| < SPAN/2 + # <- range site + + which is exactly what `[widescreen.cull] bias_sites` / `range_sites` widen + (FUN_80069b6c is the canonical instance). Instructions between the bias and + the compare are allowed only when they *carry* the value -- a same-register + `andi`/`addiu`/`sll`/`sra`/`or` -- so a genuine redefinition still breaks the + pair. This reports every such pair in the capture set so a candidate gate + can be found without knowing the function up front, and so no second variant + at the same address is missed. + """ + def carries(v, dst): + """True if v keeps dst live as a transform of itself.""" + op = (v >> 26) & 0x3f + if op in (8, 9, 12, 13, 14): # addi/addiu/andi/ori/xori + return ((v >> 16) & 0x1f) == dst and ((v >> 21) & 0x1f) == dst + if op == 0 and (v & 0x3f) in (0x00, 0x02, 0x03): # sll/srl/sra + return ((v >> 11) & 0x1f) == dst and ((v >> 16) & 0x1f) == dst + if op == 0 and (v & 0x3f) in (0x21, 0x25): # addu/or (reg move-ish) + return ((v >> 11) & 0x1f) == dst and dst in (((v >> 21) & 0x1f), + ((v >> 16) & 0x1f)) + return False + + def writes(v, dst): + op = (v >> 26) & 0x3f + if op == 0: + fn = v & 0x3f + if fn in (0x08, 0x0c, 0x0d): + return False + if fn in (0x11, 0x13, 0x18, 0x19, 0x1a, 0x1b): + return False + return ((v >> 11) & 0x1f) == dst + if op in (2, 3, 4, 5, 6, 7, 0x10, 0x12): + return False + if op in (40, 41, 42, 43, 46): # stores + return False + return ((v >> 16) & 0x1f) == dst + + cmp_ops = {11: 'sltiu'} if not args.signed else {10: 'slti', 11: 'sltiu'} + pairs = defaultdict(lambda: defaultdict(int)) + for c in cs.caps: + n = len(c.data) & ~3 + cnt = n // 4 + words = struct.unpack_from(f'<{cnt}I', c.data, 0) + base = c.lo + for i, w in enumerate(words): + if (w >> 26) & 0x3f != 9: # addiu = bias + continue + bias = w & 0xffff + if not (args.bias_min <= bias <= args.bias_max): + continue + dst = (w >> 16) & 0x1f + if dst == 0: + continue + for j in range(i + 1, min(cnt, i + 1 + args.gap + 1)): + v = words[j] + op = (v >> 26) & 0x3f + if op in cmp_ops and ((v >> 21) & 0x1f) == dst: + span = v & 0xffff + if args.span_min <= span <= args.span_max: + pairs[(base + i * 4, base + j * 4)][(w, v)] += 1 + break + if carries(v, dst): + continue + if writes(v, dst): + break + print(f'{len(pairs)} addiu/sltiu window pair(s) ' + f'bias 0x{args.bias_min:X}..0x{args.bias_max:X} ' + f'span 0x{args.span_min:X}..0x{args.span_max:X} gap<={args.gap}') + print() + for (ba, sa) in sorted(pairs): + variants = pairs[(ba, sa)] + tot = sum(variants.values()) + execd = sum(1 for c in cs.caps + if (ba & 0x1fffffff) in c.exec_pcs + or (sa & 0x1fffffff) in c.exec_pcs) + print(f'0x{ba:08X} -> 0x{sa:08X} captures={tot:<5} execd={execd:<5} ' + f'variants={len(variants)}') + for (w, v), n in sorted(variants.items(), key=lambda kv: -kv[1]): + print(f' n={n:<5} bias {w:08X} {dis(ba, w)}') + print(f' {"":<7} span {v:08X} {dis(sa, v)}') + + +def cmd_ramimage(cs, args): + """Composite the capture set into a flat guest-RAM image for Ghidra. + + Overlay code only exists in RAM at runtime, so a static EXE import cannot see + it -- every interesting gate in a heavily-overlaid title lives outside the + boot EXE's text range. The capture set *is* an observed record of that RAM, + so flattening it produces an image a decompiler can chew on. + + Where several overlays mapped different bodies to the same address the winner + is the variant seen executing in the most captures (ties broken by capture + count). Every such address is reported in the conflict manifest so an + analysis built on this image can never silently assume it saw the only + version -- use `word <VA>` to inspect the alternatives. + """ + lo, hi = args.base, args.base + args.size + seg = args.base & 0xe0000000 + + # Tally every observation first, then elect -- electing while accumulating + # makes the counts depend on capture order, which is how a "winner" can end + # up undercounted and silently wrong. + single = {} # idx -> [word, exec_captures, captures] (one variant so far) + multi = {} # idx -> {word: [exec_captures, captures]} (>1 variant seen) + for c in cs.caps: + seg_lo = c.lo & 0x1fffffff + n = len(c.data) & ~3 + words = struct.unpack_from(f'<{n // 4}I', c.data, 0) + for k, w in enumerate(words): + phys = seg_lo + k * 4 + va = seg | phys + if va < lo or va + 4 > hi: + continue + idx = va - lo + e = 1 if phys in c.exec_pcs else 0 + d = multi.get(idx) + if d is not None: + slot = d.setdefault(w, [0, 0]) + slot[0] += e + slot[1] += 1 + continue + cur = single.get(idx) + if cur is None: + single[idx] = [w, e, 1] + elif cur[0] == w: + cur[1] += e + cur[2] += 1 + else: + multi[idx] = {cur[0]: [cur[1], cur[2]], w: [e, 1]} + del single[idx] + + image = bytearray(args.size) + for idx, (w, _e, _n) in single.items(): + struct.pack_into('<I', image, idx, w) + conflicts = {} + for idx, d in multi.items(): + win = max(d.items(), key=lambda kv: (kv[1][0], kv[1][1]))[0] + struct.pack_into('<I', image, idx, win) + conflicts[lo + idx] = d + + covered = len(single) + len(multi) + with open(args.out, 'wb') as fh: + fh.write(image) + print(f'wrote {args.out} base=0x{args.base:08X} size=0x{args.size:X}') + print(f'covered words: {covered} / {args.size // 4} ' + f'({100.0 * covered * 4 / args.size:.1f}% of the image)') + print(f'conflicting addresses: {len(conflicts)}') + if args.manifest: + with open(args.manifest, 'w') as fh: + fh.write('# addresses where overlays disagree; winner listed first\n') + fh.write('# VA word exec_captures captures disasm\n') + for va in sorted(conflicts): + ranked = sorted(conflicts[va].items(), + key=lambda kv: (-kv[1][0], -kv[1][1])) + for w, (we, wc) in ((k, tuple(v)) for k, v in ranked): + fh.write(f'0x{va:08X} {w:08X} {we:<6} {wc:<6} ' + f'{dis(va, w)}\n') + fh.write('\n') + print(f'wrote conflict manifest {args.manifest}') + + +def cmd_callers(cs, args): + for va in args.vas: + tgt = (va >> 2) & 0x3ffffff + hits = defaultdict(lambda: defaultdict(int)) + for site, w, c in cs.iter_words(exec_only=args.exec_only): + op = (w >> 26) & 0x3f + if op in (2, 3) and (w & 0x3ffffff) == tgt: + hits[site][w] += 1 + print(f'=== callers of 0x{va:08X}: {len(hits)} site(s) ===') + for site in sorted(hits): + for w, n in hits[site].items(): + print(f'0x{site:08X} {w:08X} n={n:<5} {dis(site, w)}') + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('-d', '--dir', required=True, + help='overlay_captures.json.d directory') + ap.add_argument('--no-cache', action='store_true', + help='re-parse the JSONs instead of using .overlay_xref.cache') + sub = ap.add_subparsers(dest='cmd', required=True) + + def va_list(p): + p.add_argument('vas', nargs='+', type=lambda s: int(s, 16)) + + sub.add_parser('index').set_defaults(fn=cmd_index) + p = sub.add_parser('word'); va_list(p); p.set_defaults(fn=cmd_word) + p = sub.add_parser('cover'); va_list(p); p.set_defaults(fn=cmd_cover) + p = sub.add_parser('dis'); va_list(p) + p.add_argument('-n', type=int, default=32); p.set_defaults(fn=cmd_dis) + p = sub.add_parser('func'); va_list(p) + p.add_argument('-n', type=int, default=160) + p.add_argument('--back', type=int, default=400); p.set_defaults(fn=cmd_func) + p = sub.add_parser('scan') + p.add_argument('--op', nargs='+', required=True) + p.add_argument('--imm-min', type=lambda s: int(s, 0)) + p.add_argument('--imm-max', type=lambda s: int(s, 0)) + p.add_argument('--exec-only', action='store_true'); p.set_defaults(fn=cmd_scan) + p = sub.add_parser('imm') + p.add_argument('value', type=lambda s: int(s, 0)) + p.add_argument('--exec-only', action='store_true'); p.set_defaults(fn=cmd_imm) + p = sub.add_parser('window') + p.add_argument('--bias-min', type=lambda s: int(s, 0), default=0x10) + p.add_argument('--bias-max', type=lambda s: int(s, 0), default=0x7fff) + p.add_argument('--span-min', type=lambda s: int(s, 0), default=0x20) + p.add_argument('--span-max', type=lambda s: int(s, 0), default=0x7fff) + p.add_argument('--gap', type=int, default=4, + help='max instructions between the addiu and the sltiu') + p.add_argument('--signed', action='store_true', + help='also accept a signed slti as the compare') + p.set_defaults(fn=cmd_window) + p = sub.add_parser('ramimage') + p.add_argument('out', help='output flat RAM image path') + p.add_argument('--base', type=lambda s: int(s, 0), default=0x80000000) + p.add_argument('--size', type=lambda s: int(s, 0), default=0x200000) + p.add_argument('--manifest', help='write a conflicting-address report here') + p.set_defaults(fn=cmd_ramimage) + p = sub.add_parser('callers'); va_list(p) + p.add_argument('--exec-only', action='store_true'); p.set_defaults(fn=cmd_callers) + + args = ap.parse_args() + cs = CaptureSet(args.dir, cache=not args.no_cache) + args.fn(cs, args) + + +if __name__ == '__main__': + main() diff --git a/tools/psxmod_pack.py b/tools/psxmod_pack.py new file mode 100644 index 00000000..65f0eac5 --- /dev/null +++ b/tools/psxmod_pack.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Create a deterministic .psxmod ZIP from a package source directory.""" + +from __future__ import annotations + +import argparse +import zipfile +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + manifest = args.source / "manifest.toml" + if not manifest.is_file(): + parser.error("source must contain manifest.toml") + files = sorted(path for path in args.source.rglob("*") if path.is_file()) + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED, + compresslevel=9) as archive: + for path in files: + info = zipfile.ZipInfo(path.relative_to(args.source).as_posix()) + info.date_time = (1980, 1, 1, 0, 0, 0) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, path.read_bytes(), compresslevel=9) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())