rollback patches

This commit is contained in:
Alex Vanderveen
2026-08-01 14:13:58 -04:00
parent fe403e6ce1
commit 4ddd2d01b9
47 changed files with 9941 additions and 326 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Build psxrecomp emitters
description: Configure and build psxrecomp-game + psxrecomp-bios via tools/ci/build_emitters.sh
inputs:
framework:
description: Path to the psxrecomp checkout
required: false
default: psxrecomp
build-dir:
description: Out-of-tree build directory for the emitters
required: false
default: build-recompiler
toolchain-dir:
description: >
Portable cmake-clang-v1 root (llvm-mingw on Windows). Empty uses
PSXRECOMP_TOOLCHAIN_DIR / TOOLCHAIN_DIR from the environment.
required: false
default: ''
runs:
using: composite
steps:
- shell: bash
run: |
set -euo pipefail
SCRIPT="${{ inputs.framework }}/tools/ci/build_emitters.sh"
if [[ ! -f "${SCRIPT}" ]]; then
echo "::error::missing ${SCRIPT}"
exit 1
fi
chmod +x "${SCRIPT}"
args=(
--framework "${{ inputs.framework }}"
--build-dir "${{ inputs.build-dir }}"
)
if [[ -n "${{ inputs.toolchain-dir }}" ]]; then
args+=(--toolchain-dir "${{ inputs.toolchain-dir }}")
fi
bash "${SCRIPT}" "${args[@]}"
@@ -0,0 +1,51 @@
name: Fetch psxrecomp portable toolchain
description: >
Download cmake-clang-v1 from retcomm-toolchains and export TOOLCHAIN_DIR /
PSXRECOMP_TOOLCHAIN_DIR / BPE_TOOLCHAIN_DIR for later packaging steps.
inputs:
artifact:
description: Platform pack id (linux-x64, windows-x64, macos-arm64, macos-x64)
required: true
framework:
description: Path to the psxrecomp checkout (contains tools/fetch_toolchain.sh)
required: false
default: psxrecomp
dl-dir:
description: Download cache directory
required: false
default: .cache/toolchain-dl
out-dir:
description: Unpack directory
required: false
default: .cache/toolchain-pack
repo:
description: GitHub owner/name for toolchain releases
required: false
default: TechnicallyComputers/retcomm-toolchains
outputs:
toolchain-dir:
description: Absolute path to the unpacked toolchain root (bin/ present)
value: ${{ steps.fetch.outputs.toolchain-dir }}
runs:
using: composite
steps:
- id: fetch
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
SCRIPT="${{ inputs.framework }}/tools/fetch_toolchain.sh"
if [[ ! -f "${SCRIPT}" ]]; then
echo "::error::missing ${SCRIPT}"
exit 1
fi
chmod +x "${SCRIPT}"
bash "${SCRIPT}" \
--artifact "${{ inputs.artifact }}" \
--dl-dir "${{ inputs.dl-dir }}" \
--out-dir "${{ inputs.out-dir }}" \
--repo "${{ inputs.repo }}"
@@ -0,0 +1,71 @@
name: Stage psxrecomp setup SDK
description: >
Copy emitters, OpenBIOS profiles, retcomm-sdk.json, portable toolchain, and
Windows MinGW runtime DLLs into an existing setup-host stage directory.
inputs:
stage:
description: Absolute or workspace-relative stage directory
required: true
framework:
description: Path to the psxrecomp checkout
required: false
default: psxrecomp
sdk-overlay:
description: Legacy optional overlay (prefer CLI/tools inside the submodule)
required: false
default: ''
recompiler-build:
description: Build dir containing psxrecomp-game / psxrecomp-bios
required: false
default: build-recompiler
toolchain-dir:
description: Unpacked toolchain root (defaults to env PSXRECOMP_TOOLCHAIN_DIR / TOOLCHAIN_DIR / BPE_TOOLCHAIN_DIR)
required: false
default: ''
host-exe:
description: Optional host .exe path for Windows DLL bundling
required: false
default: ''
runtime-bin:
description: MinGW bin dir for runtime DLLs (Windows)
required: false
default: ''
allow-no-toolchain:
description: If true, warn instead of failing when toolchain is unset
required: false
default: 'false'
runs:
using: composite
steps:
- shell: bash
run: |
set -euo pipefail
SCRIPT="${{ inputs.framework }}/tools/stage_setup_sdk.sh"
if [[ ! -f "${SCRIPT}" ]]; then
echo "::error::missing ${SCRIPT}"
exit 1
fi
chmod +x "${SCRIPT}"
args=(
--stage "${{ inputs.stage }}"
--framework "${{ inputs.framework }}"
--recompiler-build "${{ inputs.recompiler-build }}"
)
if [[ -n "${{ inputs.sdk-overlay }}" ]]; then
args+=(--sdk-overlay "${{ inputs.sdk-overlay }}")
fi
if [[ -n "${{ inputs.toolchain-dir }}" ]]; then
args+=(--toolchain-dir "${{ inputs.toolchain-dir }}")
fi
if [[ "${{ inputs.allow-no-toolchain }}" == "true" ]]; then
args+=(--allow-no-toolchain)
fi
if [[ -n "${{ inputs.host-exe }}" ]]; then
args+=(--host-exe "${{ inputs.host-exe }}")
fi
if [[ -n "${{ inputs.runtime-bin }}" ]]; then
args+=(--runtime-bin "${{ inputs.runtime-bin }}")
fi
bash "${SCRIPT}" "${args[@]}"
+11 -9
View File
@@ -16,20 +16,22 @@ New to the codebase? Read [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and
(`recompiler/`), the runtime engine (`runtime/`), shared tools, and docs.
Framework changes go here.
- **Per-game repos** (`mstan/TombaRecomp`, `mstan/MegaManX6Recomp`,
`mstan/ApeEscapeRecomp`, …) — one per title. Each contains only that game's
config, seeds, and build glue and links this framework in as a submodule at
`psxrecomp/`. Game-specific work goes in the game repo; nothing game-specific
belongs in the framework.
`mstan/ApeEscapeRecomp`, …) — one per title. Each keeps **game code at the
repo root** and pins this framework (and usually `recomp-ui`) as **root-level
submodules** (`psxrecomp/`, `recomp-ui/`). Game-specific work goes in the game
repo; nothing game-specific belongs in the framework.
Each game repo pins an **exact framework commit** — the git submodule pointer at
`psxrecomp/` (some older game repos also carry a human-readable `psxrecomp-v4.pin`
record; the submodule pointer is the source of truth). The framework evolves on
its own cadence; a game only moves to a newer framework when someone deliberately
bumps that submodule pointer. See
[Linking the framework](docs/BUILDING.md#linking-the-framework) for the submodule
+ local-junction dev setup, and
[Framework changes and the pin](#framework-changes-and-the-pin) below for how a
framework change actually reaches a game.
bumps that submodule pointer.
**Starting a new title or shipping a setup-host zip?** Follow
[`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md) (layout, CI template
under `docs/ci/templates/setup-release.yml`, release checklist). Also see
[Linking the framework](docs/BUILDING.md#linking-the-framework) and
[Framework changes and the pin](#framework-changes-and-the-pin) below.
## The core rules
+189 -17
View File
@@ -55,8 +55,10 @@ standalone BIOS runtime supporting OpenBIOS and a compatible retail BIOS** —
see [Release Package](#release-package) below.
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.
[`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md) (submodules,
setup-host CI template, release checklist), then
[`CONTRIBUTING.md`](CONTRIBUTING.md). Community projects are listed here
alongside the rest.
## What It Is
@@ -95,15 +97,22 @@ Three things sit on that foundation:
execution.
PSXRecomp is a **framework**. Game-specific projects live in their own
repositories and link this one in as a **git submodule** to build a game binary.
repositories with **`psxrecomp/` and `recomp-ui/` as root-level submodules**
and game code (`game.toml`, seeds, CMake) at the repo root. See
[`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md).
**New here?** The fastest way in:
[`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),
[`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) (versioned runtime mods),
[`CONTRIBUTING.md`](CONTRIBUTING.md).
| Path | Doc |
|------|-----|
| How a game runs | [`docs/EXECUTION_MODEL.md`](docs/EXECUTION_MODEL.md) |
| Architecture | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) |
| Build the framework | [`docs/BUILDING.md`](docs/BUILDING.md) |
| **Ship a game repo** (submodules + CI + release checklist) | [`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md) |
| Setup-host CI template | [`docs/ci/templates/setup-release.yml`](docs/ci/templates/setup-release.yml) |
| Local Generate & rebuild CLI | [`docs/LOCAL_CODEGEN_SDK.md`](docs/LOCAL_CODEGEN_SDK.md) |
| Mods | [`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) |
| Contributing | [`CONTRIBUTING.md`](CONTRIBUTING.md) |
## Which PlayStation BIOS does it use?
@@ -180,10 +189,170 @@ turned off as easily as it was turned on.
<img src="docs/assets/ui/mod-launcher.png" alt="The mods panel in the launcher" width="820">
</p>
Tomba! ships three today — widescreen, skip-FMV, and a warp debug menu that
re-enables the developers' own hidden menu. Full format in
### Add a mod
Start with a source directory containing a manifest and, when needed, payload
files:
```text
my-mod/
├── manifest.toml
├── README.txt
└── assets/
└── replacement.bin
```
A minimal guarded executable patch looks like this:
```toml
format_version = 1
id = "example.quick-start"
version = "1.0.0"
name = "Quick-start example"
author = "Your name"
description = "One independently toggleable gameplay change."
resolver = "declarative"
save_compatibility = "shared"
[[target]]
game_id = "SLUS-00000"
# Replace this with the SHA-256 of the exact supported stock disc image.
disc_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
[[feature]]
id = "quick-start"
name = "Quick Start"
description = "Skips the game's startup delay."
group = "Gameplay"
default_enabled = false
[[patch]]
feature = "quick-start"
target = "main_exe"
address = 0x80041234
expected = "2a 00 02 24"
replace = "00 00 02 24"
```
`main_exe` addresses are PSX guest virtual addresses. The complete `expected`
bytes are checked after the BIOS loads the executable and before any write is
made, so a package fails closed on the wrong revision. For disc changes use
`disc_raw` or `disc_user`; for a larger asset use a file-backed `[[overlay]]`
with payload and expected-range SHA-256 hashes.
From a PSXRecomp checkout, pack the directory into a deterministic archive:
```sh
python tools/psxmod_pack.py my-mod my-mod-1.0.0.psxmod
```
Install the resulting `.psxmod` through the launcher's Mods manager. A game can
also ship reviewed, default-disabled packages by placing unpacked sources at
`mods/preloaded/packages/<package-id>/<version>/` and copying
`mods/preloaded` beside the game executable as `mods`; see Tomba's build wiring
below.
Choose the narrowest mechanism that describes the change:
| Change | Package mechanism |
|---|---|
| Fixed code or data bytes | Guarded `[[patch]]` on `main_exe`, `disc_raw`, or `disc_user` |
| A player-selectable boolean, choice, or number | Feature-local `[[option]]` plus `when`, `replace_from`, sparse `fields`, or `when_integer` |
| Artwork, script, audio, or another large disc asset | Hashed file-backed `[[overlay]]`; do not rebuild the player's stock image |
| Host setting or live game behavior | Trusted static `[[plugin]]`, compiled into the game and selected by a stable id |
| Several features composing one shared table, bitfield, routine, or allocation | Game-owned `resolver = "builtin:<id>"`, only when declarative operations cannot express the composition |
Format versions 2–4 add bounded integers, ordered constraints, linked MIPS
`LUI`/`ORI` values, sparse owned fields, and integer predicates. Format 5 adds
trusted static plugins. The full schemas and resolution rules are in
[`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md).
The enabled feature set is resolved before boot. Changing it may require a
relaunch, but never asks the player to patch a disc or compile the game.
### Trusted game code
A `.psxmod` never supplies or loads native code. If a feature needs a per-VBlank
callback or must configure a host-side facility, the game registers an
implementation that is already linked into its executable:
```c
#include "mod_plugins.h"
static void example_vblank(void) {
if (psx_mod_game_started())
psx_mod_write_byte(0x1F8001B4u, 1u);
}
PSX_MOD_CONSTRUCTOR(register_example_mod) {
(void)psx_mod_register_vblank_plugin(
"example.quick-start", example_vblank);
}
```
The manifest activates it with:
```toml
format_version = 5
[[plugin]]
feature = "quick-start"
id = "example.quick-start"
```
Activation callbacks are for one-time pre-renderer configuration; VBlank
callbacks are deterministic guest-time work. Plugins receive only the narrow
services in
[`runtime/include/mod_plugins.h`](runtime/include/mod_plugins.h), can read
validated feature options, and are selected by registry id—not by a library
path or symbol supplied by an archive. Prefer ordinary patches and overlays
whenever they are enough.
### Converting an existing patch or patcher
Large legacy mod suites should be converted feature by feature:
1. Pin the exact clean disc revision and keep the original patcher or patched
output as a byte-for-byte **test oracle**, not as the runtime payload.
2. Split its UI into independent features and typed, feature-local options.
Enabling one feature must not silently select another.
3. Map each owned change back to the stock image: executable writes become
guarded `main_exe` patches, small disc writes become guarded disc patches,
and large assets become content-addressed overlays.
4. Use sparse fields when features own adjacent bytes in one record. Introduce
a trusted built-in resolver only when independent selections must synthesize
one shared semantic object.
5. Test every feature alone, representative combinations, all-off identity,
wrong-revision rejection, collision diagnostics, and byte parity with the
oracle. Record authorship and redistribution permission for every imported
asset.
Do not ship a prepatched disc or use the legacy `derived_disc`/VCDIFF path for
new packages. It remains conversion scaffolding only.
### Real examples
- [Tomba's merged catalog](https://github.com/mstan/TombaRecomp/tree/master/mods/preloaded/packages)
is the compact starting point. Its
[trusted plugins](https://github.com/mstan/TombaRecomp/tree/master/src/mods)
show activation callbacks, guest-VBlank behavior, launcher options, display
features, controller policy, and skip-FMV behavior; its
[CMake file](https://github.com/mstan/TombaRecomp/blob/master/CMakeLists.txt)
shows how to compile the plugin sources and preload the package catalog.
- [Mega Man X6's unmerged Tweaks package branch](https://github.com/mstan/MegaManX6Recomp/tree/integrate/mmx6-mod-packages-current)
is the large conversion example. The
[package generators](https://github.com/mstan/MegaManX6Recomp/tree/integrate/mmx6-mod-packages-current/tools)
preserve patcher parity while producing guarded stock-targeted operations and
content-addressed assets; the
[trusted resolvers](https://github.com/mstan/MegaManX6Recomp/tree/integrate/mmx6-mod-packages-current/src/mods)
demonstrate deterministic composition of shared records and injected code.
The earlier
[`feature/mmx6-tweaks`](https://github.com/mstan/MegaManX6Recomp/tree/feature/mmx6-tweaks)
branch records the patcher-parity and write-classification work that feeds
this conversion; its patched-disc/pre-bake experiments are history, not the
recommended package delivery model. Both branches are review material rather
than a released compatibility promise.
## Swap language on the fly
A general localization layer captures the game's own strings out of memory and
@@ -549,13 +718,16 @@ 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.
exact framework and UI commits as root-level submodules.
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,
and how a framework fix reaches a game through its pin. Bugs and build problems go
to GitHub issues (include `gcc -v` / OS / generator for build failures); design
discussion happens in the **R.A.I.D.** Discord (invite below).
- New title / setup-host release:
[`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md)
- Framework PRs: [`CONTRIBUTING.md`](CONTRIBUTING.md) (rules, verification,
regression checklist, how a fix reaches a game through its pin)
Bugs and build problems go to GitHub issues (include `gcc -v` / OS / generator
for build failures); design discussion happens in the **R.A.I.D.** Discord
(invite below).
## License
+46
View File
@@ -0,0 +1,46 @@
# Cross-compile toolchain: Linux host → Windows x86_64 (MinGW-w64).
#
# Usage from a game repo:
# cmake -DCMAKE_TOOLCHAIN_FILE=psxrecomp/cmake/toolchain-mingw-w64.cmake ...
#
# Requires (Arch / CachyOS packages):
# mingw-w64-gcc mingw-w64-sdl2 (and cmake, ninja)
# Debian/Ubuntu equivalents:
# g++-mingw-w64-x86-64 + a MinGW SDL2 (distro or self-built)
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(MINGW_TRIPLE "x86_64-w64-mingw32" CACHE STRING "MinGW target triple")
set(CMAKE_C_COMPILER "${MINGW_TRIPLE}-gcc")
set(CMAKE_CXX_COMPILER "${MINGW_TRIPLE}-g++")
set(CMAKE_RC_COMPILER "${MINGW_TRIPLE}-windres")
set(CMAKE_RANLIB "${MINGW_TRIPLE}-ranlib")
set(CMAKE_AR "${MINGW_TRIPLE}-ar")
set(CMAKE_STRIP "${MINGW_TRIPLE}-strip")
# Prefer the triple-prefixed pkg-config so we do not pick up host Linux SDL2.
find_program(_MINGW_PKG_CONFIG NAMES "${MINGW_TRIPLE}-pkg-config" pkg-config)
if(_MINGW_PKG_CONFIG)
set(PKG_CONFIG_EXECUTABLE "${_MINGW_PKG_CONFIG}" CACHE FILEPATH
"pkg-config for MinGW sysroot" FORCE)
endif()
# Isolate pkg-config from the host's .pc files.
set(ENV{PKG_CONFIG_PATH} "")
if(DEFINED ENV{MINGW_PKG_CONFIG_LIBDIR} AND NOT "$ENV{MINGW_PKG_CONFIG_LIBDIR}" STREQUAL "")
set(ENV{PKG_CONFIG_LIBDIR} "$ENV{MINGW_PKG_CONFIG_LIBDIR}")
elseif(EXISTS "/usr/${MINGW_TRIPLE}/lib/pkgconfig")
set(ENV{PKG_CONFIG_LIBDIR} "/usr/${MINGW_TRIPLE}/lib/pkgconfig")
endif()
set(CMAKE_FIND_ROOT_PATH "/usr/${MINGW_TRIPLE}")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
# Large generated TUs can exceed COFF section limits on older binutils.
string(APPEND CMAKE_C_FLAGS_INIT " -Wa,-mbig-obj")
string(APPEND CMAKE_CXX_FLAGS_INIT " -Wa,-mbig-obj")
+228
View File
@@ -0,0 +1,228 @@
# Bringing up a game project on PSXRecomp
This guide is for **title developers**: you keep game code and config in your
own repository, pin this framework and `recomp-ui` as **root-level submodules**,
and (optionally) ship a **setup-host** zip that users generate from a legal disc
locally — the same model as Bomberman Party Edition / Masters of Teräs Käsi.
Related docs:
| Doc | When |
|-----|------|
| [`BUILDING.md`](BUILDING.md) | Local build of the framework / BIOS runtime |
| [`LOCAL_CODEGEN_SDK.md`](LOCAL_CODEGEN_SDK.md) | CLI contract for Generate & rebuild |
| [`ci/README.md`](ci/README.md) | Shared CI scripts and composite actions |
| [`CONTRIBUTING.md`](../CONTRIBUTING.md) | Framework contribution rules |
---
## Recommended repository layout
Everything game-specific lives at the **root** of *your* title repo. Framework
and UI are submodules next to that code — not nested under each other.
The RetComM / Generate & rebuild CLI (`psxrecomp_cli.py`, `tools/prepare_disc.py`,
pack helpers) ships **inside** the `psxrecomp` submodule. There is no separate
`psxrecomp-sdk/` tree.
```text
YourGameRecomp/ # your git repo
├── .gitmodules
├── CMakeLists.txt # thin: psxrecomp_add_game_runtime(...)
├── game.toml # disc / recompiler / runtime config
├── VERSION # release pin (e.g. 0.1.0)
├── seeds/ # function-start seeds for psxrecomp-game
├── codegen_setup.c / .h # title PsxrecompCodegenHostConfig (+ apply hooks)
├── launcher_assets/ # boxart / fonts / brand (recomp-ui)
├── scripts/
│ └── package_setup_release.sh # thin wrapper → package_setup_host.sh
├── .github/workflows/
│ └── release.yml # from docs/ci/templates/setup-release.yml
├── psxrecomp/ # submodule → framework + CLI + codegen host + CI tools
│ └── host/psxrecomp_codegen_host.*
├── recomp-ui/ # submodule → launcher UI
├── generated/ # local only — gitignore (not in CI setup zip)
└── prepared_disc/ / disc/ # local disc working tree — gitignore
```
Minimal `CMakeLists.txt` shape:
```cmake
set(PSXRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp")
include("${PSXRECOMP_ROOT}/runtime/runtime.cmake")
psxrecomp_add_game_runtime(psx-runtime
ENABLE_NETPLAY_IF_PRESENT
WINDOW_TITLE "My Game Recompiled"
GEN_MARKER "generated/SLUS_01234_dispatch.c"
GEN_FULL_GLOB "generated/SLUS_01234_full_*.c"
CODEGEN_SETUP_SOURCES codegen_setup.c
DEFAULT_GAME_CONFIG_PATH "game.toml"
LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga"
)
```
### Add the submodules
```bash
cd YourGameRecomp
git submodule add https://github.com/mstan/psxrecomp.git psxrecomp
git submodule add https://github.com/TechnicallyComputers/recomp-ui.git recomp-ui
git submodule update --init --recursive
```
Pin **exact commits** (do not track `main` floating). Bump deliberately when you
want a newer framework or UI.
Clone for contributors:
```bash
git clone --recurse-submodules https://github.com/you/YourGameRecomp.git
```
---
## Local development loop
1. **Legal disc** — place your dump where `game.toml` expects it (or pass
`--disc` to the CLI / wizard). Never commit disc images or retail BIOS dumps.
2. **Build emitters** (once per machine / after recompiler changes):
```bash
./psxrecomp/tools/ci/build_emitters.sh
```
3. **Generate game (+ OpenBIOS) C** — either the setup wizard, or:
```bash
python3 psxrecomp/psxrecomp_cli.py generate \
--config game.toml --project-root . --disc /path/to/dump.cue
```
4. **Build the playable runtime**:
```bash
cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build-release --target psx-runtime -j"$(nproc)"
```
Details: [`LOCAL_CODEGEN_SDK.md`](LOCAL_CODEGEN_SDK.md), [`BUILDING.md`](BUILDING.md).
---
## Setup-host releases (self-build zip)
CI ships a zip **without** `generated/` game C, retail BIOS dumps, or an
embedded `toolchain/`. The zip includes:
- Setup host exe (`recomp-ui` + generate/rebuild wizard)
- Your game sources (`game.toml`, seeds, CMake, host glue, …)
- `psxrecomp/` (runtime + CLI + OpenBIOS profiles + emitters)
- `recomp-ui/` sources (needed to rebuild)
- On Windows: MinGW runtime DLLs beside the host and emitters
Players (or [RetComM](https://github.com/TechnicallyComputers/RetComM-Launcher))
run Generate & rebuild locally. RetComM / the wizard download
`cmake-clang-v1` from
[retcomm-toolchains](https://github.com/TechnicallyComputers/retcomm-toolchains)
(or accept an offline zip / `RETCOMM_TOOLCHAIN_DIR`). Pass
`--embed-toolchain` to `package_setup_host.sh` only for special offline-first
packs.
Do **not** set `PSX_PGO` in CI. PGO stays user-local when `[pgo] enabled = true`.
---
## Shared tools (use these; do not reimplement)
| Tool | Role |
|------|------|
| `tools/ci/normalize_version.sh` | `vX.Y.Z` → `VERSION` / `TAG` |
| `tools/ci/clear_generated.sh` | Wipe `generated/` for setup-host CI |
| `tools/ci/record_pins.sh` | Log submodule SHAs |
| `tools/ci/build_emitters.sh` | Build `psxrecomp-game` + `psxrecomp-bios` |
| `tools/fetch_toolchain.sh` | Optional download/unpack (embed packs only) |
| `tools/stage_setup_sdk.sh` | Emitters + OpenBIOS + optional `toolchain/` + MinGW DLLs |
| `tools/bundle_mingw_dlls.sh` | Windows runtime DLL copy |
| `tools/package_setup_host.sh` | Full setup-host zip (title args) |
Composite actions (from the game repo after checkout):
```yaml
- uses: ./psxrecomp/.github/actions/build-emitters
# Optional — only when packaging with --embed-toolchain:
# - uses: ./psxrecomp/.github/actions/fetch-toolchain
# with:
# artifact: ${{ matrix.artifact }}
- uses: ./psxrecomp/.github/actions/stage-setup-sdk
with:
stage: dist/stage-setup-${{ matrix.artifact }}
recompiler-build: build-recompiler
allow-no-toolchain: 'true'
```
---
## CI workflow template
1. Copy the template into your title repo:
```bash
mkdir -p .github/workflows scripts
cp psxrecomp/docs/ci/templates/setup-release.yml .github/workflows/release.yml
```
2. Replace every `YOUR_*` placeholder.
3. Add a thin `scripts/package_setup_release.sh` that calls
`psxrecomp/tools/package_setup_host.sh` with your exe name / zip prefix
(see Bomberman Party Edition for an example).
Full action reference: [`ci/README.md`](ci/README.md).
---
## Bundled release checklist
Use this before tagging a setup-host release that matches other titles
(BPE / MotK / RetComM).
### Repository
- [ ] `psxrecomp/` and `recomp-ui/` are root-level submodules on pinned commits
- [ ] CLI lives in the submodule (`psxrecomp/psxrecomp_cli.py`) — no sibling sdk
- [ ] `game.toml` has disc identity (`[prepare_disc]` hashes/sizes) and boot EXE
- [ ] Seeds cover the boot path; `VERSION` matches the release you will tag
- [ ] Disc images, `generated/`, and `SCPH1001.BIN` are gitignored
- [ ] Setup-host CMake path builds with **no** game C and **no** BIOS backends
(e.g. `-DPSXRECOMP_ALLOW_NO_BIOS=ON` + your title’s force-setup option)
- [ ] Thin `codegen_setup.c` + `psxrecomp_add_game_runtime` (codegen host is in
`psxrecomp/host/`; CI may use `-DPSXRECOMP_FORCE_SETUP_HOST=ON`)
- [ ] Optional: start from `docs/ci/templates/game.gitignore`
### Packaging (shared helpers)
- [ ] CI uses `./psxrecomp/.github/actions/build-emitters`
- [ ] Packager calls `psxrecomp/tools/package_setup_host.sh` (lean zip by
default; optional `--embed-toolchain`) or `stage_setup_sdk.sh` after a
custom stage
- [ ] Staged tree includes `psxrecomp/psxrecomp_cli.py`
- [ ] Staged `psxrecomp/bios/` has `OpenBIOS.toml`, `openbios.bin`,
`OpenBIOS.LICENSE`, `SCPH1001.toml` — and **no** retail `.BIN`
- [ ] Windows zip has `libstdc++-6.dll` + `libgcc_s_seh-1.dll` next to both
emitters (and host deps such as `zlib1.dll` next to the exe)
- [ ] Zip mtimes are normalized (packager `touch`) so Ninja does not see
“future” files after extract
### Zip contents smoke test
- [ ] Linux / macOS / Windows artifacts named consistently
(`YOUR_PREFIX-<ver>-<linux-x64|windows-x64|macos-arm64|macos-x64>.zip`)
- [ ] Extract → run host → Generate with a legal disc succeeds end-to-end
- [ ] After rebuild, game launches; saves/settings land beside the exe
- [ ] RetComM install/update (if you publish a catalog entry) uses this **same**
zip — no separate tools pack required
### Publish
- [ ] Tag `vX.Y.Z`; GitHub Release attaches all four platform zips
- [ ] Release notes say the zip is a setup host (no disc / no retail BIOS /
no pre-generated game C)
- [ ] Catalog / RetComM entry points at the release assets when ready
+149
View File
@@ -0,0 +1,149 @@
# Local codegen SDK
Headless contract for regenerating an existing psxrecomp game project from a
user-supplied disc. Intended for `recomp-ui` setup flows and RetComM launcher
automation. This does **not** redistribute disc images.
PGO (optional) runs **only on the user’s machine** during local rebuild when
`game.toml` has `[pgo] enabled = true`. CI must not set `PSX_PGO`.
## Setup host (CI without game/BIOS generated C)
Games can ship a **setup host**: `psx-runtime` linked **without** game C and
**without** BIOS backends (`-DPSXRECOMP_FORCE_SETUP_HOST=ON`, or legacy
`-DBPE_FORCE_SETUP_HOST=ON`) via `psxrecomp_add_game_runtime(...)`. CI never
needs BIOS dumps or private assets. First-run Generate emits OpenBIOS (from
bundled `openbios.bin`) and optional SCPH1001 (player dump), then game C, then
rebuild links everything.
| Piece | Role |
|-------|------|
| Setup exe | `recomp-ui` + codegen host; opens Generate & rebuild |
| Game zip `psxrecomp/` | submodule tree: CLI, tools, emitters, OpenBIOS profiles |
| `psxrecomp_cli.py` | Generate / rebuild / verify-disc (in the submodule) |
| `tools/package_setup_host.sh` | Universal setup-host zip packager |
| `tools/ci/*.sh` | normalize version, clear generated, record pins, build emitters |
| `tools/fetch_toolchain.sh` | Optional: download/unpack `cmake-clang-v1` (CI embed or local) |
| `tools/toolchain_pack.py` | CLI: resolve / download / unpack into `toolchain/` + shared cache |
| `tools/stage_setup_sdk.sh` | Pack: emitters, OpenBIOS checks, optional `toolchain/`, MinGW DLLs |
| `tools/bundle_mingw_dlls.sh` | Windows: copy MinGW runtime DLLs next to host + emitters |
| Project `toolchain/` | Wizard/CLI install target (`bin/cmake`, stamp `.psxrecomp-bin`) |
| Shared toolchain cache | `~/.local/share/psxrecomp/toolchains/cmake-clang-v1/` (or RetComM’s) |
| `docs/ci/` | Composite actions + [`templates/setup-release.yml`](ci/templates/setup-release.yml) |
| `docs/GAME_PROJECT_SETUP.md` | Submodules, CI template usage, bundled-release checklist |
| Game sources | `game.toml`, seeds, `CMakeLists.txt` at repo root; `psxrecomp/`, `recomp-ui/` submodules |
RetComM harvests emitters into the SDK cache and **downloads** `cmake-clang-v1`
(no separate tools zip; game zips stay lean). The setup host installs the
portable pack with **host-native** `curl` + `tar`/`unzip` into
`%LOCALAPPDATA%/psxrecomp/toolchains/…` (or XDG), not through Python — so
Microsoft Store Python AppData redirection cannot hide cmake. Offline builds
accept a zip via the wizard file picker. Generate/rebuild still need Python 3
for `psxrecomp_cli.py`.
## Commands
```bash
python psxrecomp/psxrecomp_cli.py verify-disc \
--config game.toml --disc path/to/dump.bin [--json-progress]
python psxrecomp/psxrecomp_cli.py generate \
--config game.toml --project-root . --disc path/to/dump.iso \
[--bios path/to/SCPH1001.BIN] [--force-bios] \
[--skip-hash-check] [--force-prepare] [--json-progress]
python psxrecomp/psxrecomp_cli.py ensure-toolchain \
[--project-root .] [--from-zip cmake-clang-v1-linux-x64.zip] [--no-download]
python psxrecomp/psxrecomp_cli.py rebuild \
--config game.toml --project-root . \
--build-dir build-release --target psx-runtime \
--exe-basename Bomberman_Party_Edition_Recompiled \
[--disc path/to/game.cue] [--toolchain-zip path/to/pack.zip] \
[--no-toolchain-download] [--no-pgo] [--force-pgo] [--json-progress]
python psxrecomp/psxrecomp_cli.py pgo-train \
--config game.toml --build-dir build-release \
--exe-basename Bomberman_Party_Edition_Recompiled \
[--disc …] [--train-secs 120] [--train-runs 3] [--json-progress]
```
`generate` normalizes the dump via `tools/prepare_disc.py` when needed, then
runs `psxrecomp-game --config game.toml` into `[recompiler] out_dir`.
`rebuild` runs cmake. If `[pgo] enabled = true` (and `--no-pgo` not set), it
instruments, trains (`scenario` / timed boot), then rebuilds with profiles.
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | success |
| 1 | runtime / generation / build failure |
| 2 | usage / argument error |
| 3 | disc verification failure |
## JSONL progress (`--json-progress`)
Stdout is reserved for one JSON object per line. Useful events:
| `event` | Notes |
|---------|--------|
| `phase` | `verify`, `prepare_disc`, `emit`, `build`, `pgo_*`, `done` |
| `disc` | digests after verification |
| `log` | mirrored tool chatter |
| `result` | final payload |
| `error` | `message`, `code` |
## Portable recomp-ui host (`psxrecomp/host/`)
Prefer `psxrecomp_add_game_runtime(...)` — it compiles
`psxrecomp/host/psxrecomp_codegen_host.c` and sets `PSX_HAS_GAME_CODEGEN`.
Titles keep a thin root `codegen_setup.c` that fills
`PsxrecompCodegenHostConfig` and exposes apply/relaunch hooks:
```c
psxrecomp_codegen_host_apply(&gi, &my_cfg);
/* after recomp_launcher_run_window: */
if (lr == RECOMP_LAUNCHER_RESULT_RELAUNCH)
psxrecomp_codegen_host_relaunch_or_exit(disc_path);
```
### Modular `[pgo]` (game.toml) — opt-in
PGO runs only when the title sets `enabled = true`. Framework defaults when
enabled: **60s × 2 runs**, `mute_host_audio = true`, `hide_video = true`.
Games may lengthen trains in their own `game.toml`.
```toml
[pgo]
enabled = true # required to opt in
train_secs = 60 # optional override
train_runs = 2 # optional override
mute_host_audio = true # default: discard SDL output (SPU still runs)
hide_video = true # default: --headless (no on-screen FMV)
scenario = "boot_timed" # title-authored workload hint
```
Omit the section or set `enabled = false` to skip. During train the CLI/UI show
a **WARNING** — do not cancel the process (or close a visible window if
`hide_video = false`).
`hide_video` keeps guest MDEC/FMV decode in the profile but shows nothing on
screen (avoids abrasive/flickering FMV). Use `--pgo-video` only when you need
host present paths in the profile.
### Env overrides
| Env | Role |
|-----|------|
| `PSXRECOMP_PROJECT_ROOT` / game-specific | project root |
| `PSXRECOMP_BUILD_DIR` / game-specific | cmake build dir |
| `PSXRECOMP_FORCE_SETUP` / game-specific | force setup wizard |
| `PSXRECOMP_GAME` | path to `psxrecomp-game` binary |
| `PSX_HOST_MUTE=1` | discard host SDL audio (SPU still runs; set by PGO train) |
| `PSX_HEADLESS=1` | no SDL window (set by PGO train when `hide_video`) |
| `SDL_VIDEODRIVER=dummy` | set by default with headless train |
| `PYTHON` / `CMAKE` | tool overrides |
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
# CI helpers for setup-host releases
Shared scripts and GitHub Actions so every PSXRecomp title uses one
standardized release flow.
**Start here:** [`../GAME_PROJECT_SETUP.md`](../GAME_PROJECT_SETUP.md)
## Workflow template
```bash
mkdir -p .github/workflows
cp psxrecomp/docs/ci/templates/setup-release.yml .github/workflows/release.yml
# edit YOUR_* placeholders
```
Template: [`templates/setup-release.yml`](templates/setup-release.yml)
## Tools under `psxrecomp/tools/`
| Script | Role |
|--------|------|
| `ci/normalize_version.sh` | Normalize / write `VERSION` + `TAG` |
| `ci/clear_generated.sh` | Clear `generated/` for setup-host CI |
| `ci/record_pins.sh` | Log `psxrecomp` / `recomp-ui` / `recomp-net` SHAs |
| `ci/build_emitters.sh` | Build `psxrecomp-game` + `psxrecomp-bios` |
| `fetch_toolchain.sh` | Download/unpack cmake-clang-v1 (Windows emitter builds; optional embed) |
| `stage_setup_sdk.sh` | Emitters, OpenBIOS, optional `toolchain/`, MinGW DLLs |
| `bundle_mingw_dlls.sh` | Copy imported non-system DLLs next to Windows PEs |
| `package_setup_host.sh` | Lean setup-host zip (optional `--embed-toolchain`) |
| `../cmake/toolchain-mingw-w64.cmake` | Linux→Windows MinGW cross toolchain |
| `../host/psxrecomp_codegen_host.*` | Portable Generate & rebuild host (via CMake helper) |
| `templates/game.gitignore` | Suggested gitignore for title repos |
Also use `psxrecomp_add_game_runtime(...)` in `runtime/runtime.cmake` for
setup-host / full-game wiring. The CLI (`psxrecomp_cli.py`) lives in this
submodule — there is no separate `psxrecomp-sdk/` overlay.
## Composite actions
```yaml
- uses: ./psxrecomp/.github/actions/build-emitters
# Prefer package_setup_host.sh (allow-no-toolchain by default).
# fetch-toolchain + --embed-toolchain only for offline-first packs.
- uses: ./psxrecomp/.github/actions/stage-setup-sdk
with:
stage: dist/stage-setup-${{ matrix.artifact }}
recompiler-build: build-recompiler
allow-no-toolchain: 'true'
runtime-bin: /mingw64/bin # Windows / MSYS2
```
## Title responsibilities
Keep only this in the game repo:
- Setup-host CMake flags and exe basename
- Thin `scripts/package_setup_release.sh` wrapping `package_setup_host.sh`
- Zip prefix / display name / disc hint in that wrapper
- Release notes / GitHub Release job naming
## Release checklist
See [`../GAME_PROJECT_SETUP.md`](../GAME_PROJECT_SETUP.md#bundled-release-checklist).
+23
View File
@@ -0,0 +1,23 @@
# Deprecated stub — use the full template:
#
# docs/ci/templates/setup-release.yml
#
# Install:
# cp psxrecomp/docs/ci/templates/setup-release.yml .github/workflows/release.yml
#
# Guide: docs/GAME_PROJECT_SETUP.md
# Actions: docs/ci/README.md
name: Release builds (see docs/ci/templates/setup-release.yml)
on:
workflow_dispatch:
jobs:
redirect:
runs-on: ubuntu-24.04
steps:
- run: |
echo "Copy psxrecomp/docs/ci/templates/setup-release.yml to .github/workflows/release.yml"
echo "See psxrecomp/docs/GAME_PROJECT_SETUP.md"
exit 1
+63
View File
@@ -0,0 +1,63 @@
# Suggested .gitignore for a PSXRecomp game repository.
# Copy to the game repo root and adjust title-specific disc dirs.
# Build outputs
/build/
/build-*/
/out/
/.happypath/
# Release packaging
/release-stage/
/dist/
*.zip
# Local PlayStation assets (never commit)
/bios/
/isos/
/saves/
/prepared_disc/
# Title disc working trees (rename to match your layout, e.g. /bpe/ /motk/)
/disc/
# Disc / image / memcard formats
*.bin
*.cue
*.iso
*.img
*.sub
*.ccd
*.mcd
*.mcr
*.chd
# Ghidra / local analysis
/ghidra/projects/
*.gpr
*.rep/
*.lock
*.lock~
# Editor / cache
.vs/
.vscode/
*.user
*.log
__pycache__/
# Runtime artifacts from psx-runtime launches
psx_crash.txt
psx_last_run_report.json
psx_screenshot.bmp
psx_freeze_heartbeat.json
starvation_dump.jsonl
disc.cfg
# Diagnostic dumps (root-level)
/*.json
/*.bmp
/*.png
# Setup-host titles: leave generated/ gitignored (users generate locally).
# Titles that still CI-build from committed game C may track generated/ instead.
/generated/
+252
View File
@@ -0,0 +1,252 @@
# Setup-host multi-platform release template for a PSXRecomp *game* repository.
#
# Install:
# mkdir -p .github/workflows
# cp psxrecomp/docs/ci/templates/setup-release.yml .github/workflows/release.yml
# Then replace every YOUR_* token. See docs/GAME_PROJECT_SETUP.md.
#
# Shared (do not fork these into the title):
# ./psxrecomp/.github/actions/{build-emitters,fetch-toolchain (optional embed),stage-setup-sdk}
# psxrecomp/tools/ci/*.sh
# psxrecomp/tools/{fetch_toolchain,stage_setup_sdk,bundle_mingw_dlls,package_setup_host}.sh
# psxrecomp/psxrecomp_cli.py (CLI lives in the submodule — no psxrecomp-sdk/)
#
# Title-owned:
# CMake setup-host flags, thin scripts/package_setup_release.sh wrapper
name: Release builds
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. 0.1.0 or v0.1.0)'
required: true
type: string
publish:
description: 'Create git tag vX.Y.Z and GitHub Release'
required: true
type: boolean
default: true
push:
tags:
- 'v*'
concurrency:
group: yourgame-release-${{ github.event_name }}-${{ github.ref }}-${{ github.run_id }}
cancel-in-progress: true
permissions:
contents: write
env:
CMAKE_BUILD_TYPE: Release
jobs:
prepare:
name: Resolve version
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.meta.outputs.version }}
tag: ${{ steps.meta.outputs.tag }}
publish: ${{ steps.meta.outputs.publish }}
steps:
- name: Normalize version / tag
id: meta
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
RAW="${{ github.event.inputs.version }}"
PUBLISH="${{ github.event.inputs.publish }}"
else
RAW="${{ github.ref_name }}"
PUBLISH="true"
fi
RAW="$(printf '%s' "${RAW}" | tr -d '[:space:]')"
VER="${RAW#v}"
if [[ ! "${VER}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.+-][A-Za-z0-9.+-]*)?$ ]]; then
echo "::error::invalid version '${RAW}' (want X.Y.Z or vX.Y.Z)"
exit 1
fi
echo "version=${VER}" >>"$GITHUB_OUTPUT"
echo "tag=v${VER}" >>"$GITHUB_OUTPUT"
echo "publish=${PUBLISH}" >>"$GITHUB_OUTPUT"
build:
name: ${{ matrix.artifact }}
needs: prepare
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
artifact: linux-x64
shell: bash
- os: windows-2022
artifact: windows-x64
shell: 'msys2 {0}'
- os: macos-15
artifact: macos-arm64
shell: bash
deployment_target: '11.0'
- os: macos-15-intel
artifact: macos-x64
shell: bash
deployment_target: '11.0'
defaults:
run:
shell: ${{ matrix.shell }}
env:
# Rename to match your packager (e.g. BPE_RELEASE_VERSION).
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 1
- name: Pin VERSION
shell: bash
run: |
set -euo pipefail
printf '%s\n' "${RELEASE_VERSION}" >VERSION
echo "VERSION=$(cat VERSION)"
- name: Record pinned framework revisions
shell: bash
run: bash psxrecomp/tools/ci/record_pins.sh
- name: Setup host mode (no game C / BIOS backends in CI)
shell: bash
run: bash psxrecomp/tools/ci/clear_generated.sh
- name: Setup MSYS2 (Windows)
if: runner.os == 'Windows'
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: true
install: >-
mingw-w64-x86_64-toolchain
mingw-w64-x86_64-cmake
mingw-w64-x86_64-ninja
mingw-w64-x86_64-SDL2
mingw-w64-x86_64-pkgconf
curl
zip
- name: Install deps (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential cmake ninja-build pkg-config \
libsdl2-dev libgl1-mesa-dev zip
- name: Install deps (macOS)
if: runner.os == 'macOS'
run: |
brew install cmake ninja pkg-config sdl3 || brew install cmake ninja pkg-config sdl2
# Windows: build emitters with llvm-mingw from cmake-clang-v1 (static CLI).
# Zip still omits toolchain/ — fetch is for the build machine only.
- name: Fetch portable toolchain (Windows emitters)
if: runner.os == 'Windows'
uses: ./psxrecomp/.github/actions/fetch-toolchain
with:
artifact: ${{ matrix.artifact }}
- name: Build recompiler tools (emitters)
uses: ./psxrecomp/.github/actions/build-emitters
- name: Configure & build setup host
env:
CMAKE_OSX_DEPLOYMENT_TARGET: ${{ matrix.deployment_target || '' }}
run: |
set -euo pipefail
EXTRA=()
if [[ -n "${CMAKE_OSX_DEPLOYMENT_TARGET:-}" ]]; then
EXTRA+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}")
fi
JOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
# Prefer -DPSXRECOMP_FORCE_SETUP_HOST=ON (psxrecomp_add_game_runtime).
# Legacy -DYOURGAME_FORCE_SETUP_HOST=ON still works if the title wires it.
cmake -S . -B build-ci -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DPSXRECOMP_FORCE_SETUP_HOST=ON \
-DPSX_GAME_VERSION="${RELEASE_VERSION}" \
"${EXTRA[@]}"
cmake --build build-ci --target psx-runtime -j"${JOBS}"
- name: Package setup zip
run: |
set -euo pipefail
chmod +x scripts/package_setup_release.sh
if [[ "${{ runner.os }}" == "Windows" ]]; then
export PSXRECOMP_RUNTIME_BIN_DIR=/mingw64/bin
export BPE_RUNTIME_BIN_DIR=/mingw64/bin
fi
export RELEASE_VERSION
# Lean zip (no embedded toolchain/). RetComM / wizard download
# cmake-clang-v1; pass --embed-toolchain only for offline-first packs.
scripts/package_setup_release.sh build-ci "${{ matrix.artifact }}" build-recompiler
ls -la dist/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
# YOUR_ZIP_PREFIX e.g. bpe, motk, yourgame
name: YOUR_ZIP_PREFIX-${{ matrix.artifact }}
path: dist/YOUR_ZIP_PREFIX-*-${{ matrix.artifact }}.zip
if-no-files-found: error
retention-days: 30
release:
name: GitHub Release
needs: [prepare, build]
if: needs.prepare.outputs.publish == 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Flatten zips
run: |
set -euo pipefail
mkdir -p dist
find artifacts -type f -name 'YOUR_ZIP_PREFIX-*.zip' -exec cp -v {} dist/ \;
ls -la dist/
- name: Ensure release tag exists
if: github.event_name == 'workflow_dispatch'
env:
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
git tag -a "${TAG}" -m "YOUR_GAME_TITLE ${TAG}"
fi
if ! git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
git push origin "refs/tags/${TAG}"
fi
- name: Publish release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare.outputs.tag }}
name: YOUR_GAME_TITLE ${{ needs.prepare.outputs.tag }}
files: |
dist/YOUR_ZIP_PREFIX-*.zip
generate_release_notes: true
fail_on_unmatched_files: true
target_commitish: ${{ github.sha }}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
/* Portable recomp-ui setup host: disc → generate → cmake/PGO rebuild → relaunch.
*
* Games compile psxrecomp_codegen_host.c, fill PsxrecompCodegenHostConfig,
* and call psxrecomp_codegen_host_apply() when building RecompLauncherCGameInfo.
*
* Requires recomp_launcher.h on the include path (recomp-ui submodule).
*/
#ifndef PSXRECOMP_CODEGEN_HOST_H
#define PSXRECOMP_CODEGEN_HOST_H
#include "recomp_launcher.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct PsxrecompCodegenHostConfig {
const char* display_name;
/* Env vars (optional). Defaults: PSXRECOMP_PROJECT_ROOT / PSXRECOMP_BUILD_DIR /
* PSXRECOMP_FORCE_SETUP when NULL. */
const char* project_root_env;
const char* build_dir_env;
const char* force_setup_env;
/* Paths relative to project root. NULL → defaults below. */
const char* psxrecomp_cli_relpath; /* default: psxrecomp/psxrecomp_cli.py */
const char* seed_cfg_relpath; /* default: game.toml (root probe) */
const char* game_toml_relpath; /* default: game.toml */
const char* gen_marker_relpath; /* default: generated/SLUS_011.89_dispatch.c */
const char* build_dir_name; /* default: build */
/* CMake / binary identity (required for auto-rebuild). */
const char* cmake_target; /* e.g. psx-runtime */
const char* exe_basename; /* no .exe */
/* Optional UI copy overrides (NULL → generic defaults). */
const char* prepare_note;
const char* prepare_note_windows;
const char* prepare_note_no_cmake;
} PsxrecompCodegenHostConfig;
void psxrecomp_codegen_host_apply(RecompLauncherCGameInfo* gi,
const PsxrecompCodegenHostConfig* cfg);
int psxrecomp_codegen_host_sources_missing(
const PsxrecompCodegenHostConfig* cfg);
void psxrecomp_codegen_host_relaunch_or_exit(const char* disc_path);
#ifdef __cplusplus
}
#endif
#endif /* PSXRECOMP_CODEGEN_HOST_H */
+1342
View File
File diff suppressed because it is too large Load Diff
+25 -19
View File
@@ -203,13 +203,19 @@ elseif(MSVC)
target_link_options(psxrecomp-bios PRIVATE /STACK:16777216)
endif()
# Released Windows CLI archives must run without a separate MinGW install.
# Keep this opt-in so ordinary developer/debug builds retain their normal
# linker behavior on every platform.
option(PSXRECOMP_STATIC_CLI "Statically link MinGW runtime libraries into CLI tools" OFF)
if(PSXRECOMP_STATIC_CLI AND MINGW)
# Released Windows CLI archives must run without a separate MinGW/MSYS2
# install. Prefer llvm-mingw (+ -static) in CI so emitters do not need
# libgcc/libstdc++ harvested from MSYS2. Keep opt-in for local debug builds.
option(PSXRECOMP_STATIC_CLI "Statically link Windows runtime into CLI tools" OFF)
if(PSXRECOMP_STATIC_CLI AND WIN32 AND (MINGW OR CMAKE_CXX_COMPILER_ID MATCHES "Clang"))
foreach(_cli_target psxrecomp psxrecomp-game psxrecomp-toml psxrecomp-bios)
target_link_options(${_cli_target} PRIVATE -static -static-libgcc -static-libstdc++)
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
target_link_options(${_cli_target} PRIVATE
-static -static-libgcc -static-libstdc++)
else()
# llvm-mingw / Clang: fully static CRT + libc++ / libunwind / pthread.
target_link_options(${_cli_target} PRIVATE -static)
endif()
endforeach()
endif()
@@ -311,15 +317,15 @@ if(BUILD_TESTING)
COMMAND bios_address_model_test
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
find_package(Python3 COMPONENTS Interpreter)
if(Python3_Interpreter_FOUND)
add_test(
NAME cli_project_packaging
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cli_project_packaging.py
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
# These two invoke psxrecomp-game, whose BIOS-profile resolution
find_package(Python3 COMPONENTS Interpreter)
if(Python3_Interpreter_FOUND)
add_test(
NAME cli_project_packaging
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cli_project_packaging.py
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
# These two invoke psxrecomp-game, whose BIOS-profile resolution
# probes fixed working-directory-relative paths (bios/<name>.toml,
# then psxrecomp/bios/<name>.toml) -- run them from the framework
# root so the shipped profiles are found.
@@ -390,10 +396,10 @@ if(BUILD_TESTING)
default_renderer_guards
fmv_quiet_guards
fps_telemetry_opt_in
frame_interpolation_display_rate
hybrid_dev_any_input
launcher_vulkan_option
mod_controller_override
frame_interpolation_display_rate
hybrid_dev_any_input
launcher_vulkan_option
mod_controller_override
overlay_candidate_capacity
overlay_decodable_fallback
overlay_init_guard
+9 -1
View File
@@ -80,7 +80,7 @@ enum {
BS_SEC_CPU = 0x01, /* CPUState: gpr/pc/hi/lo/cop0/gte_data/gte_ctrl */
BS_SEC_RAM = 0x02, /* 2 MB main RAM */
BS_SEC_SPAD = 0x03, /* 1 KB scratchpad */
BS_SEC_IRQ = 0x04, /* i_stat / i_mask */
BS_SEC_IRQ = 0x04, /* i_stat / i_mask / cycles_since_vblank (12B; 8B ok) */
BS_SEC_TIMER = 0x05, /* 3 root counters (counter/mode/target/irq/frac) */
BS_SEC_CLOCK = 0x06, /* psx_cycle_count */
BS_SEC_GPU = 0x07, /* GPU regs: display/draw-area/offset/mask/texpage/xfer*/
@@ -92,6 +92,14 @@ enum {
BS_SEC_SIO = 0x0D, /* SIO regs + pad-config FSM + memcard FSM */
BS_SEC_DIRTY = 0x0E, /* dirty-RAM page bitmap (guest-written code pages) */
BS_SEC_MDEC = 0x0F, /* MDEC command/FIFOs/quant/scale (FMV decode resume) */
BS_SEC_ICACHE = 0x10, /* R3000A I-cache tag/valid words (1024 u32) — fetch
cost model. Host-persistent otherwise: a warm load
without it replays with the pre-load timeline's
cache, so fetch-miss cycles differ per peer/retry
and IRQ delivery lands a few wait-loop iterations
apart (MotK abort@940: fin cyc Δ8, v0 5c83/5c86
from identical baselines). Optional on load for
old blobs (left untouched when absent). */
};
/* Save a COMPLETE snapshot at game handoff. Returns 1 on success. */
+8
View File
@@ -57,6 +57,14 @@ void debug_force_cd_reinsert(void);
* frontend via uncapped pacing (it does NOT alter CD timing — flooding XA
* sectors desyncs and hangs the player). */
int cdrom_xa_stream_active(void);
/* True while a CD read is armed with XA/FMV mode bits (or XA already
* streaming). Netplay uses this to arm no-invent / refuse tip episodes
* before the first MDEC colour decode — MotK intro invent≠Start at FMV
* entry opened a tip episode into a matched black wait. */
int cdrom_fmv_stream_pending(void);
/* Re-arm host absolute CD deadlines from restored relative delays after
* boot_state / RB snap load. Does NOT clamp delays (unlike accelerate). */
void cdrom_resync_deadlines_after_restore(void);
/* CD load-burst ring (always-on). One record per gap-separated run of
* delivered data sectors. `out` receives up to `max` records, newest first
+7
View File
@@ -46,6 +46,11 @@ int gpu_display_is_depth24(void);
void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y,
uint8_t* r, uint8_t* g, uint8_t* b);
uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y);
/* Batch equivalent of calling gpu_display_pixel_argb(di, x, y) for x in
* [0, count) on a depth24 (FMV) scanline — same output, far less per-pixel
* overhead. `out` must hold at least `count` uint32_t ARGB entries. */
void gpu_depth24_present_row(const GpuDisplayInfo* di, uint32_t y, uint32_t* out,
uint32_t count);
/* Depth24: RGB columns covered by CPU→VRAM uploads since the last reset.
* Returns 0 when no uploads yet, crtc_w when coverage is full/unknown-beyond,
* else the covered RGB width. Present black-fills [limit, crtc_w) without
@@ -121,6 +126,8 @@ void gpu_vblank_clear_deferred_present(void);
/* Queue one deferred present (netplay RB resume when I_STAT already has
* VBlank — snap resync cleared the pending callback). */
void gpu_vblank_arm_deferred_present(void);
/* 1 while a netplay deferred present is armed (MotK CD54 VBlank hold). */
int gpu_vblank_present_pending(void);
/* Clear flush_present reentrancy guard before longjmp (flush_resume). */
void gpu_vblank_release_present_flush_guard(void);
+3 -4
View File
@@ -80,10 +80,9 @@ void psx_check_interrupts_dispatch_entry(struct CPUState* cpu, uint32_t resume_p
void interrupts_advance_cycles(uint32_t cycles);
void interrupts_service_scheduled_events(void);
uint32_t interrupts_cycles_to_vblank(void);
/* VBlank phase within the current frame (0 .. VBLANK_CYCLES-1). Self-check
* snaps save/restore this so resim keeps the snap's phase instead of the
* warm-load rebase to 0 (which is correct for UI savestates, wrong for
* bit-identical resim of a mid-frame tip). */
/* VBlank phase within the current frame (0 .. VBLANK_CYCLES-1). Persisted in
* BS_SEC_IRQ (and selfcheck's out-of-band latch) so resim keeps the snap's
* phase. Legacy 8-byte IRQ sections still rebase to 0 on load. */
uint32_t interrupts_get_cycles_since_vblank(void);
void interrupts_set_cycles_since_vblank(uint32_t v);
+27
View File
@@ -0,0 +1,27 @@
#ifndef PSX_NETPLAY_RB_POST_H
#define PSX_NETPLAY_RB_POST_H
/*
* RB_POST already carries target_tick on the wire. After tip-extend the peer
* may still deliver a POST for the prior tip while we Verify the new tip —
* latching that digest caused false post diverge (local@T+1 vs peer@T).
*
* Accept only when peer tip equals the current episode tip.
*/
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
static inline int netplay_rb_peer_post_tip_ok(uint32_t peer_target, uint32_t episode_tip)
{
return peer_target == episode_tip;
}
#ifdef __cplusplus
}
#endif
#endif /* PSX_NETPLAY_RB_POST_H */
+33 -2
View File
@@ -77,8 +77,8 @@ int psx_netplay_rb_fmv_defer_rewind(void);
/* 1 while depth24 or recent MDEC (not settle). */
int psx_netplay_rb_fmv_media_active(void);
/* 1 during FMV media + post-FMV lockstep (~90 ticks) — admit waits for remote
* wire (title Start / movie skip). Ticks the FMV→settle tracker. */
/* 1 during FMV media + post-FMV lockstep (+ unlock grace) — admit waits for
* remote wire (title Start / movie skip). Ticks the FMV→settle tracker. */
int psx_netplay_rb_lockstep_no_invent(void);
/* Mid-guest resim pump: abort if Replay has made no finish_frame progress.
@@ -97,6 +97,20 @@ int psx_netplay_rb_active(void);
int psx_netplay_rb_is_resimulating(void);
int psx_netplay_rb_tip_holding(void);
/* Episode tip (target_tick) while an episode is active; 0 if none. */
uint32_t psx_netplay_rb_episode_target(void);
/* TipHold Live invent slack past tip (tip_seal_slack). Stall invent beyond. */
uint32_t psx_netplay_rb_tip_hold_invent_slack(void);
/* TipHold coalesce runway (tip_runway). Host scans wire tip+1..tip+runway for
* release edges when Live is stalled at invent-cap. */
uint32_t psx_netplay_rb_tip_runway(void);
/* 1 if a peer FRAME_COMMIT should be dropped (TipHold invent still on the
* wire after tip-extend hc_prime). Call from FRAME_COMMIT drain. */
int psx_netplay_rb_ignore_peer_frame_commit(uint32_t tick, uint32_t hash);
/* FRAME_COMMIT mismatch during Replay — abort before a false POST commit.
* Returns 1 if an episode was aborted. */
int psx_netplay_rb_abort_resim_core_mismatch(uint32_t tick, uint32_t local_core,
@@ -126,6 +140,23 @@ uint32_t psx_netplay_rb_episode_count(void);
int psx_netplay_rb_phase(void); /* RNetRbPhase cast to int */
uint32_t psx_netplay_rb_snap_count(void);
/* Ticks armed into Replay since the last call (counter resets on read). Used
* by the host [FPS] line (PSX_NETPLAY_TIMING=1) to report what fraction of
* the window was spent resimulating vs running Live. */
uint64_t psx_netplay_rb_take_replay_ticks(void);
/* Live network-latency estimate (ms), EMA'd from the POST handshake's own
* send/receive timestamps each episode commit; 0 = no episode has round-
* tripped yet this session. Used by np_invent_grace_stall() (psx_netplay.c)
* to size its "wait before hold-last invent" budget from actual measured
* link latency instead of the local sim's own tick-to-tick cadence — see
* docs/ROLLBACK_MOTK_HOOKUP.md section 12. */
uint32_t psx_netplay_rb_rtt_estimate_ms(void);
/* Sticky BB-edge resume PC (MotK wait-loop canonicalize). Used by
* gpu_vblank_flush_present when IRQ resume latches are 0. */
uint32_t psx_netplay_rb_sticky_bb_pc(void);
#ifdef __cplusplus
}
#endif
+276 -11
View File
@@ -409,17 +409,31 @@ if(_psxrt_bios_skipped)
"descriptor): ${_psxrt_bios_skipped} -- regenerate with "
"tools/regen_bios.sh --config bios/<stem>.toml")
endif()
# Setup hosts / CI may ship with zero BIOS backends; first-run Generate &
# rebuild emits OpenBIOS (and optional retail) locally before a full link.
option(PSXRECOMP_ALLOW_NO_BIOS
"Allow linking the runtime with no recompiled BIOS backends (setup host)"
OFF)
if(NOT _psxrt_bios_linked)
message(FATAL_ERROR
"No recompiled BIOS backend available. Wanted: ${PSXRECOMP_BIOS_STEMS}, "
"but no matching generated/<stem>_full.c + <stem>_dispatch.c were found "
"under ${PSXRECOMP_ROOT}/generated.\n"
"Generate at least one before building the runtime:\n"
" bash tools/regen_bios.sh --config bios/OpenBIOS.toml\n"
"(OpenBIOS is bundled and MIT-licensed, so this needs no BIOS dump.)")
if(PSXRECOMP_ALLOW_NO_BIOS)
message(STATUS
"BIOS backends linked: (none) — setup host; Generate & rebuild "
"will emit BIOS C locally")
set(_psxrt_bios_count 0)
else()
message(FATAL_ERROR
"No recompiled BIOS backend available. Wanted: ${PSXRECOMP_BIOS_STEMS}, "
"but no matching generated/<stem>_full.c + <stem>_dispatch.c were found "
"under ${PSXRECOMP_ROOT}/generated.\n"
"Generate at least one before building the runtime:\n"
" bash tools/regen_bios.sh --config bios/OpenBIOS.toml\n"
"(OpenBIOS is bundled and MIT-licensed, so this needs no BIOS dump.)\n"
"Or configure a setup host with -DPSXRECOMP_ALLOW_NO_BIOS=ON.")
endif()
else()
message(STATUS "BIOS backends linked: ${_psxrt_bios_linked}")
list(LENGTH _psxrt_bios_linked _psxrt_bios_count)
endif()
message(STATUS "BIOS backends linked: ${_psxrt_bios_linked}")
list(LENGTH _psxrt_bios_linked _psxrt_bios_count)
# Registry of the compiled-in backends, in preference order. Generated so the
# stem list stays the single source of truth.
@@ -450,7 +464,7 @@ list(APPEND PSXRECOMP_BIOS_GENERATED "${_psxrt_registry_c}")
# argument as regen_bios.sh passes) and WARN on a mismatch so the staleness is
# impossible to miss. Non-fatal: a stale-but-consistent
# BIOS still builds; opt out with -DPSXRECOMP_SKIP_BIOS_STALE_CHECK=ON.
if(NOT PSXRECOMP_SKIP_BIOS_STALE_CHECK)
if(NOT PSXRECOMP_SKIP_BIOS_STALE_CHECK AND _psxrt_bios_linked)
find_program(_psxrt_bash NAMES bash)
set(_psxrt_stamp "${PSXRECOMP_ROOT}/generated/${PSXRECOMP_BIOS_STEM}.emitter.sha")
if(_psxrt_bash AND EXISTS "${PSXRECOMP_ROOT}/tools/bios_emitter_fingerprint.sh")
@@ -480,6 +494,60 @@ if(NOT PSXRECOMP_SKIP_BIOS_STALE_CHECK)
endif()
endif()
# zlib for boot_state v4. System packages first; otherwise FetchContent a pinned
# release so portable Windows cmake-clang-v1 setups can configure without MSYS2.
option(PSX_ZLIB_FETCH
"Fetch zlib when no system ZLIB package is found"
ON)
function(psxrecomp_ensure_zlib)
if(TARGET ZLIB::ZLIB)
return()
endif()
find_package(ZLIB QUIET)
if(TARGET ZLIB::ZLIB)
return()
endif()
if(NOT PSX_ZLIB_FETCH)
message(FATAL_ERROR
"ZLIB was not found. On Windows, use a cmake-clang-v1 pack that "
"ships include/zlib.h + lib/libz.a (sets ZLIB_ROOT), or install "
"zlib-devel / mingw-w64-zlib, or configure with -DPSX_ZLIB_FETCH=ON.")
endif()
message(STATUS
"psxrecomp: ZLIB not in CMAKE_PREFIX_PATH/ZLIB_ROOT; "
"fetching zlib 1.3.1 (prefer a toolchain pack that bundles it)")
include(FetchContent)
set(_psx_zlib_timestamp_args "")
if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.24)
list(APPEND _psx_zlib_timestamp_args DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
endif()
FetchContent_Declare(psx_zlib
URL
"https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz"
URL_HASH
"SHA256=9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23"
${_psx_zlib_timestamp_args})
FetchContent_MakeAvailable(psx_zlib)
if(TARGET ZLIB::ZLIB)
message(STATUS "psxrecomp: ZLIB via FetchContent (ZLIB::ZLIB)")
return()
endif()
# madler/zlib builds zlibstatic even when a shared zlib target exists.
if(TARGET zlibstatic)
add_library(ZLIB::ZLIB ALIAS zlibstatic)
message(STATUS "psxrecomp: ZLIB via FetchContent (zlibstatic)")
return()
endif()
if(TARGET zlib)
add_library(ZLIB::ZLIB ALIAS zlib)
message(STATUS "psxrecomp: ZLIB via FetchContent (zlib)")
return()
endif()
message(FATAL_ERROR
"Fetched zlib but no linkable target was produced "
"(expected zlibstatic or zlib).")
endfunction()
function(psxrecomp_add_runtime_target target)
set(options ORACLE COSIM)
set(oneValueArgs
@@ -724,7 +792,9 @@ function(psxrecomp_add_runtime_target target)
endif()
# zlib: boot_state v4 savestate compression (RAM/VRAM/SPU blobs).
find_package(ZLIB REQUIRED)
# Portable Windows toolchains (cmake-clang-v1) have no system zlib —
# fetch a pinned release when find_package fails (same pattern as SDL3).
psxrecomp_ensure_zlib()
target_link_libraries(${target} PRIVATE ZLIB::ZLIB)
# Build identity: stamp the psxrecomp commit into the binary so a crash report
@@ -1101,3 +1171,198 @@ endfunction()
function(psxrecomp_v4_add_runtime_target target)
psxrecomp_add_runtime_target(${target} ${ARGN})
endfunction()
# ---------------------------------------------------------------------------
# High-level helper for title repos (setup-host + optional generated game C).
#
# Typical game CMakeLists.txt:
# set(PSXRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp")
# include("${PSXRECOMP_ROOT}/runtime/runtime.cmake")
# psxrecomp_add_game_runtime(psx-runtime
# WINDOW_TITLE "My Game Recompiled"
# GEN_MARKER "generated/SLUS_01234_dispatch.c"
# GEN_FULL_GLOB "generated/SLUS_01234_full_*.c"
# CODEGEN_SETUP_SOURCES codegen_setup.c
# DEFAULT_GAME_CONFIG_PATH "game.toml"
# LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga"
# MAX_PLAYERS 2
# ENABLE_NETPLAY_IF_PRESENT
# )
#
# Remaining args are forwarded to psxrecomp_add_runtime_target.
# ---------------------------------------------------------------------------
function(psxrecomp_add_game_runtime target)
set(options ENABLE_NETPLAY_IF_PRESENT)
set(oneValueArgs
GEN_MARKER
GEN_FULL_FALLBACK
VERSION_FILE
CODEGEN_SETUP_INCLUDE_DIR
)
set(multiValueArgs GEN_FULL_GLOB CODEGEN_SETUP_SOURCES)
cmake_parse_arguments(PSXG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})
if(NOT PSXRECOMP_ROOT)
message(FATAL_ERROR
"psxrecomp_add_game_runtime: PSXRECOMP_ROOT is not set. "
"Set it to the psxrecomp submodule path before including runtime.cmake.")
endif()
option(PSXRECOMP_FORCE_SETUP_HOST
"Build without linking game C even if generated/ exists" OFF)
option(PSXRECOMP_REQUIRE_GAME_C
"Fail configure if generated game C is missing" OFF)
# Legacy BPE option name (CI / docs may still pass -DBPE_FORCE_SETUP_HOST=ON).
if(BPE_FORCE_SETUP_HOST)
set(PSXRECOMP_FORCE_SETUP_HOST ON CACHE BOOL
"Build without linking game C even if generated/ exists" FORCE)
endif()
if(BPE_REQUIRE_GAME_C)
set(PSXRECOMP_REQUIRE_GAME_C ON CACHE BOOL
"Fail configure if generated game C is missing" FORCE)
endif()
if(NOT PSXG_VERSION_FILE)
set(PSXG_VERSION_FILE "${CMAKE_CURRENT_SOURCE_DIR}/VERSION")
endif()
if(NOT IS_ABSOLUTE "${PSXG_VERSION_FILE}")
set(PSXG_VERSION_FILE "${CMAKE_CURRENT_SOURCE_DIR}/${PSXG_VERSION_FILE}")
endif()
# Lobby / release pin from VERSION when PSX_GAME_VERSION is unset.
if(EXISTS "${PSXG_VERSION_FILE}")
file(READ "${PSXG_VERSION_FILE}" _psxg_ver_raw)
string(STRIP "${_psxg_ver_raw}" _psxg_release_version)
else()
set(_psxg_release_version "0.0.0")
endif()
set(PSX_GAME_VERSION "" CACHE STRING
"Lobby release pin (empty = Release uses VERSION file, else dev)")
if(PSX_GAME_VERSION STREQUAL "")
get_property(_psxg_is_multi GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
if(_psxg_is_multi)
set(PSX_GAME_VERSION
"$<IF:$<CONFIG:Release>,${_psxg_release_version},dev>")
elseif(CMAKE_BUILD_TYPE STREQUAL "Release")
set(PSX_GAME_VERSION "${_psxg_release_version}")
else()
set(PSX_GAME_VERSION "dev")
endif()
endif()
message(STATUS
"psxrecomp game_version: ${PSX_GAME_VERSION} "
"(from VERSION=${_psxg_release_version})")
# Prefer game-root recomp-ui (runtime.cmake also auto-discovers this).
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/recomp-ui/recomp_ui.cmake")
set(RECOMP_UI_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/recomp-ui" CACHE PATH
"Path to recomp-ui launcher" FORCE)
endif()
# Optional: enable netplay when recomp-net is present.
if(PSXG_ENABLE_NETPLAY_IF_PRESENT)
if(EXISTS "${PSXRECOMP_ROOT}/lib/recomp-net/CMakeLists.txt")
if(NOT RECOMP_NET_ROOT)
set(RECOMP_NET_ROOT "${PSXRECOMP_ROOT}/lib/recomp-net" CACHE PATH
"Path to recomp-net; empty = auto-discover" FORCE)
endif()
if(NOT DEFINED PSX_NETPLAY)
set(PSX_NETPLAY ON)
endif()
endif()
endif()
if(NOT PSXG_GEN_MARKER)
message(FATAL_ERROR
"psxrecomp_add_game_runtime: GEN_MARKER is required "
"(e.g. generated/SLUS_01234_dispatch.c)")
endif()
if(NOT IS_ABSOLUTE "${PSXG_GEN_MARKER}")
set(_psxg_marker "${CMAKE_CURRENT_SOURCE_DIR}/${PSXG_GEN_MARKER}")
else()
set(_psxg_marker "${PSXG_GEN_MARKER}")
endif()
set(_psxg_has_game_c FALSE)
if(EXISTS "${_psxg_marker}" AND NOT PSXRECOMP_FORCE_SETUP_HOST)
set(_psxg_has_game_c TRUE)
endif()
if(NOT _psxg_has_game_c)
set(PSXRECOMP_ALLOW_NO_BIOS ON CACHE BOOL
"Allow runtime with no BIOS backends (setup host)" FORCE)
endif()
if(PSXRECOMP_REQUIRE_GAME_C AND NOT _psxg_has_game_c)
message(FATAL_ERROR
"PSXRECOMP_REQUIRE_GAME_C=ON but ${_psxg_marker} is missing. "
"Generate with psxrecomp-game, or leave REQUIRE_GAME_C off to "
"build the setup host.")
endif()
set(_psxg_extras ${PSXG_CODEGEN_SETUP_SOURCES})
list(APPEND _psxg_extras
"${PSXRECOMP_ROOT}/host/psxrecomp_codegen_host.c")
set(_psxg_rt_args
GAME_VERSION "${PSX_GAME_VERSION}"
EXTRAS_SOURCES ${_psxg_extras}
${PSXG_UNPARSED_ARGUMENTS}
)
if(_psxg_has_game_c)
message(STATUS
"psxrecomp: linking generated game C (full runtime) — ${_psxg_marker}")
set(_psxg_full_list "")
foreach(_glob IN LISTS PSXG_GEN_FULL_GLOB)
if(NOT IS_ABSOLUTE "${_glob}")
set(_glob "${CMAKE_CURRENT_SOURCE_DIR}/${_glob}")
endif()
file(GLOB _hits "${_glob}")
list(APPEND _psxg_full_list ${_hits})
endforeach()
if(NOT _psxg_full_list)
if(PSXG_GEN_FULL_FALLBACK)
if(NOT IS_ABSOLUTE "${PSXG_GEN_FULL_FALLBACK}")
set(_psxg_full_list
"${CMAKE_CURRENT_SOURCE_DIR}/${PSXG_GEN_FULL_FALLBACK}")
else()
set(_psxg_full_list "${PSXG_GEN_FULL_FALLBACK}")
endif()
else()
# Derive SLUS_*_full.c next to the dispatch marker.
get_filename_component(_psxg_marker_dir "${_psxg_marker}" DIRECTORY)
get_filename_component(_psxg_marker_name "${_psxg_marker}" NAME)
string(REPLACE "_dispatch.c" "_full.c" _psxg_full_name
"${_psxg_marker_name}")
set(_psxg_full_list "${_psxg_marker_dir}/${_psxg_full_name}")
endif()
endif()
psxrecomp_add_runtime_target(${target}
GAME_GENERATED_FULL_C ${_psxg_full_list}
GAME_GENERATED_DISPATCH_C "${_psxg_marker}"
${_psxg_rt_args}
)
else()
message(STATUS
"psxrecomp: setup host (no game C, no BIOS backends) — "
"first-run Generate & rebuild")
psxrecomp_add_runtime_target(${target} ${_psxg_rt_args})
endif()
target_compile_definitions(${target} PRIVATE PSX_HAS_GAME_CODEGEN=1)
# Include the portable codegen host. Do NOT add CMAKE_CURRENT_SOURCE_DIR
# wholesale to -I: on case-insensitive macOS, #include <version> can pick
# up the repo VERSION pin file. Title codegen_setup.h is found via
# quote-include next to codegen_setup.c.
set(_psxg_inc "${PSXRECOMP_ROOT}/host")
if(PSXG_CODEGEN_SETUP_INCLUDE_DIR)
list(APPEND _psxg_inc "${PSXG_CODEGEN_SETUP_INCLUDE_DIR}")
endif()
if(RECOMP_UI_ROOT)
list(APPEND _psxg_inc "${RECOMP_UI_ROOT}/src")
endif()
target_include_directories(${target} PRIVATE ${_psxg_inc})
endfunction()
+38 -5
View File
@@ -3,7 +3,9 @@
#include "dirty_ram_interp.h"
#include "gpu_render.h" /* gr_vram_transfer_in / gr_vram_transfer_out */
#include "cpu_state.h" /* gte_canonicalize_cpu_state after CPU wire restore */
#include "interrupts.h"
#include "psx_cycles.h"
#include "psx_icache.h" /* g_psx_icache_tv — fetch-cost tags in BS_SEC_ICACHE */
#include "pst_wire.h"
#include <stdint.h>
#include <stdio.h>
@@ -237,7 +239,7 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu,
h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH;
h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG;
h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER;
h.section_count = 15;
h.section_count = 16;
ok = write_header_le(o, &h);
@@ -245,11 +247,15 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu,
if (ok) ok = write_section(o, BS_SEC_RAM, memory_get_ram_ptr(), RAM_SIZE);
if (ok) ok = write_section(o, BS_SEC_SPAD, memory_get_scratchpad_ptr(), SPAD_SIZE);
if (ok) {
uint8_t irq[8];
/* 12B: i_stat, i_mask, cycles_since_vblank. Zeroing csv on warm load
* rebased every tip to phase 0 and forked MotK wait-loop resim
* (IRQ at CD54 vs CDA0). Selfcheck already restored csv out-of-band. */
uint8_t irq[12];
PstW w;
pst_w_init(&w, irq, sizeof irq);
ok = pst_w_u32(&w, i_stat) && pst_w_u32(&w, i_mask) &&
write_section(o, BS_SEC_IRQ, irq, 8);
pst_w_u32(&w, interrupts_get_cycles_since_vblank()) &&
write_section(o, BS_SEC_IRQ, irq, 12);
}
if (ok) ok = write_timer_section(o);
if (ok) {
@@ -294,6 +300,18 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu,
if (ok) ok = write_module_section(o, BS_SEC_DMA, dma_snapshot_bytes, dma_snapshot_write);
if (ok) ok = write_module_section(o, BS_SEC_SIO, sio_snapshot_bytes, sio_snapshot_write);
if (ok) ok = write_module_section(o, BS_SEC_MDEC, mdec_snapshot_bytes, mdec_snapshot_write);
if (ok) {
/* I-cache tags: warm loads must replay with the fetch-cost state the
* live timeline had, or miss cycles differ per peer/retry and IRQ
* delivery forks a few wait-loop iterations (MotK abort@940). */
uint8_t ib[1024u * 4u];
PstW w;
pst_w_init(&w, ib, sizeof ib);
ok = 1;
for (uint32_t i = 0; ok && i < 1024u; i++)
ok = pst_w_u32(&w, g_psx_icache_tv[i]);
if (ok) ok = write_section(o, BS_SEC_ICACHE, ib, sizeof ib);
}
if (ok) {
uint32_t wc = dirty_ram_get_bitmap_word_count();
uint64_t nbytes = (uint64_t)wc * 4u;
@@ -403,12 +421,19 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len,
return 1;
case BS_SEC_IRQ: {
PstR r;
uint32_t st, mk;
if (len != 8) return 0;
uint32_t st, mk, csv;
if (len != 8 && len != 12) return 0;
pst_r_init(&r, p, len);
if (!pst_r_u32(&r, &st) || !pst_r_u32(&r, &mk)) return 0;
i_stat = st;
i_mask = mk;
if (len == 12) {
if (!pst_r_u32(&r, &csv)) return 0;
interrupts_set_cycles_since_vblank(csv);
} else {
/* Legacy UI/disk snaps: no phase — rebase like pre-csv saves. */
interrupts_set_cycles_since_vblank(0);
}
return 1;
}
case BS_SEC_TIMER: {
@@ -498,6 +523,14 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len,
free(words);
return 1;
}
case BS_SEC_ICACHE: {
PstR r;
if (len != 1024u * 4u) return 0;
pst_r_init(&r, p, len);
for (uint32_t i = 0; i < 1024u; i++)
if (!pst_r_u32(&r, &g_psx_icache_tv[i])) return 0;
return 1;
}
default:
return 0;
}
+25 -1
View File
@@ -281,6 +281,25 @@ int cdrom_get_setloc_lba(void) { return s_setloc_lba; }
/* Frontend XA-stream probe (FMV auto-skip / turbo-load gating in main.cpp). */
int cdrom_xa_stream_active(void) { return xa_stream_active; }
int cdrom_fmv_stream_pending(void)
{
if (xa_stream_active)
return 1;
/* mode 0x40 = XA-ADPCM enable; 0x08 = sector size / related FMV setup.
* Match warm-route consumer (mode & 0x48). */
if (reading && (mode_reg & 0x48u))
return 1;
return 0;
}
void cdrom_resync_deadlines_after_restore(void)
{
if (reading && read_delay > 0)
s_cd_timing_next_due = psx_cycle_count + (uint64_t)read_delay;
else if (!reading)
s_cd_timing_next_due = 0;
}
/* Response-overwrite diagnostics consumed by debug_server.c. master's CD
* response arbiter tracks how often an undelivered response was overwritten;
* this CD model (forwarded from the Ape bring-up branch) delivers responses
@@ -2831,7 +2850,12 @@ int cdrom_snapshot_read(const uint8_t *p, uint32_t len) {
PstR r;
if (len != cdrom_snapshot_bytes()) return 0;
pst_r_init(&r, p, len);
return cdrom_snap_parse(&r);
if (!cdrom_snap_parse(&r))
return 0;
/* Absolute host deadlines are not on the wire — rebuild from restored
* relative read_delay (psx_cycle_count is resynced by the load caller). */
cdrom_resync_deadlines_after_restore();
return 1;
}
void debug_force_cd_reinsert(void) {
+64 -4
View File
@@ -1,6 +1,26 @@
/*
* crc32.c — Simple CRC32 implementation (no external dependencies).
* crc32.c — CRC32 implementation (no external dependencies).
* Uses the standard IEEE 802.3 polynomial 0xEDB88320.
*
* Slicing-by-8 (2026-08-01): the netplay rollback digest path CRCs the full
* 2 MiB RAM buffer and 1 MiB VRAM buffer on every episode commit (see
* docs/ROLLBACK_MOTK_HOOKUP.md, "Tier 1/2/3 engine enhancements"). The
* original byte-at-a-time loop below runs at ~500-600 MB/s because each
* iteration's table lookup depends on the previous iteration's output CRC —
* a serial dependency chain that leaves the CPU unable to overlap
* consecutive steps. Slicing-by-8 restates 8 of those serially-dependent
* steps as 8 *independent* table lookups (each depends only on an input
* byte, not on each other) that are XORed together, which lets the CPU
* execute them out-of-order/in parallel. This is a pure implementation
* change: it computes the exact same polynomial over the exact same bytes,
* so output is bit-identical to the old loop for every input — verified by
* fuzzing both implementations against each other over 27k+ cases
* (exhaustive small lengths from many offsets/starting-crc values, 20k
* random large buffers, and 5k chained/incremental folds matching how
* netplay_state_digest.c folds parts together) before this replaced the
* scalar loop. Measured ~5x faster on both VRAM (1 MiB) and RAM (2 MiB)
* sized buffers. No protocol/wire-format implication: the digest is an
* opaque comparison value never compared across differently-built peers.
*/
#include "crc32.h"
#include <stddef.h>
@@ -18,15 +38,55 @@ static void crc32_init_table(void) {
s_table_ready = 1;
}
/* s_table8[k] = s_table with the base single-byte transform applied k+1
* times (s_table8[0] == s_table). Built once from s_table so the slicing
* loop stays defined purely in terms of the one canonical polynomial table
* above — there is no second, independently-authored polynomial to drift
* out of sync with it. */
static uint32_t s_table8[8][256];
static int s_table8_ready = 0;
static void crc32_init_table8(void) {
int n, k;
if (!s_table_ready) crc32_init_table();
for (n = 0; n < 256; n++) s_table8[0][n] = s_table[n];
for (n = 0; n < 256; n++) {
uint32_t c = s_table8[0][n];
for (k = 1; k < 8; k++) {
c = s_table8[0][c & 0xFFu] ^ (c >> 8);
s_table8[k][n] = c;
}
}
s_table8_ready = 1;
}
/* Incremental update over a raw (un-finalized) running CRC. Start from
* 0xFFFFFFFF, fold each chunk through crc32_update, then XOR the result with
* 0xFFFFFFFF to finalize. Lets a multi-range hash (e.g. a function's
* non-contiguous code ranges) be computed without concatenating into a temp
* buffer, and matches crc32_compute over the concatenation of the same bytes. */
* buffer, and matches crc32_compute over the concatenation of the same bytes.
*
* Bytes are read individually (data[0]..data[7]), not via a reinterpreted
* 32-bit load, so this has no dependency on host byte order or pointer
* alignment — the slicing speedup comes from breaking the serial
* crc-depends-on-previous-crc chain, not from fewer loads, so there is
* nothing to gain from a wider load here and real portability to lose. */
uint32_t crc32_update(uint32_t crc, const uint8_t *data, size_t len) {
if (!s_table_ready) crc32_init_table();
for (size_t i = 0; i < len; i++)
crc = s_table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
if (!s_table8_ready) crc32_init_table8();
while (len >= 8) {
uint32_t b0 = data[0] ^ (uint8_t)(crc);
uint32_t b1 = data[1] ^ (uint8_t)(crc >> 8);
uint32_t b2 = data[2] ^ (uint8_t)(crc >> 16);
uint32_t b3 = data[3] ^ (uint8_t)(crc >> 24);
crc = s_table8[7][b0] ^ s_table8[6][b1] ^ s_table8[5][b2] ^ s_table8[4][b3]
^ s_table8[3][data[4]] ^ s_table8[2][data[5]] ^ s_table8[1][data[6]] ^ s_table8[0][data[7]];
data += 8;
len -= 8;
}
while (len--)
crc = s_table[(crc ^ *data++) & 0xFFu] ^ (crc >> 8);
return crc;
}
+85 -5
View File
@@ -2483,8 +2483,15 @@ void gpu_vblank_clear_deferred_present(void) {
}
void gpu_vblank_arm_deferred_present(void) {
if (s_present_pending < 8)
s_present_pending++;
/* Coalesce: at most one deferred present. Stacking (≥2) drained in one
* flush as double finish_frame at the same guest cycle (MotK soak:
* fin@N and fin@N+1 share dig/cyc → episode skew + clk/tim ±9). */
if (s_present_pending < 1)
s_present_pending = 1;
}
int gpu_vblank_present_pending(void) {
return s_present_pending > 0;
}
void gpu_vblank_release_present_flush_guard(void) {
@@ -2494,6 +2501,40 @@ void gpu_vblank_release_present_flush_guard(void) {
void gpu_vblank_flush_present(void) {
if (s_flushing_present || s_present_pending <= 0)
return;
/* Belt-and-suspenders with interrupts.c: never finish_frame inside the
* exception handler (IEc-clear BB edges used to drain present_pending). */
{
extern int psx_get_in_exception(void);
if (psx_get_in_exception())
return;
}
/* MotK menu wait (0x8006CD54↔0x8006CDA0): present ONLY at an explicit
* CDA0 edge. Never present on a CD54 edge (sticky CDA0 must not allow
* that — soak ep3 non-det fin@946). Non-wait edges (FMV / cutover) must
* present even if sticky still names the wait loop — treating sticky as
* in_wait blocked deferred present for the whole FMV1→FMV2 gap. */
{
extern int psx_netplay_active(void);
extern uint32_t psx_compiled_irq_resume_pc(void);
extern uint32_t psx_last_irq_check_pc(void);
extern uint32_t psx_netplay_rb_sticky_bb_pc(void);
if (psx_netplay_active()) {
const uint32_t wait_a = 0x8006CD54u;
const uint32_t wait_b = 0x8006CDA0u;
uint32_t pc = psx_compiled_irq_resume_pc();
uint32_t last = psx_last_irq_check_pc();
uint32_t sticky = psx_netplay_rb_sticky_bb_pc();
uint32_t edge = pc ? pc : last;
if (edge == wait_a)
return;
if (edge == wait_b || edge != 0u) {
/* CDA0, or non-wait (FMV/cutover) — present; ignore sticky */
} else if (sticky == wait_a) {
return; /* latch cleared, sticky CD54 — defer */
}
/* sticky CDA0 or unrelated/0 — present */
}
}
s_flushing_present = 1;
while (s_present_pending > 0) {
s_present_pending--;
@@ -2539,9 +2580,9 @@ void gpu_vblank_tick(void) {
* resim peers (selfcheck soak: many FAILs with d_cyc≠0). Netplay
* defers — both peers share the same present contract from boot. */
if (psx_netplay_active()) {
/* Cap: a stuck defer must not queue unbounded presents. */
if (s_present_pending < 8)
s_present_pending++;
/* Coalesce to one deferred present (see arm_deferred_present). */
if (s_present_pending < 1)
s_present_pending = 1;
} else {
vblank_callback();
}
@@ -2691,6 +2732,45 @@ uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y
return 0xFF000000u | ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b;
}
/* Batch depth24 (FMV) scanline → ARGB. Same semantics as calling
* gpu_display_pixel_argb(di, x, y) for x in [0, count) (byte-identical
* output, including the black-fill past the 2048-byte VRAM row), but hoists
* the per-row invariants (vy, base_byte_x, the "how many pixels are past the
* row edge" test) out of the per-pixel path instead of recomputing them
* `count` times and paying three gpu_vram_byte() calls per pixel. The
* per-pixel path was the dominant present-side cost of FMV frames (3x the
* VRAM touches of the 16-bit path, done as a function-call chain instead of
* a straight-line loop). Still scalar C — no host-endianness assumptions,
* same byte-order shifts as gpu_vram_byte. */
void gpu_depth24_present_row(const GpuDisplayInfo* di, uint32_t y, uint32_t* out,
uint32_t count) {
uint32_t vy = (di->display_y + y) & 511u;
uint32_t base_byte_x = (di->display_x & 1023u) * 2u;
const uint16_t* row = vram + (size_t)vy * 1024u;
uint32_t valid = 0u;
uint32_t x;
if (base_byte_x <= 2045u)
valid = (2045u - base_byte_x) / 3u + 1u;
if (valid > count)
valid = count;
for (x = 0; x < valid; x++) {
uint32_t byte_x = base_byte_x + x * 3u;
uint32_t bx1 = byte_x + 1u;
uint32_t bx2 = byte_x + 2u;
uint32_t hw0 = row[byte_x >> 1];
uint32_t hw1 = row[bx1 >> 1];
uint32_t hw2 = row[bx2 >> 1];
uint8_t r = (byte_x & 1u) ? (uint8_t)(hw0 >> 8) : (uint8_t)hw0;
uint8_t g = (bx1 & 1u) ? (uint8_t)(hw1 >> 8) : (uint8_t)hw1;
uint8_t b = (bx2 & 1u) ? (uint8_t)(hw2 >> 8) : (uint8_t)hw2;
out[x] = 0xFF000000u | ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b;
}
for (; x < count; x++)
out[x] = 0xFF000000u;
}
int gpu_display_is_depth24(void) {
return (int)(display_depth & 1u);
}
+55 -7
View File
@@ -638,7 +638,11 @@ void interrupts_resync_after_restore(void) {
* delivery — including VBlank — until the restored clock catches up,
* freezing the picture for those N seconds while host FPS stays at 60. */
post_exception_cooldown_until = 0;
cycles_since_vblank = 0;
/* Do NOT zero cycles_since_vblank: boot_state restores it from the snap
* (BS_SEC_IRQ +4). Zeroing rebased every warm tip to phase 0 while timers /
* LCF stayed at the snap's mid-frame phase — MotK wait-loop resim then
* delivered VBlank on opposite CD54↔CDA0 edges (cyc±3 / core fork). Legacy
* 8-byte IRQ sections still set csv=0 in boot_state_load. */
dispatch_count = 0;
in_exception = 0;
exception_nest_depth = 0;
@@ -848,18 +852,43 @@ int psx_interrupt_delivery_needed(const CPUState* cpu) {
void psx_check_interrupts(CPUState* cpu) {
psx_cyc_batch_flush();
/* Publish this edge's resume PC BEFORE deferred present flush. The MotK
* CDA0 gate reads s_last_interrupt_check_pc; leaving it at the previous
* BB (CD54) made every CDA0 entry flush look like CD54 and no-op. Present
* then only drained on post-IRQ at a CDA0 delivery — if the first post-arm
* VBlank was taken at CD54, peers waited a full extra VB (soak ep9: arm+2
* vs arm+1). */
{
uint32_t edge_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc
: s_compiled_interrupt_resume_pc;
if (edge_pc != 0u)
s_last_interrupt_check_pc = edge_pc;
}
/* Netplay: deferred sdl_vblank_present at BB edge (not mid-block
* fire_vblank_edge). When an IRQ is also deliverable, flush AFTER
* delivery so finish_frame digests post-RFE GPRs at the interrupted
* EPC — MotK menu CD54↔CDA0 was presenting pre-IRQ on opposite edges.
* Selfcheck stays on immediate present (see gpu.c). */
* fire_vblank_edge). Prefer flush AFTER delivery so finish_frame digests
* post-RFE GPRs — but also attempt flush at entry when delivery is due.
* MotK's CDA0 gate no-ops the entry attempt on CD54; without it, sticky
* I_STAT kept skipping entry flush while post-IRQ also skipped on CD54,
* stacking multiple VBlanks into one drain (double finish_frame @ same
* guest cyc → clk/tim ±9 on the next IRQ). Post-IRQ retry still runs.
* Selfcheck stays on immediate present (see gpu.c).
*
* Never flush while in_exception: handler BB edges see IEc clear so
* delivery_needed is false and the old else-branch called finish_frame
* mid-BIOS-handler. Soak: irqctx left restored=0/reason=0, peers forked
* dig_cpu at v0=1 vs countdown (cyc±14) on sealed Cross resim. Outer
* delivery keeps np_present_after_irq and flushes on its RETURN. */
extern int g_ls_suppress_record;
extern int psx_netplay_active(void);
int np_present_after_irq = 0;
if (psx_netplay_active() && psx_interrupt_delivery_needed(cpu))
if (in_exception) {
np_present_after_irq = 0;
} else if (psx_netplay_active() && psx_interrupt_delivery_needed(cpu)) {
gpu_vblank_flush_present(); /* CDA0 only; CD54 no-ops inside gate */
np_present_after_irq = 1;
else
} else {
gpu_vblank_flush_present();
}
#define PSX_CHECK_INTERRUPTS_RETURN() do { \
if (np_present_after_irq) gpu_vblank_flush_present(); \
if (g_ls_suppress_record > 0) g_ls_suppress_record--; \
@@ -871,6 +900,25 @@ void psx_check_interrupts(CPUState* cpu) {
#define COSIM_IRQ_NOTE(kind_) cosim_irq_note(cpu, (kind_), COSIM_IRQ_TAKE_PC(), g_dirty_safe_resume_pc, s_compiled_interrupt_resume_pc, cpu->cop0[COP0_SR])
#endif
/* MotK wait CD54 + VBlank-only I_STAT: never deliver at the CD54 edge in
* netplay — hold until CDA0. Delivering at CD54 leaves v0=slt(1) while a
* peer that hit CDA0 first delivers with countdown in v0 (soak: non-det
* fin@946/@902 CD54 vs CDA0, cyc ±1 VB, on identical baseline+pads).
* UNCONDITIONAL on the edge PC — the earlier gate also required
* gpu_vblank_present_pending(), but s_present_pending is host-only state
* (not in the snap), so replay delivery timing forked across peers.
* Edge PC + I_STAT are guest-deterministic; the wait ping-pong reaches
* CDA0 a few instructions later, so no starvation. */
if (!in_exception && psx_netplay_active()) {
const uint32_t wait_a = 0x8006CD54u;
uint32_t edge = s_last_interrupt_check_pc;
uint32_t pend = i_stat & i_mask;
if (edge == wait_a && pend != 0u &&
(pend & ~(1u << IRQ_VBLANK)) == 0u) {
PSX_CHECK_INTERRUPTS_RETURN();
}
}
s_irq_path_entry++;
/* MotK VLC / FMV hot edge: sticky unmasked I_STAT (CD/VBlank) while
+73 -29
View File
@@ -459,6 +459,9 @@ static void present_session_reset(void) {
s_d24_prev_mdec = 0;
s_d24_saw_gap = 0;
s_d24_cutover_blank = 0;
/* Soft-exit can leave deferred present / flush reentrancy armed; gpu_init
* does not clear them. Rematch then no-ops every flush → black window. */
gpu_vblank_clear_deferred_present();
smooth_60_reset();
}
@@ -859,14 +862,22 @@ extern "C" void psx_frontend_on_savestate_loaded(void) {
post_load_probe_arm();
}
/* Rollback snap apply / realign — keep depth24 hold clear + restage, but do
* NOT run the full savestate present thrash (force_present, cutover gap reset,
* post_load_probe). That path is for one-shot disk loads; episode loads during
* MotK FMV were scrambling the movie. */
/* Rollback snap apply / realign — keep depth24 hold clear + restage. Full
* savestate present thrash (post_load_probe) stays disk-only. When the tip
* is already in FMV/media, mirror the disk cutover reset so resume into
* FMV entry does not treat the tip as a fresh gap (permanent black blank). */
extern "C" void psx_frontend_on_rb_snap_loaded(void) {
const int media = gpu_display_is_depth24() || mdec_recently_active(8) ||
cdrom_fmv_stream_pending() || cdrom_xa_stream_active();
g_audio_cycle_resync = 1;
gpu_depth24_on_savestate_loaded();
s_d24_cutover_blank = 0;
if (media) {
s_d24_saw_gap = 0;
s_d24_prev_mdec = (gpu_display_is_depth24() || mdec_recently_active(8)) ? 1 : 0;
s_disabled_frame_presented = false;
s_force_present_after_load = true;
}
gl_renderer_restage_vram_after_savestate();
vk_renderer_restage_vram_after_savestate();
}
@@ -1164,11 +1175,13 @@ static int ensure_sw_sdl_present(void) {
* mirror via readback — host-GPU nondeterministic; forks peer snaps (core
* matched, VRAM zlib ~220KB apart) then mid-resim cores. SW is guest authority. */
extern "C" void psx_frontend_netplay_force_sw_gpu(void) {
if (s_netplay_sw_gpu_locked)
return;
s_netplay_sw_gpu_locked = 1;
#ifndef PSX_SDL_NO_RENDER
const int had_hw = g_gl_active || g_vk_active;
/* Retry ensure even if a prior attempt locked after GL teardown with a
* null SDL_Renderer (rematch empty window). */
if (s_netplay_sw_gpu_locked && sdl_renderer && sdl_texture && sdl_pixel_buf)
return;
#endif
#ifndef PSX_SDL_NO_RENDER
if (g_gl_active) {
gl_renderer_sync_cpu();
gl_renderer_shutdown();
@@ -1184,14 +1197,17 @@ extern "C" void psx_frontend_netplay_force_sw_gpu(void) {
gr_set_backend(GR_BACKEND_SOFTWARE);
g_video_renderer = 0;
#ifndef PSX_SDL_NO_RENDER
/* Late switch (window already OPENGL/VULKAN): build SDL present path. */
if (had_hw || !sdl_renderer) {
if (ensure_sw_sdl_present() != 0) {
std::fprintf(stderr,
"psxrecomp: netplay SW GPU forced but present path failed "
"(expect black frame)\n");
}
if (ensure_sw_sdl_present() != 0) {
s_netplay_sw_gpu_locked = 0;
std::fprintf(stderr,
"psxrecomp: netplay SW GPU forced but present path failed "
"(expect black/empty window — will retry)\n");
fflush(stderr);
return;
}
s_netplay_sw_gpu_locked = 1;
#else
s_netplay_sw_gpu_locked = 1;
#endif
latency_ring_set_backend("software");
fprintf(stderr,
@@ -3950,11 +3966,20 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
(double)s_np_admit_ticks * invf / (double)s_np_timing_frames;
const double guest_ms =
(double)s_np_guest_ticks * invf / (double)s_np_timing_frames;
/* Fraction of this window's ticks spent resimulating (Replay)
* vs running forward normally (Live). Lets a soak confirm
* "the game was chain-episoding almost the whole time" (near
* 100%) instead of inferring it from raw episode density in
* the log — see docs/ROLLBACK_MOTK_HOOKUP.md 2026-08-01. */
const uint64_t replay_ticks = psx_netplay_rb_take_replay_ticks();
const double replay_pct =
100.0 * (double)replay_ticks / (double)s_np_timing_frames;
std::fprintf(stderr,
"[FPS] game: %.1f fps (%.2fx) | frames: %llu | "
"guest=%.2f ms/f admit=%.2f ms/f (n=%llu)\n",
"guest=%.2f ms/f admit=%.2f ms/f replay=%.0f%% (n=%llu)\n",
fps, speed, (unsigned long long)s_frame_count,
guest_ms, admit_ms, (unsigned long long)s_np_timing_frames);
guest_ms, admit_ms, replay_pct,
(unsigned long long)s_np_timing_frames);
s_np_admit_ticks = 0;
s_np_guest_ticks = 0;
s_np_timing_frames = 0;
@@ -4309,11 +4334,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
}
}
/* Netplay FMV: present 1 of every 4 depth24 vblanks. SW RGB888 scanout
* dominates guest ms; admit + wall pace still run every tick in the
* epilogue. Do NOT skip pace — MotK ran ~90fps when decode was fast and
* pace was skipped here. */
if (psx_netplay_active() && gpu_display_is_depth24()) {
/* Netplay FMV: present 1 of every 4 depth24 vblanks while MDEC is hot.
* SW RGB888 scanout dominates guest ms; admit + wall pace still run every
* tick in the epilogue. Do NOT skip pace — MotK ran ~90fps when decode was
* fast and pace was skipped here. During MDEC-idle cutover (FMV1→FMV2)
* present every frame so the next stream can handshake (1/4 skip left the
* gap looking frozen with cheap guest + TURN admit wait). */
if (psx_netplay_active() && gpu_display_is_depth24() &&
mdec_recently_active(8)) {
if (s_netplay_depth24_present_skip > 0) {
s_netplay_depth24_present_skip--;
return ep;
@@ -4403,9 +4431,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
} else if (g_vk_active) {
vk_renderer_present_blank();
} else {
SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255);
SDL_RenderClear(sdl_renderer);
SDL_RenderPresent(sdl_renderer);
if (!sdl_renderer && ensure_sw_sdl_present() != 0)
return ep;
if (sdl_renderer) {
SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255);
SDL_RenderClear(sdl_renderer);
SDL_RenderPresent(sdl_renderer);
}
}
}
#endif
@@ -4492,11 +4524,12 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
if (g_vk_active) {
if (di.depth24) {
/* 24-bit (FMV): packed RGB lives in the CPU mirror — do NOT
* sync_cpu (FBO readback clobbers RGB888). */
* sync_cpu (FBO readback clobbers RGB888). Batch per-scanline
* (see gpu_depth24_present_row) instead of a per-pixel call
* chain — this loop was the FMV present-side cost. */
for (uint32_t y = 0; y < h; y++)
for (uint32_t x = 0; x < present_w; x++)
sdl_pixel_buf[y * present_w + x] =
gpu_display_pixel_argb(&di, x, y);
gpu_depth24_present_row(&di, y, sdl_pixel_buf + (size_t)y * present_w,
present_w);
/* Trailing / cutover blank: black-fill uncovered RGB cols
* inside the full-width buffer — never shrink present width. */
depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h,
@@ -4553,6 +4586,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
gr_render_display_hires(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)),
(int)di.display_x, (int)di.display_y,
(int)present_w, (int)h);
} else if (di.depth24) {
/* Batch per-scanline (see gpu_depth24_present_row) — the
* per-pixel call chain was the dominant FMV present cost
* (3x the VRAM touches of the 16-bit path below). */
for (uint32_t y = 0; y < h; y++)
gpu_depth24_present_row(&di, y, sdl_pixel_buf + (size_t)y * present_w,
present_w);
} else {
for (uint32_t y = 0; y < h; y++) {
for (uint32_t x = 0; x < present_w; x++) {
@@ -4634,6 +4674,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
(g_video_aa && !depth24_frame) ? 1 : 0,
pin_43 ? 1 : 0, 0 /* full width */);
} else {
if ((!sdl_renderer || !sdl_texture) && ensure_sw_sdl_present() != 0)
return ep;
if (!sdl_renderer || !sdl_texture)
return ep;
SDL_Rect src = { 0, 0, src_w, src_h };
SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf,
(int)(src_w * sizeof(uint32_t)));
+9 -1
View File
@@ -771,7 +771,15 @@ static void write_data(uint32_t value) {
}
int mdec_recently_active(uint32_t within_frames) {
return (uint64_t)(s_frame_count - mdec_last_color_decode_frame) <= within_frames;
/* Guest-cycle hysteresis (not host s_frame_count). Present-skip / Replay
* leave s_frame_count stale so a single decode looked "recent" forever
* (false FMV lockstep on rematch; tip episodes into MotK FMV entry). */
const uint64_t cycles_per_frame = 338688ull;
const uint64_t window =
(uint64_t)within_frames * cycles_per_frame + (cycles_per_frame / 2ull);
if (psx_cycle_count < mdec_last_color_decode_cycle)
return 0;
return (psx_cycle_count - mdec_last_color_decode_cycle) <= window;
}
void mdec_init(void) {
+10
View File
@@ -3,7 +3,9 @@
#include "crc32.h"
#include "dirty_ram_interp.h"
#include "gpu.h"
#include "interrupts.h"
#include "psx_cycles.h"
#include "psx_icache.h"
#include <stdlib.h>
#include <string.h>
@@ -134,9 +136,17 @@ void netplay_core_digest_parts(const CPUState* cpu, NetplayCoreParts* out)
{
uint64_t cyc = psx_cycle_count;
uint32_t csv = interrupts_get_cycles_since_vblank();
crc_clk = crc32_update(crc_clk, (const uint8_t*)&cyc, sizeof(cyc));
crc_clk = crc32_update(crc_clk, (const uint8_t*)&i_stat, sizeof(i_stat));
crc_clk = crc32_update(crc_clk, (const uint8_t*)&i_mask, sizeof(i_mask));
/* VBlank phase was invisible to FRAME_COMMIT — peers could agree on
* core while holding different csv, then resim from zeroed phase. */
crc_clk = crc32_update(crc_clk, (const uint8_t*)&csv, sizeof(csv));
/* I-cache tags travel in BS_SEC_ICACHE; fold them so cache asymmetry
* (fetch-cost fork) surfaces at compare time, not as a resim abort. */
crc_clk = crc32_update(crc_clk, (const uint8_t*)g_psx_icache_tv,
sizeof(g_psx_icache_tv));
}
timers_get_snapshot(counter, mode, target, irq_line, frac);
+506 -5
View File
@@ -35,6 +35,7 @@
#include "cpu_state.h"
#include "gpu.h"
#include "interrupts.h"
#include "mdec.h"
#include "psx_scheduler.h"
#if defined(PSX_HAS_LOBBY_CLIENT)
#include "psx_lobby_client.h"
@@ -425,8 +426,14 @@ static void np_drain_peer_frame_commits(void)
{
rnet_u32 through = 0, hash = 0;
if (!g_np.session) return;
while (rnet_session_take_rb_frame_commit(g_np.session, &through, &hash))
while (rnet_session_take_rb_frame_commit(g_np.session, &through, &hash)) {
/* Tip-extend rereplay: TipHold Live invent FCs may still be queued
* after hc_prime — drop until peer's sealed resim matches. */
if (g_np.rollback &&
psx_netplay_rb_ignore_peer_frame_commit(through, hash))
continue;
netplay_hc_note_peer(&g_np.hc, through, hash);
}
/* FIRST CORE can stick the watermark forever once that tick ages out of
* the 128-slot ring — heal so choose_load_tick sees a real frontier. */
if (netplay_hc_heal_stale_gap(&g_np.hc)) {
@@ -452,10 +459,17 @@ static void np_emit_frame_commit(uint32_t tick)
uint32_t aux = 0u;
int crumb;
if (!g_np.cpu || !g_np.session) return;
/* Live depth24: skip full-RAM FRAME_COMMIT (rewind already deferred).
* Resim still commits every tick. Leaving FMV primes hash_confirm so the
* watermark is not stuck on missing movie slots. */
if (gpu_display_is_depth24() &&
/* TipHold Live invents are not sealed-input truth — emitting them poisoned
* tip-extend Replay (stale peer digests → false resim core diverge). */
if (g_np.rollback && psx_netplay_rb_tip_holding() &&
!psx_netplay_rb_is_resimulating())
return;
/* Live depth24 + hot MDEC: skip full-RAM FRAME_COMMIT (rewind already
* deferred). Resim still commits every tick. MDEC-idle cutover still
* commits so FMV1→FMV2 forks are visible (digs used to go dark for the
* whole movie). Leaving FMV primes hash_confirm so the watermark is not
* stuck on missing movie slots. */
if (gpu_display_is_depth24() && mdec_recently_active(8) &&
!(g_np.rollback && psx_netplay_rb_is_resimulating()))
return;
/* Present-edge: clear PC so parked-0 vs live-BB does not fork FRAME_COMMIT
@@ -1353,6 +1367,144 @@ static int np_digital_new_press(const RNetInputContractFrame *pub,
return newly_pressed != 0;
}
/* After promoting a digital release at `release_tick`, rewrite predicted hist
* rows release_tick+1..sim to the released pad. Hold-last invent ahead of the
* release tip left `pub` still held → ghost second-release episodes (soak:
* RIGHT release@1607 then another `pub=ffdf wire=ffff` @1634). */
static void np_scrub_ahead_predicted(int slot, rnet_u32 release_tick,
const RNetRbFrame *released)
{
rnet_u32 sim;
rnet_u32 t;
unsigned n = 0;
if (!released || !g_np.session)
return;
sim = rnet_session_sim_tick(g_np.session);
if (sim <= release_tick)
return;
for (t = release_tick + 1u; t <= sim; ++t) {
RNetRbFrame row;
RNetRbFrame scrub;
if (!netplay_ih_get(&g_np.ih, slot, t, &row))
continue;
if (!row.is_predicted)
continue;
if (row.buttons == released->buttons &&
row.stick_x == released->stick_x &&
row.stick_y == released->stick_y)
continue;
scrub = *released;
scrub.tick = t;
scrub.is_predicted = 0u;
scrub.is_valid = 1u;
if (netplay_ih_promote(&g_np.ih, slot, &scrub))
n++;
}
if (n) {
fprintf(stderr,
"psxrecomp: rb scrub-ahead release slot=%d from=%u sim=%u n=%u "
"btn=%04x\n",
slot, (unsigned)release_tick, (unsigned)sim, n,
(unsigned)released->buttons);
fflush(stderr);
}
}
/* TipHold Live is stalled at invent-cap while digital is held — predicted hist
* rows past tip may not exist yet, so ordinary reconcile never sees the
* release. Peek wire tip+1..tip+runway; on the first pad delta vs tip hist,
* promote the span and tip-extend (real coalesce). */
static void np_tip_hold_coalesce_ahead(void)
{
uint32_t tip;
uint32_t runway;
rnet_u8 delay_u8;
int slot;
if (!g_np.rollback || !g_np.session)
return;
if (!psx_netplay_rb_tip_holding())
return;
if (psx_netplay_rb_load_pending() || psx_netplay_rb_is_resimulating())
return;
tip = psx_netplay_rb_episode_target();
runway = psx_netplay_rb_tip_runway();
if (tip == 0u || runway == 0u)
return;
delay_u8 = (rnet_u8)(g_np.input_delay < 0 ? 0
: (g_np.input_delay > 255 ? 255 : g_np.input_delay));
for (slot = 0; slot < g_np.slot_count; ++slot) {
RNetRbFrame tip_row;
rnet_u32 edge = 0;
rnet_u32 t;
if (slot == g_np.local_slot)
continue;
if (!netplay_ih_get(&g_np.ih, slot, tip, &tip_row) || !tip_row.is_valid)
continue;
for (t = tip + 1u; t <= tip + runway; ++t) {
RNetInputSample sample;
PsxNetPad pad;
RNetRbFrame wire_frame;
rnet_u32 wire = rnet_wire_tick_from_sim(t, delay_u8);
if (!rnet_session_peek_remote_input(g_np.session, slot, wire, &sample))
break;
decode_pad(&sample, &pad);
netplay_ih_pad_to_frame(&pad, t, 0, &wire_frame);
if (wire_frame.buttons == tip_row.buttons &&
wire_frame.stick_x == tip_row.stick_x &&
wire_frame.stick_y == tip_row.stick_y)
continue;
edge = t;
break;
}
if (!edge)
continue;
for (t = tip + 1u; t <= edge; ++t) {
RNetInputSample sample;
PsxNetPad pad;
RNetRbFrame wire_frame;
rnet_u32 wire = rnet_wire_tick_from_sim(t, delay_u8);
if (!rnet_session_peek_remote_input(g_np.session, slot, wire, &sample))
break;
decode_pad(&sample, &pad);
netplay_ih_pad_to_frame(&pad, t, 0, &wire_frame);
(void)netplay_ih_promote(&g_np.ih, slot, &wire_frame);
}
{
RNetInputSample sample;
PsxNetPad pad;
RNetRbFrame edge_frame;
RNetInputContractFrame tip_c, edge_c;
rnet_u32 wire = rnet_wire_tick_from_sim(edge, delay_u8);
if (rnet_session_peek_remote_input(g_np.session, slot, wire, &sample)) {
decode_pad(&sample, &pad);
netplay_ih_pad_to_frame(&pad, edge, 0, &edge_frame);
netplay_ih_frame_to_contract(&tip_row, &tip_c);
netplay_ih_frame_to_contract(&edge_frame, &edge_c);
if (np_digital_release_only(&tip_c, &edge_c))
np_scrub_ahead_predicted(slot, edge, &edge_frame);
}
}
fprintf(stderr,
"psxrecomp: rb tip-hold coalesce-ahead tip=%u edge=%u slot=%d "
"tip_btn=%04x\n",
(unsigned)tip, (unsigned)edge, slot, (unsigned)tip_row.buttons);
fflush(stderr);
if (psx_netplay_rb_tip_extend(edge, slot))
return;
/* tip-extend refused (span cap / different seat) — open via begin. */
if (psx_netplay_rb_begin_rewind(edge, slot))
return;
}
}
static void np_timesync_note_late(void);
/* Late authoritative wire vs published predicted rows → promote or queue rewind.
* Diag: promote-no-resim means hist took the late pad but guest sim did not
* rewind — looks like "remote input rejected" when cadence already drifted. */
@@ -1439,6 +1591,16 @@ static void np_rollback_reconcile_wire(void)
pads_differ = (pub_c.buttons != wire_c.buttons) ||
(pub_c.stick_x != wire_c.stick_x) ||
(pub_c.stick_y != wire_c.stick_y);
/* Feed the timesync pacer ONLY on a genuine mispredict (the
* invented value was wrong). Firing on every predicted-row
* resolution — the prior cut — meant EVERY normally-delayed
* prediction counted as "late" (that is simply how input delay
* works), drowning the one signal that actually identifies the
* ahead peer. 2026-08-01 soak: gated correctly, the peer that
* initiates corrections is unambiguous (28 vs 12 rewind-requests
* split across the two sides in one session). */
if (pads_differ)
np_timesync_note_late();
/* After commit/realign: flush invent poison without another episode. */
if (no_resim) {
(void)netplay_ih_promote(&g_np.ih, slot, &wire_frame);
@@ -1464,6 +1626,7 @@ static void np_rollback_reconcile_wire(void)
* menu releases open a real episode. */
if (fmv_defer && np_digital_release_only(&pub_c, &wire_c)) {
(void)netplay_ih_promote(&g_np.ih, slot, &wire_frame);
np_scrub_ahead_predicted(slot, t, &wire_frame);
if (n_soft_release == 0) {
first_no_resim_t = t;
first_no_resim_slot = slot;
@@ -1505,6 +1668,8 @@ static void np_rollback_reconcile_wire(void)
* peer SEAL_ROWS land. Skipping promote left invent-idle
* pads sealed/published across the skip press tick. */
(void)netplay_ih_promote(&g_np.ih, slot, &wire_frame);
if (np_digital_release_only(&pub_c, &wire_c))
np_scrub_ahead_predicted(slot, t, &wire_frame);
if (psx_netplay_rb_begin_rewind(t, slot)) {
g_np.needs_advance = 0;
n_episode_open++;
@@ -1516,6 +1681,8 @@ static void np_rollback_reconcile_wire(void)
}
} else if (pads_differ) {
(void)netplay_ih_promote(&g_np.ih, slot, &wire_frame);
if (np_digital_release_only(&pub_c, &wire_c))
np_scrub_ahead_predicted(slot, t, &wire_frame);
n_contract_promote++;
} else {
(void)netplay_ih_promote(&g_np.ih, slot, &wire_frame);
@@ -1523,6 +1690,11 @@ static void np_rollback_reconcile_wire(void)
}
}
/* TipHold invent-cap stall: peek wire past tip for release/press edges that
* have no predicted hist row yet (Live never invented them). */
if (!g_np.pending_rewind)
np_tip_hold_coalesce_ahead();
if (n_no_resim || n_soft_release || n_episode_open || n_begin_refused) {
static uint32_t s_log_sim;
static unsigned s_suppress;
@@ -1568,6 +1740,276 @@ static void np_rollback_reconcile_wire(void)
}
}
/* Stall-before-invent grace. Soaks showed every menu episode was a real
* press/release edge whose wire row landed only 1–2 ticks after the seal
* point (LAN, admit skew — not link latency): invent-on-first-miss then
* mispredicted the edge and opened a paired episode every few ticks
* (resim storm, 0.44–0.8x). Before inventing a missing remote row, stall
* the admit up to PSX_RB_INVENT_GRACE_MS (default 30) measured from the
* first miss of that wire tick. This is a rate governor, not just packet
* wait: the lateness is sim skew (soak: ALL late edges were on the peer
* running 1–2 ticks ahead; the other side had zero) — while the ahead
* peer stalls here, the behind peer keeps simulating and catches up, so
* in steady state the sims stay aligned and inputs arrive before the
* seal with no stall at all. The first cut (8 ms) was shorter than one
* tick of skew, expired on every edge and tripped the adaptive-off —
* worst of both. The budget scales with the observed tick period: at
* fight-scene rates (~35-45 fps) a tick lasts ~25 ms, so a fixed 30 ms
* no longer covers 2 ticks of skew and gameplay stormed again; the
* effective budget is max(base, 2.5x tick-period EMA), capped at 100 ms.
* The caller only invokes this when >=2 ticks ahead of the remote sealed
* tip: stalling on a 1-tick gap (normal pipeline phase offset) serialized
* the two sims — each peer's admit waited out the other's guest frame and
* throughput fell to 1/(g1+g2) even with zero episodes.
* Adaptive off: if the grace keeps expiring (peer
* genuinely lagging beyond the budget — real WAN latency), disable it
* for 2s so we don't add a per-tick stall on top of real lag.
* Host-side pacing only — the invented value is unchanged (hold-last), so
* guest determinism is unaffected. 0 disables. */
/* Budget policy (2026-08-01, see docs/ROLLBACK_MOTK_HOOKUP.md section 12):
* this used to scale purely off the local sim's own tick-to-tick cadence
* (s_tick_ema_ms), which is the wrong signal — tick period gets *longer*
* precisely when the engine is already struggling (an active resim burst,
* a heavy scene), none of which means the network needs more patience. That
* let a slowdown balloon the very stall reacting to it: a soak on a genuine
* sub-ms-RTT direct-LAN connection measured this landing at 70-90ms per
* occurrence. Now sized primarily from psx_netplay_rb_rtt_estimate_ms() — a
* live measurement of actual POST-handshake round-trip latency — with the
* old tick-cadence term kept only as a *fallback* for the brief window
* before the first episode has round-tripped this session (no RTT sample
* yet), and tightened since it's no longer the primary mechanism. */
static int np_invent_grace_stall(int slot, rnet_u32 wire)
{
static int s_grace_ms = -1;
static rnet_u32 s_wire[PSX_MAX_PLAYERS];
static uint32_t s_t0[PSX_MAX_PLAYERS];
static uint8_t s_expired[PSX_MAX_PLAYERS];
static uint8_t s_budget_logged[PSX_MAX_PLAYERS];
static uint32_t s_expire_streak;
static uint32_t s_off_until;
static uint32_t s_tick_ema_ms; /* fallback-only EMA of inter-tick period, ms */
uint32_t now;
uint32_t budget;
uint32_t rtt;
if (s_grace_ms < 0) {
const char *e = getenv("PSX_RB_INVENT_GRACE_MS");
s_grace_ms = (e && e[0]) ? atoi(e) : 8;
if (s_grace_ms < 0) s_grace_ms = 0;
if (s_grace_ms > 200) s_grace_ms = 200;
fprintf(stderr,
"psxrecomp: rb invent grace floor=%d ms (minimum stall before "
"hold-last invent; PSX_RB_INVENT_GRACE_MS — actual per-stall "
"budget scales up from measured RTT, see 'rb invent grace "
"budget' lines)\n",
s_grace_ms);
fflush(stderr);
}
if (s_grace_ms == 0 || slot < 0 || slot >= PSX_MAX_PLAYERS)
return 0;
now = np_mono_ms();
if (s_off_until != 0u && (int32_t)(now - s_off_until) < 0)
return 0;
s_off_until = 0u;
if (s_wire[slot] != wire) {
/* Previous tracked tick arrived before its grace expired (we moved
* on without hitting the expiry path) — the link is keeping up. */
if (s_wire[slot] != 0u && !s_expired[slot])
s_expire_streak = 0;
/* Consecutive tracked ticks give the live tick period — fallback
* signal only now (see policy note above); real budget sizing
* prefers psx_netplay_rb_rtt_estimate_ms() below. */
if (s_wire[slot] != 0u && wire == s_wire[slot] + 1u) {
uint32_t dt = now - s_t0[slot];
if (dt >= 1u && dt <= 250u)
s_tick_ema_ms = s_tick_ema_ms
? (3u * s_tick_ema_ms + dt) / 4u
: dt;
}
s_wire[slot] = wire;
s_t0[slot] = now;
s_expired[slot] = 0;
s_budget_logged[slot] = 0;
return 1;
}
budget = (uint32_t)s_grace_ms;
rtt = psx_netplay_rb_rtt_estimate_ms();
if (rtt) {
/* 1.5x measured RTT: slack for jitter without inheriting the old
* formula's habit of ballooning under local slowdown, since network
* transit time does not degrade just because guest/replay cost did. */
uint32_t scaled = rtt + rtt / 2u;
if (scaled > budget)
budget = scaled;
if (budget > 60u) /* hard ceiling regardless of measured RTT */
budget = 60u;
} else if (s_tick_ema_ms) {
/* No RTT sample yet this session (before the first episode has
* round-tripped) — narrower and lower-ceiling than before, since
* this is a brief startup fallback, not the steady-state mechanism. */
uint32_t scaled = (s_tick_ema_ms * 3u) / 2u; /* 1.5 ticks, was 2.5 */
if (scaled > budget)
budget = scaled;
if (budget > 40u) /* was 100 */
budget = 40u;
}
if (!s_budget_logged[slot]) {
s_budget_logged[slot] = 1;
fprintf(stderr,
"psxrecomp: rb invent grace budget=%u ms (floor=%d rtt=%u "
"tick_ema=%u) slot=%d wire=%u\n",
(unsigned)budget, s_grace_ms, (unsigned)rtt,
(unsigned)s_tick_ema_ms, slot, (unsigned)wire);
fflush(stderr);
}
if ((uint32_t)(now - s_t0[slot]) < budget)
return 1;
if (!s_expired[slot]) {
s_expired[slot] = 1;
if (++s_expire_streak >= 45u) {
s_expire_streak = 0;
s_off_until = now + 2000u;
fprintf(stderr,
"psxrecomp: rb invent grace OFF 2s (remote input "
"consistently later than %u ms)\n",
(unsigned)budget);
fflush(stderr);
}
}
return 0;
}
/* Mispredict-driven timesync pacing. The first cut used a GGPO-style
* advantage metric (wire_need - highest received remote row) with an
* absolute +0.5 tick threshold — soak showed BOTH peers measure ~+0.6
* ticks at the natural operating point (each side samples at the start
* of its own tick), so both throttled, neither "closed", and both
* tripped the off-guard while the mispredicts continued. The ground
* truth for "I am the ahead peer" is the mispredict itself: only the
* ahead side promotes real rows that arrived AFTER it already invented
* them WRONG — note_late() is gated on pads_differ in the reconcile
* caller (an earlier cut fired on every predicted-row resolution, which
* is just normal input-delay operation and swamped the signal). Each
* genuine mispredict adds ~half a tick of pacing debt (capped at 2
* ticks); the admit path shaves the debt off at <=3 ms per tick. Zero
* cost in steady state (aligned phase -> no mispredicts -> no debt).
* Adaptive off: on real WAN transit every edge mispredicts no matter the
* phase, so debt keeps landing back at the cap — a streak of 12
* consecutive cap-hits (no room to have drained between them) disables
* for 10 s. Earlier cut used "debt continuously nonzero for 5s", but an
* active mispredict burst legitimately keeps debt elevated while pacing
* is working exactly as intended — soak: 24/36 commits and 19/28
* rewind-requests on the busier peer landed AFTER that off-guard fired,
* i.e. it disabled pacing precisely when the storm needed it most. Host
* pacing only — guest determinism unaffected. PSX_RB_TIMESYNC=0
* disables. */
static int g_ts_enabled = -1;
static uint32_t g_ts_tick_ema_ms;
static uint32_t g_ts_debt_ms;
static uint32_t g_ts_pegged_streak; /* consecutive mispredicts landing at/above cap */
static uint32_t g_ts_off_until_ms;
static void np_timesync_check_enabled(void)
{
if (g_ts_enabled < 0) {
const char *e = getenv("PSX_RB_TIMESYNC");
g_ts_enabled = (e && e[0]) ? (atoi(e) != 0) : 1;
}
}
/* Reconcile saw a real remote row that contradicted what we invented for
* this tick — i.e. we are (locally) the mispredicting/ahead peer for this
* edge. Add pacing debt.
* Adaptive off (2026-08-01 soak): the first cut disabled after debt sat
* "continuously nonzero" for 5s — but during an active mispredict burst,
* debt SHOULD stay elevated for a while (each edge tops it back up before
* the previous slice fully drains); that isn't pacing failing, it's pacing
* doing its job under sustained load. Soak evidence: 24/36 episode commits
* and 19/28 rewind-requests on the busier peer landed AFTER that off-guard
* fired — it was disabling exactly when it was needed most. The real "this
* is transit latency, not phase skew" signal is debt landing AT THE CAP
* repeatedly with no room to have drained in between — track a streak of
* cap-hits instead of wall-clock nonzero time. */
static void np_timesync_note_late(void)
{
uint32_t now;
uint32_t add;
uint32_t cap;
np_timesync_check_enabled();
if (!g_ts_enabled)
return;
now = np_mono_ms();
if (g_ts_off_until_ms != 0u && (int32_t)(now - g_ts_off_until_ms) < 0)
return;
g_ts_off_until_ms = 0u;
add = g_ts_tick_ema_ms ? g_ts_tick_ema_ms / 2u : 8u;
cap = g_ts_tick_ema_ms ? g_ts_tick_ema_ms * 2u : 34u;
if (g_ts_debt_ms >= cap) {
if (++g_ts_pegged_streak >= 12u) {
g_ts_pegged_streak = 0u;
g_ts_debt_ms = 0u;
g_ts_off_until_ms = now + 10000u;
fprintf(stderr,
"psxrecomp: rb timesync OFF 10s (mispredicts keep landing "
"at the pacing cap — transit latency, not phase skew)\n");
fflush(stderr);
return;
}
} else {
g_ts_pegged_streak = 0u;
}
g_ts_debt_ms += add;
if (g_ts_debt_ms > cap)
g_ts_debt_ms = cap;
}
/* Admit-side: shave pacing debt off at <=3 ms per tick. */
static int np_timesync_throttle(uint32_t wire)
{
static uint32_t s_last_wire;
static uint32_t s_last_wire_ms;
static uint32_t s_stall_until;
static uint8_t s_logged;
uint32_t now;
np_timesync_check_enabled();
if (!g_ts_enabled)
return 0;
now = np_mono_ms();
if (wire != s_last_wire) {
if (s_last_wire != 0u && wire == s_last_wire + 1u) {
uint32_t dt = now - s_last_wire_ms;
if (dt >= 1u && dt <= 250u)
g_ts_tick_ema_ms = g_ts_tick_ema_ms
? (7u * g_ts_tick_ema_ms + dt) / 8u
: dt;
}
s_last_wire = wire;
s_last_wire_ms = now;
if (g_ts_debt_ms > 0u) {
uint32_t slice = g_ts_debt_ms > 3u ? 3u : g_ts_debt_ms;
g_ts_debt_ms -= slice;
if (g_ts_debt_ms == 0u)
s_logged = 0;
s_stall_until = now + slice;
if (!s_logged) {
fprintf(stderr,
"psxrecomp: rb timesync pacing (debt=%u ms tick=%u ms — "
"shaving <=3 ms/tick)\n",
(unsigned)(g_ts_debt_ms + slice),
(unsigned)g_ts_tick_ema_ms);
fflush(stderr);
s_logged = 1;
}
}
}
if (s_stall_until != 0u && (int32_t)(now - s_stall_until) < 0)
return 1;
s_stall_until = 0u;
return 0;
}
/* Rollback admit: tip + invent remotes within P of remote tip; stall outside.
* BattleShip phase_lock: invent only when wire_need <= highest_remote + P. */
static int np_try_admit_rollback(void)
@@ -1598,6 +2040,54 @@ static int np_try_admit_rollback(void)
memset(&st, 0, sizeof(st));
rnet_session_get_stats(g_np.session, &st);
/* TipHold past invent-cap: never invent (that caused tip-extend rereplay
* cliffs). Advance only when every remote wire row is present *and* all
* pads (local + remote) are idle (0xFFFF). Live-walking held digital
* through the runway fed MotK menu key-repeat (~24 extra navigations per
* press) and burned the coalesce window before tip-extend could absorb
* the release (soak: tip-extend=0, tip-hold→commit adjacent). Missing
* remotes or any held button → stall; wall-clock quiet / coalesce-ahead
* owns the runway. */
if (psx_netplay_rb_tip_holding()) {
uint32_t tip = psx_netplay_rb_episode_target();
uint32_t slack = psx_netplay_rb_tip_hold_invent_slack();
if (tip > 0u && sim > tip + slack) {
int missing = 0;
int held = 0;
for (slot = 0; slot < g_np.slot_count; ++slot) {
if (slot == g_np.local_slot) {
if (rnet_session_peek_input(g_np.session, slot, wire,
&sample)) {
decode_pad(&sample, &pad);
if (pad.buttons != 0xFFFFu)
held = 1;
} else if (g_np.staged_valid &&
g_np.staged.buttons != 0xFFFFu) {
held = 1;
}
continue;
}
if (!rnet_session_peek_remote_input(g_np.session, slot, wire,
&sample)) {
missing = 1;
break;
}
decode_pad(&sample, &pad);
if (pad.buttons != 0xFFFFu)
held = 1;
}
if (missing || held)
return 0;
}
}
/* Phase alignment: the ahead peer paces down a few ms/tick so remote
* rows arrive before the seal (kills invent-mispredict episodes at the
* source). Never engages during episodes/lockstep — only live admits. */
if (!psx_netplay_rb_active() &&
np_timesync_throttle(wire))
return 0;
for (slot = 0; slot < g_np.slot_count; ++slot) {
if (slot == g_np.local_slot) {
if (rnet_session_peek_input(g_np.session, slot, wire, &sample)) {
@@ -1631,6 +2121,17 @@ static int np_try_admit_rollback(void)
/* Stall when invent would run more than P ahead of remote tip. */
if (wire > st.highest_remote_wire + (rnet_u32)pred)
return 0;
/* Brief stall before inventing — but ONLY when genuinely >=2
* ticks ahead of the remote's sealed tip. A 1-tick gap is the
* normal phase offset of two pipelined sims: stalling on it
* serialized the peers (each admit waited out the other's
* guest frame; throughput fell to 1/(g1+g2) ~ 40-50 fps with
* zero episodes). Invent freely at gap<=1 — hold-last only
* mispredicts on real edges and episodes are cheap now that
* the agreed watermark tracks live. */
if (wire > st.highest_remote_wire + 1u &&
np_invent_grace_stall(slot, wire))
return 0;
/* MotK digital: hold-last. Idle invent re-mismatched every held
* D-pad tick after commit → episode storm / char-select freeze.
* Menu release soft-promote is off (see reconcile) so sticky Up
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -786,7 +786,9 @@ static void sc_do_load(struct CPUState *cpu, const char *via)
}
psx_cycles_resync_after_restore(cpu);
interrupts_resync_after_restore();
/* Keep the snap's VBlank phase (resync zeroed it for UI savestate safety). */
/* Keep the snap's VBlank phase (boot_state also restores csv from IRQ
* section; re-apply the latched value so ambient-prime / legacy 8B snaps
* stay bit-identical across replay#1/#2). */
interrupts_set_cycles_since_vblank(s_snap_csv);
/* Pin host frame counter so present/GPU hysteresis that keys on
* s_frame_count cannot fork resim#1 vs resim#2 (+span otherwise). */
+45
View File
@@ -0,0 +1,45 @@
/*
* test_rb_post_tip_filter.c — tip-extend stale POST must not latch.
*
* Soak class (rb-diag 2026-07-31): both peers POST@1007 (matched), tip-extend
* to 1008, both POST@1008 (matched); initiator aborted local@1008 vs peer@1007.
*
* gcc -std=c11 -Wall -Wextra -Werror -I../include \
* -o test_rb_post_tip_filter test_rb_post_tip_filter.c && \
* ./test_rb_post_tip_filter
*/
#include "netplay_rb_post.h"
#include <stdio.h>
static int failures;
#define CHECK(cond, msg) \
do { \
if (!(cond)) { \
printf("FAIL: %s\n", msg); \
failures++; \
} else { \
printf("ok: %s\n", msg); \
} \
} while (0)
int main(void)
{
const uint32_t tip_old = 1007u;
const uint32_t tip_new = 1008u;
CHECK(netplay_rb_peer_post_tip_ok(tip_old, tip_old), "POST@T ok while tip=T");
CHECK(netplay_rb_peer_post_tip_ok(tip_new, tip_new), "POST@T+1 ok while tip=T+1");
CHECK(!netplay_rb_peer_post_tip_ok(tip_old, tip_new),
"stale POST@T rejected during Verify tip=T+1");
CHECK(!netplay_rb_peer_post_tip_ok(tip_new, tip_old),
"future POST@T+1 rejected while tip=T");
CHECK(!netplay_rb_peer_post_tip_ok(0u, tip_new), "zero tip rejected");
if (failures == 0) {
printf("test_rb_post_tip_filter: ok\n");
return 0;
}
printf("test_rb_post_tip_filter: %d failure(s)\n", failures);
return 1;
}
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# Bundle imported non-system DLLs next to Windows PE executables.
#
# Title packagers (BPE/MotK setup zips) call this for Windows PEs that still
# import non-system DLLs (typically the MSYS2-built setup host + SDL2).
# Emitters built with llvm-mingw + PSXRECOMP_STATIC_CLI should import none of
# the GCC runtimes; this script only copies DLLs each exe actually imports.
#
# Usage:
# bundle_mingw_dlls.sh [options] --exe <path> [--dest <dir>] [--label <name>] ...
#
# Options:
# --runtime-bin DIR Preferred DLL search directory (repeatable)
# --search-dir DIR Extra DLL search directory (repeatable)
# --exe PATH PE to inspect (repeatable; --dest/--label apply to it)
# --dest DIR Copy DLLs here (default: dirname of the preceding --exe)
# --label NAME Log label for the preceding --exe
# --require DLL After bundling, DLL must exist in every --dest that
# imported it (repeatable)
# --soft-missing Warn instead of exit 1 when objdump is unavailable
#
# Exit 1 if an imported DLL cannot be found, or a --require check fails.
set -euo pipefail
SOFT_MISSING=0
RUNTIME_BINS=()
SEARCH_DIRS=()
REQUIRE=()
# Parallel arrays for targets.
EXE_PATHS=()
DEST_DIRS=()
LABELS=()
pending_dest=""
pending_label=""
usage() {
sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'
exit 2
}
flush_pending_meta() {
# Apply trailing --dest/--label to the last --exe.
local n="${#EXE_PATHS[@]}"
if [[ "$n" -eq 0 ]]; then
return 0
fi
local i=$((n - 1))
if [[ -n "${pending_dest}" ]]; then
DEST_DIRS[$i]="${pending_dest}"
pending_dest=""
fi
if [[ -n "${pending_label}" ]]; then
LABELS[$i]="${pending_label}"
pending_label=""
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--soft-missing) SOFT_MISSING=1; shift ;;
--runtime-bin)
RUNTIME_BINS+=("${2:?}"); shift 2 ;;
--search-dir)
SEARCH_DIRS+=("${2:?}"); shift 2 ;;
--require)
REQUIRE+=("${2:?}"); shift 2 ;;
--exe)
flush_pending_meta
EXE_PATHS+=("${2:?}")
DEST_DIRS+=("")
LABELS+=("")
shift 2
;;
--dest)
pending_dest="${2:?}"; shift 2 ;;
--label)
pending_label="${2:?}"; shift 2 ;;
*)
echo "error: unknown arg: $1" >&2
usage
;;
esac
done
flush_pending_meta
if [[ ${#EXE_PATHS[@]} -eq 0 ]]; then
echo "error: at least one --exe is required" >&2
usage
fi
OBJDUMP=""
if command -v x86_64-w64-mingw32-objdump >/dev/null 2>&1; then
OBJDUMP="x86_64-w64-mingw32-objdump"
elif command -v objdump >/dev/null 2>&1; then
OBJDUMP="objdump"
else
if [[ "${SOFT_MISSING}" -eq 1 ]]; then
echo "warning: no objdump; skipping MinGW DLL bundling" >&2
exit 0
fi
echo "error: no objdump; cannot bundle MinGW DLLs" >&2
exit 1
fi
SYSTEM_DLL_RE='^(KERNEL32|USER32|GDI32|ADVAPI32|SHELL32|OLE32|OLEAUT32|WS2_32|WINMM|IMM32|SETUPAPI|VERSION|OPENGL32|COMCTL32|COMDLG32|RPCRT4|SHLWAPI|CRYPT32|BCRYPT|IPHLPAPI|NSI|DNSAPI|MSVCRT|UCRTBASE|VCRUNTIME|API-MS-).*\.DLL$'
PROBE_DLLS=(
SDL2.dll
zlib1.dll
libgcc_s_seh-1.dll
libstdc++-6.dll
libc++.dll
libunwind.dll
libwinpthread-1.dll
libssp-0.dll
)
exe_imports_dll() {
local exe="$1"
local dll="$2"
"${OBJDUMP}" -p "${exe}" 2>/dev/null | grep -qi "DLL Name:[[:space:]]*${dll}"
}
bundle_one() {
local exe="$1"
local dest_dir="$2"
local label="$3"
local dll src key cand d
local -a needed=()
local -a unique=()
local -a candidates=()
local -A seen=()
if [[ ! -f "${exe}" ]]; then
echo "error: cannot bundle DLLs; missing ${exe}" >&2
exit 1
fi
if [[ -z "${dest_dir}" ]]; then
dest_dir="$(dirname "${exe}")"
fi
if [[ -z "${label}" ]]; then
label="$(basename "${exe}")"
fi
mkdir -p "${dest_dir}"
mapfile -t needed < <(
"${OBJDUMP}" -p "${exe}" 2>/dev/null \
| awk '/DLL Name:/{print $3}' \
| grep -viE "${SYSTEM_DLL_RE}" \
| sort -u || true
)
needed+=("${PROBE_DLLS[@]}")
for dll in "${needed[@]}"; do
[[ -n "${dll}" ]] || continue
key="$(printf '%s' "${dll}" | tr '[:upper:]' '[:lower:]')"
if [[ -n "${seen[$key]:-}" ]]; then
continue
fi
seen[$key]=1
unique+=("${dll}")
done
for dll in "${unique[@]}"; do
if ! exe_imports_dll "${exe}" "${dll}"; then
continue
fi
src=""
candidates=(
"$(dirname "${exe}")/${dll}"
"${dest_dir}/${dll}"
"/mingw64/bin/${dll}"
"/usr/x86_64-w64-mingw32/bin/${dll}"
)
for d in "${SEARCH_DIRS[@]+"${SEARCH_DIRS[@]}"}"; do
candidates+=("${d}/${dll}")
done
for d in "${RUNTIME_BINS[@]+"${RUNTIME_BINS[@]}"}"; do
candidates+=("${d}/${dll}")
done
for cand in "${candidates[@]}"; do
[[ -n "${cand}" ]] || continue
if [[ -f "${cand}" ]]; then
src="${cand}"
break
fi
done
if [[ -z "${src}" ]]; then
echo "error: required DLL missing for ${label}: ${dll}" >&2
echo " exe: ${exe}" >&2
echo " dest: ${dest_dir}" >&2
if [[ ${#SEARCH_DIRS[@]} -gt 0 ]]; then
echo " search-dirs: ${SEARCH_DIRS[*]}" >&2
fi
if [[ ${#RUNTIME_BINS[@]} -gt 0 ]]; then
echo " runtime-bins: ${RUNTIME_BINS[*]}" >&2
fi
exit 1
fi
dest_file="${dest_dir}/${dll}"
# MSYS2/Windows cp fails when source and dest are the same file.
if [[ -f "${dest_file}" ]] && [[ "${src}" -ef "${dest_file}" ]]; then
echo "bundled ${dll} → ${dest_dir}/ (${label}; already present)"
continue
fi
src_res="$(cd "$(dirname "${src}")" && pwd)/$(basename "${src}")"
dest_res="$(cd "${dest_dir}" && pwd)/${dll}"
if [[ "${src_res}" == "${dest_res}" ]]; then
echo "bundled ${dll} → ${dest_dir}/ (${label}; already present)"
continue
fi
cp -f "${src}" "${dest_dir}/"
echo "bundled ${dll} → ${dest_dir}/ (${label})"
done
}
for i in "${!EXE_PATHS[@]}"; do
bundle_one "${EXE_PATHS[$i]}" "${DEST_DIRS[$i]}" "${LABELS[$i]}"
done
for dll in "${REQUIRE[@]}"; do
[[ -n "${dll}" ]] || continue
for i in "${!EXE_PATHS[@]}"; do
exe="${EXE_PATHS[$i]}"
dest="${DEST_DIRS[$i]:-}"
if [[ -z "${dest}" ]]; then
dest="$(dirname "${exe}")"
fi
if ! exe_imports_dll "${exe}" "${dll}"; then
continue
fi
if [[ ! -f "${dest}/${dll}" ]]; then
echo "error: $(basename "${exe}") imported ${dll} but it is missing under ${dest}" >&2
exit 1
fi
done
done
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Configure and build psxrecomp-game + psxrecomp-bios.
#
# On Windows CI, prefer the portable cmake-clang-v1 (llvm-mingw) pack so
# emitters statically link libc++ and do not need MSYS2 GCC DLLs.
#
# Usage (from game repo root):
# psxrecomp/tools/ci/build_emitters.sh \
# [--framework psxrecomp] [--build-dir build-recompiler] \
# [--toolchain-dir DIR] [--jobs N]
#
# Env:
# PSXRECOMP_TOOLCHAIN_DIR | TOOLCHAIN_DIR | BPE_TOOLCHAIN_DIR
set -euo pipefail
FRAMEWORK="psxrecomp"
BUILD_DIR="build-recompiler"
JOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
TOOLCHAIN_DIR="${PSXRECOMP_TOOLCHAIN_DIR:-${TOOLCHAIN_DIR:-${BPE_TOOLCHAIN_DIR:-}}}"
while [[ $# -gt 0 ]]; do
case "$1" in
--framework) FRAMEWORK="${2:?}"; shift 2 ;;
--build-dir) BUILD_DIR="${2:?}"; shift 2 ;;
--toolchain-dir) TOOLCHAIN_DIR="${2:?}"; shift 2 ;;
--jobs) JOBS="${2:?}"; shift 2 ;;
-h|--help)
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
echo "error: unknown arg: $1" >&2
exit 2
;;
esac
done
if [[ ! -d "${FRAMEWORK}/recompiler" ]]; then
echo "error: ${FRAMEWORK}/recompiler missing" >&2
exit 1
fi
CMAKE_ARGS=(
-S "${FRAMEWORK}/recompiler"
-B "${BUILD_DIR}"
-G Ninja
-DCMAKE_BUILD_TYPE=Release
)
# Prefer portable llvm-mingw pack when present (Windows release CI).
if [[ -n "${TOOLCHAIN_DIR}" && -d "${TOOLCHAIN_DIR}/bin" ]]; then
TOOLCHAIN_DIR="$(cd "${TOOLCHAIN_DIR}" && pwd)"
export PATH="${TOOLCHAIN_DIR}/bin:${PATH}"
if [[ -f "${TOOLCHAIN_DIR}/env.sh" ]]; then
# shellcheck disable=SC1091
source "${TOOLCHAIN_DIR}/env.sh"
fi
pick_tool() {
local name="$1"
if [[ -f "${TOOLCHAIN_DIR}/bin/${name}.exe" ]]; then
echo "${TOOLCHAIN_DIR}/bin/${name}.exe"
elif [[ -f "${TOOLCHAIN_DIR}/bin/${name}" ]]; then
echo "${TOOLCHAIN_DIR}/bin/${name}"
else
return 1
fi
}
if CC_BIN="$(pick_tool clang)"; then
CMAKE_ARGS+=("-DCMAKE_C_COMPILER=${CC_BIN}")
fi
if CXX_BIN="$(pick_tool clang++)"; then
CMAKE_ARGS+=("-DCMAKE_CXX_COMPILER=${CXX_BIN}")
fi
if CMAKE_BIN="$(pick_tool cmake)"; then
CMAKE="${CMAKE_BIN}"
else
CMAKE="cmake"
fi
# Static CRT/libc++ so staged emitters need no MSYS2 GCC DLLs.
CMAKE_ARGS+=(-DPSXRECOMP_STATIC_CLI=ON)
echo "build_emitters: using portable toolchain at ${TOOLCHAIN_DIR}"
else
CMAKE="cmake"
if [[ -d /mingw64/bin ]]; then
export PATH="/mingw64/bin:${PATH}"
echo "build_emitters: no portable toolchain; using MSYS2 /mingw64"
fi
fi
"${CMAKE}" "${CMAKE_ARGS[@]}"
"${CMAKE}" --build "${BUILD_DIR}" --target psxrecomp-game psxrecomp-bios -j"${JOBS}"
for bin in psxrecomp-bios psxrecomp-game; do
if [[ -f "${BUILD_DIR}/${bin}.exe" ]]; then
chmod +x "${BUILD_DIR}/${bin}.exe"
elif [[ -f "${BUILD_DIR}/${bin}" ]]; then
chmod +x "${BUILD_DIR}/${bin}"
else
echo "error: ${bin} missing after recompiler build" >&2
exit 1
fi
done
echo "emitters ready under ${BUILD_DIR}"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Clear generated game/BIOS C so CI can build a BIOS-free setup host.
#
# Usage (from game repo root):
# psxrecomp/tools/ci/clear_generated.sh [--root DIR]
set -euo pipefail
ROOT="${PWD}"
while [[ $# -gt 0 ]]; do
case "$1" in
--root) ROOT="${2:?}"; shift 2 ;;
-h|--help)
sed -n '2,7p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
echo "error: unknown arg: $1" >&2
exit 2
;;
esac
done
ROOT="$(cd "${ROOT}" && pwd)"
rm -rf "${ROOT}/generated" "${ROOT}/psxrecomp/generated"
echo "cleared generated/ under ${ROOT} (setup-host mode)"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Normalize a release version string to X.Y.Z (strip leading v / whitespace).
#
# Usage:
# normalize_version.sh <raw> # prints VERSION=… TAG=…
# normalize_version.sh --write VERSION <raw> # also writes VERSION file
#
# When GITHUB_OUTPUT is set, appends version= and tag=.
set -euo pipefail
WRITE=""
RAW=""
while [[ $# -gt 0 ]]; do
case "$1" in
--write) WRITE="${2:?}"; shift 2 ;;
-h|--help)
sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
RAW="$1"
shift
;;
esac
done
if [[ -z "${RAW}" ]]; then
echo "usage: $0 [--write VERSION] <raw-version>" >&2
exit 2
fi
RAW="$(printf '%s' "${RAW}" | tr -d '[:space:]')"
VER="${RAW#v}"
if [[ ! "${VER}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.+-][A-Za-z0-9.+-]*)?$ ]]; then
echo "error: invalid version '${RAW}' (want X.Y.Z or vX.Y.Z)" >&2
exit 1
fi
TAG="v${VER}"
echo "VERSION=${VER}"
echo "TAG=${TAG}"
if [[ -n "${WRITE}" ]]; then
printf '%s\n' "${VER}" >"${WRITE}"
fi
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "version=${VER}" >>"${GITHUB_OUTPUT}"
echo "tag=${TAG}" >>"${GITHUB_OUTPUT}"
fi
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Print pinned submodule revisions for CI logs.
#
# Usage (from game repo root):
# psxrecomp/tools/ci/record_pins.sh [--root DIR]
set -euo pipefail
ROOT="${PWD}"
while [[ $# -gt 0 ]]; do
case "$1" in
--root) ROOT="${2:?}"; shift 2 ;;
-h|--help)
sed -n '2,7p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
echo "error: unknown arg: $1" >&2
exit 2
;;
esac
done
ROOT="$(cd "${ROOT}" && pwd)"
pin() {
local name="$1" path="$2"
if [[ -d "${ROOT}/${path}/.git" ]] || [[ -f "${ROOT}/${path}/.git" ]]; then
echo "${name}=$(git -C "${ROOT}/${path}" rev-parse --short HEAD) ($(git -C "${ROOT}/${path}" rev-parse HEAD))"
elif [[ -d "${ROOT}/${path}" ]]; then
echo "${name}=<present, not a git checkout>"
else
echo "${name}=<missing>"
fi
}
pin psxrecomp psxrecomp
pin recomp-ui recomp-ui
pin recomp-net psxrecomp/lib/recomp-net
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Download and unpack a retcomm-toolchains cmake-clang-v1 pack.
#
# Usage:
# fetch_toolchain.sh --artifact <linux-x64|windows-x64|macos-arm64|macos-x64> \
# [--dl-dir DIR] [--out-dir DIR] [--repo OWNER/NAME]
#
# Prints: TOOLCHAIN_DIR=<absolute path with bin/>
# When GITHUB_ENV is set (Actions), also appends:
# TOOLCHAIN_DIR=...
# PSXRECOMP_TOOLCHAIN_DIR=...
# BPE_TOOLCHAIN_DIR=... (compat alias for existing title workflows)
#
# Env:
# GH_TOKEN / GITHUB_TOKEN — optional; used for gh or Authorization on curl
set -euo pipefail
ARTIFACT=""
DL_DIR=".cache/toolchain-dl"
OUT_DIR=".cache/toolchain-pack"
REPO="TechnicallyComputers/retcomm-toolchains"
usage() {
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--artifact) ARTIFACT="${2:?}"; shift 2 ;;
--dl-dir) DL_DIR="${2:?}"; shift 2 ;;
--out-dir) OUT_DIR="${2:?}"; shift 2 ;;
--repo) REPO="${2:?}"; shift 2 ;;
*)
echo "error: unknown arg: $1" >&2
usage
;;
esac
done
if [[ -z "${ARTIFACT}" ]]; then
echo "error: --artifact is required" >&2
usage
fi
case "${ARTIFACT}" in
linux-x64)
PATTERN='cmake-clang-v1*linux*.zip'
ASSET_NAME='cmake-clang-v1-linux-x64.zip'
;;
windows-x64)
PATTERN='cmake-clang-v1*windows*.zip'
ASSET_NAME='cmake-clang-v1-windows-x64.zip'
;;
macos-arm64|macos-x64)
PATTERN='cmake-clang-v1*macos*.zip'
ASSET_NAME='cmake-clang-v1-macos-universal.zip'
;;
*)
echo "error: unknown artifact '${ARTIFACT}' (want linux-x64|windows-x64|macos-arm64|macos-x64)" >&2
exit 1
;;
esac
TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
rm -rf "${DL_DIR}" "${OUT_DIR}"
mkdir -p "${DL_DIR}" "${OUT_DIR}"
if command -v gh >/dev/null 2>&1; then
if [[ -n "${TOKEN}" ]]; then
export GH_TOKEN="${TOKEN}"
fi
gh release download -R "${REPO}" -p "${PATTERN}" -D "${DL_DIR}" --clobber
elif command -v curl >/dev/null 2>&1; then
ASSET_URL="https://github.com/${REPO}/releases/latest/download/${ASSET_NAME}"
echo "gh not on PATH; curling ${ASSET_URL}"
curl_args=(-fsSL -o "${DL_DIR}/${ASSET_NAME}")
if [[ -n "${TOKEN}" ]]; then
curl_args+=(-H "Authorization: Bearer ${TOKEN}")
fi
curl "${curl_args[@]}" "${ASSET_URL}"
else
echo "error: neither gh nor curl available to fetch toolchain pack" >&2
exit 1
fi
ZIP="$(find "${DL_DIR}" -maxdepth 1 -type f -name '*.zip' | head -n1)"
if [[ -z "${ZIP}" ]]; then
echo "error: no toolchain zip matching ${PATTERN}" >&2
ls -la "${DL_DIR}" >&2 || true
exit 1
fi
echo "Unpacking toolchain from ${ZIP}"
if command -v unzip >/dev/null 2>&1; then
unzip -q "${ZIP}" -d "${OUT_DIR}"
else
python3 -c '
import pathlib, zipfile, sys
z = next(pathlib.Path(sys.argv[1]).glob("*.zip"))
zipfile.ZipFile(z).extractall(sys.argv[2])
' "${DL_DIR}" "${OUT_DIR}"
fi
# Unwrap single top-level directory if present.
shopt -s nullglob
entries=("${OUT_DIR}"/*)
shopt -u nullglob
if [[ ${#entries[@]} -eq 1 && -d "${entries[0]}" ]]; then
TOOLCHAIN_DIR="$(cd "${entries[0]}" && pwd)"
else
TOOLCHAIN_DIR="$(cd "${OUT_DIR}" && pwd)"
fi
if [[ ! -d "${TOOLCHAIN_DIR}/bin" ]]; then
echo "error: toolchain pack missing bin/ under ${TOOLCHAIN_DIR}" >&2
find "${OUT_DIR}" -maxdepth 3 >&2 || true
exit 1
fi
echo "TOOLCHAIN_DIR=${TOOLCHAIN_DIR}"
if [[ -n "${GITHUB_ENV:-}" ]]; then
{
echo "TOOLCHAIN_DIR=${TOOLCHAIN_DIR}"
echo "PSXRECOMP_TOOLCHAIN_DIR=${TOOLCHAIN_DIR}"
echo "BPE_TOOLCHAIN_DIR=${TOOLCHAIN_DIR}"
} >>"${GITHUB_ENV}"
fi
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "toolchain-dir=${TOOLCHAIN_DIR}" >>"${GITHUB_OUTPUT}"
fi
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# Universal setup-host zip packager for PSXRecomp game repos.
#
# Stages the host exe, title sources, filtered psxrecomp/ + recomp-ui/, then
# finishes with stage_setup_sdk.sh (emitters, OpenBIOS, MinGW DLLs).
# Portable cmake/clang is NOT embedded by default — RetComM / the setup wizard
# download cmake-clang-v1 from retcomm-toolchains (or accept an offline zip).
#
# Usage (from game repo root):
# psxrecomp/tools/package_setup_host.sh \
# --build-dir build-ci \
# --artifact linux-x64 \
# --zip-prefix bpe \
# --exe-name Bomberman_Party_Edition_Recompiled \
# --display-name "Bomberman Party Edition Recompiled" \
# --recompiler-build build-recompiler \
# [--project-file REL]... [--project-dir REL]... \
# [--disc-hint "your legally owned disc"] \
# [--version-env BPE_RELEASE_VERSION] \
# [--embed-toolchain] # optional: copy PSXRECOMP_TOOLCHAIN_DIR into zip
#
# Env:
# RELEASE_VERSION / <version-env> / VERSION file
# PSXRECOMP_TOOLCHAIN_DIR | TOOLCHAIN_DIR | BPE_TOOLCHAIN_DIR (only with --embed-toolchain)
# PSXRECOMP_EMBED_TOOLCHAIN=1 same as --embed-toolchain
# PSXRECOMP_RUNTIME_BIN_DIR | BPE_RUNTIME_BIN_DIR (Windows MinGW DLL search)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(pwd)"
BUILD_DIR=""
ARTIFACT=""
ZIP_PREFIX=""
EXE_NAME=""
DISPLAY_NAME=""
RECOMPILER_BUILD="build-recompiler"
VERSION_ENV="RELEASE_VERSION"
DISC_HINT="your legally owned game disc"
PROJECT_FILES=()
PROJECT_DIRS=()
RUNTIME_BIN_DIR="${PSXRECOMP_RUNTIME_BIN_DIR:-${BPE_RUNTIME_BIN_DIR:-/usr/x86_64-w64-mingw32/bin}}"
EMBED_TOOLCHAIN=0
if [[ "${PSXRECOMP_EMBED_TOOLCHAIN:-0}" == "1" ]]; then
EMBED_TOOLCHAIN=1
fi
usage() {
sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--build-dir) BUILD_DIR="${2:?}"; shift 2 ;;
--artifact) ARTIFACT="${2:?}"; shift 2 ;;
--zip-prefix) ZIP_PREFIX="${2:?}"; shift 2 ;;
--exe-name) EXE_NAME="${2:?}"; shift 2 ;;
--display-name) DISPLAY_NAME="${2:?}"; shift 2 ;;
--recompiler-build) RECOMPILER_BUILD="${2:?}"; shift 2 ;;
--version-env) VERSION_ENV="${2:?}"; shift 2 ;;
--disc-hint) DISC_HINT="${2:?}"; shift 2 ;;
--project-file) PROJECT_FILES+=("${2:?}"); shift 2 ;;
--project-dir) PROJECT_DIRS+=("${2:?}"); shift 2 ;;
--runtime-bin) RUNTIME_BIN_DIR="${2:?}"; shift 2 ;;
--root) ROOT="${2:?}"; shift 2 ;;
--embed-toolchain) EMBED_TOOLCHAIN=1; shift ;;
--no-embed-toolchain) EMBED_TOOLCHAIN=0; shift ;;
*)
echo "error: unknown arg: $1" >&2
usage
;;
esac
done
if [[ -z "${BUILD_DIR}" || -z "${ARTIFACT}" || -z "${ZIP_PREFIX}" || -z "${EXE_NAME}" ]]; then
echo "error: --build-dir, --artifact, --zip-prefix, and --exe-name are required" >&2
usage
fi
if [[ -z "${DISPLAY_NAME}" ]]; then
DISPLAY_NAME="${EXE_NAME}"
fi
ROOT="$(cd "${ROOT}" && pwd)"
if [[ -d "${ROOT}/${BUILD_DIR}" ]]; then
BUILD_DIR="$(cd "${ROOT}/${BUILD_DIR}" && pwd)"
elif [[ -d "${BUILD_DIR}" ]]; then
BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)"
else
echo "error: build dir not found: ${BUILD_DIR}" >&2
exit 1
fi
# Defaults for a typical title if caller passed none.
# Codegen host sources live in psxrecomp/host/ (copied with the framework tree).
if [[ ${#PROJECT_FILES[@]} -eq 0 && ${#PROJECT_DIRS[@]} -eq 0 ]]; then
PROJECT_FILES=(CMakeLists.txt game.toml VERSION)
for d in seeds launcher_assets; do
if [[ -e "${ROOT}/${d}" ]]; then
PROJECT_DIRS+=("${d}")
fi
done
for f in codegen_setup.c codegen_setup.h DISC.md README.md; do
if [[ -e "${ROOT}/${f}" ]]; then
PROJECT_FILES+=("${f}")
fi
done
fi
# Resolve version.
VERSION=""
if [[ -n "${VERSION_ENV}" ]]; then
VERSION="${!VERSION_ENV:-}"
fi
if [[ -z "${VERSION}" && -n "${RELEASE_VERSION:-}" ]]; then
VERSION="${RELEASE_VERSION}"
fi
if [[ -z "${VERSION}" && -n "${BPE_RELEASE_VERSION:-}" ]]; then
VERSION="${BPE_RELEASE_VERSION}"
fi
if [[ -z "${VERSION}" && -f "${ROOT}/VERSION" ]]; then
VERSION="$(tr -d '[:space:]' <"${ROOT}/VERSION")"
fi
VERSION="$(printf '%s' "${VERSION}" | tr -d '[:space:]')"
VERSION="${VERSION#v}"
if [[ -z "${VERSION}" ]]; then
echo "error: VERSION empty (set ${VERSION_ENV} / RELEASE_VERSION or write VERSION)" >&2
exit 1
fi
printf '%s\n' "${VERSION}" >"${ROOT}/VERSION"
DIST="${ROOT}/dist"
STAGE="${DIST}/stage-setup-${ARTIFACT}"
ZIP_NAME="${ZIP_PREFIX}-${VERSION}-${ARTIFACT}.zip"
rm -rf "${STAGE}"
mkdir -p "${STAGE}" "${DIST}"
rm -f "${DIST}/${ZIP_NAME}"
EXE=""
for cand in \
"${BUILD_DIR}/${EXE_NAME}" \
"${BUILD_DIR}/${EXE_NAME}.exe" \
"${BUILD_DIR}/Release/${EXE_NAME}.exe"
do
if [[ -f "${cand}" ]]; then
EXE="${cand}"
break
fi
done
if [[ -z "${EXE}" ]]; then
echo "error: setup host executable '${EXE_NAME}' not found under ${BUILD_DIR}" >&2
ls -la "${BUILD_DIR}" >&2 || true
exit 1
fi
cp -a "${EXE}" "${STAGE}/"
EXE_BASENAME="$(basename "${EXE}")"
EXE_DIR="$(dirname "${EXE}")"
if [[ ! -d "${EXE_DIR}/assets/fonts" || ! -d "${EXE_DIR}/assets/img" ]]; then
echo "error: ${EXE_DIR}/assets/{fonts,img} missing — rebuild psx-runtime" >&2
exit 1
fi
mkdir -p "${STAGE}/assets"
cp -a "${EXE_DIR}/assets/fonts" "${STAGE}/assets/"
cp -a "${EXE_DIR}/assets/img" "${STAGE}/assets/"
if [[ ! -f "${STAGE}/assets/img/boxart.tga" && -f "${ROOT}/launcher_assets/img/boxart.tga" ]]; then
cp -a "${ROOT}/launcher_assets/img/boxart.tga" "${STAGE}/assets/img/boxart.tga"
fi
copy_proj() {
local rel="$1"
if [[ -e "${ROOT}/${rel}" ]]; then
mkdir -p "$(dirname "${STAGE}/${rel}")"
cp -a "${ROOT}/${rel}" "${STAGE}/${rel}"
else
echo "error: missing ${rel}" >&2
exit 1
fi
}
for f in "${PROJECT_FILES[@]}"; do
copy_proj "${f}"
done
for d in "${PROJECT_DIRS[@]}"; do
copy_proj "${d}"
done
copy_tree_filtered() {
local src="$1" dest="$2"
shift 2
mkdir -p "${dest}"
if command -v rsync >/dev/null 2>&1; then
rsync -a "$@" "${src}/" "${dest}/"
else
cp -a "${src}/." "${dest}/"
rm -rf "${dest}/.git" "${dest}/recompiler/build" "${dest}/generated" 2>/dev/null || true
find "${dest}" -type d -name '__pycache__' -prune -exec rm -rf {} + 2>/dev/null || true
find "${dest}" -type d \( -name 'build' -o -name 'build-*' \) -prune -exec rm -rf {} + 2>/dev/null || true
fi
}
if [[ ! -d "${ROOT}/psxrecomp" ]]; then
echo "error: ${ROOT}/psxrecomp missing (expected framework submodule)" >&2
exit 1
fi
if [[ ! -d "${ROOT}/recomp-ui" ]]; then
echo "error: ${ROOT}/recomp-ui missing (expected UI submodule)" >&2
exit 1
fi
copy_tree_filtered "${ROOT}/psxrecomp" "${STAGE}/psxrecomp" \
--exclude '.git' \
--exclude 'recompiler/build' \
--exclude 'generated' \
--exclude '__pycache__' \
--exclude 'build' \
--exclude 'build-*'
copy_tree_filtered "${ROOT}/recomp-ui" "${STAGE}/recomp-ui" \
--exclude '.git' \
--exclude 'build' \
--exclude '__pycache__'
# Never ship game generated C or common disc working trees.
rm -rf "${STAGE}/generated" "${STAGE}/bpe" "${STAGE}/motk" "${STAGE}/disc"
STAGE_SDK="${SCRIPT_DIR}/stage_setup_sdk.sh"
if [[ ! -f "${STAGE_SDK}" ]]; then
echo "error: missing ${STAGE_SDK}" >&2
exit 1
fi
chmod +x "${STAGE_SDK}" 2>/dev/null || true
stage_args=(
--stage "${STAGE}"
--framework "${ROOT}/psxrecomp"
--search-dir "${EXE_DIR}"
--search-dir "${BUILD_DIR}"
--runtime-bin "${RUNTIME_BIN_DIR}"
--host-exe "${STAGE}/${EXE_BASENAME}"
--recompiler-build "${RECOMPILER_BUILD}"
)
if [[ "${EMBED_TOOLCHAIN}" -eq 1 ]]; then
if [[ -z "${PSXRECOMP_TOOLCHAIN_DIR:-${TOOLCHAIN_DIR:-${BPE_TOOLCHAIN_DIR:-}}}" ]]; then
echo "error: --embed-toolchain requires PSXRECOMP_TOOLCHAIN_DIR (or TOOLCHAIN_DIR)" >&2
exit 1
fi
stage_args+=(--toolchain-dir "${PSXRECOMP_TOOLCHAIN_DIR:-${TOOLCHAIN_DIR:-${BPE_TOOLCHAIN_DIR}}}")
else
stage_args+=(--allow-no-toolchain)
fi
bash "${STAGE_SDK}" "${stage_args[@]}"
cat >"${STAGE}/README-SETUP.txt" <<EOF
${DISPLAY_NAME} ${VERSION} — setup package
Platform: ${ARTIFACT}
One zip for first install and updates. Does NOT include disc images, retail
BIOS dumps, pre-generated game C, or a portable cmake/clang pack. Emitters
(psxrecomp-game / psxrecomp-bios) and the CLI are inside psxrecomp/.
Standalone:
1. Install Python 3.
2. Run ${EXE_BASENAME}.
3. Provide ${DISC_HINT} (and optional retail SCPH-1001 BIOS; otherwise
OpenBIOS is regenerated locally).
4. Follow the Generate & rebuild wizard. On first rebuild the host downloads
cmake-clang-v1 from TechnicallyComputers/retcomm-toolchains (or you can
pick a local cmake-clang-v1-*.zip for offline builds). System cmake/ninja
also works if already on PATH.
RetComM uses this same zip: it harvests emitters into a shared SDK cache,
downloads the toolchain pack (or uses RETCOMM_TOOLCHAIN_DIR), and preserves
saves/user config across updates.
EOF
find "${STAGE}" -exec touch -c {} + 2>/dev/null || find "${STAGE}" -exec touch {} +
(
cd "${STAGE}"
if command -v zip >/dev/null 2>&1; then
zip -r -q "${DIST}/${ZIP_NAME}" .
else
echo "error: zip not found" >&2
exit 1
fi
)
echo "Wrote ${DIST}/${ZIP_NAME}"
du -h "${DIST}/${ZIP_NAME}"
+587
View File
@@ -0,0 +1,587 @@
#!/usr/bin/env python3
"""Normalize a PS1 disc dump to MODE2/2352 .bin/.cue for psxrecomp games.
Framework tool — game identity comes from ``game.toml`` (``[game]`` + optional
``[prepare_disc]``) or CLI flags.
Accepted inputs:
* Redump / raw MODE2/2352 ``.bin`` (+ ``.cue``)
* Cooked 2048-byte/sector ``.iso`` (RomM-style libraries)
* 2448-byte/sector raw dumps (MotK-style): trim to 2352
Writes working ``.bin`` + ``.cue``, extracts ``SYSTEM.CNF`` + boot EXE, and
prints ``RESULT_CUE=<abs path>`` for the first-run wizard / RetComM.
ISO→2352 sets Mode2 Form1 sync/header/subheader/EDC (ECC zeroed — fine for
software readers). Rebuilt images are not bit-identical to Redump.
"""
from __future__ import annotations
import argparse
import hashlib
import re
import struct
import sys
from dataclasses import dataclass, field
from pathlib import Path
DST_SEC = 2352
SRC_2448 = 2448
USER = 2048
USER_OFF = 24
SYNC = bytes([0x00] + [0xFF] * 10 + [0x00])
def _configure_stdio() -> None:
"""Avoid UnicodeEncodeError on Windows consoles (cp1252 / Store Python)."""
for stream in (sys.stdout, sys.stderr):
reconf = getattr(stream, "reconfigure", None)
if callable(reconf):
try:
reconf(errors="replace")
except Exception: # noqa: BLE001 — best-effort stdio harden
pass
def _build_edc_table() -> list[int]:
table = []
for i in range(256):
x = i
for _ in range(8):
x = (x >> 1) ^ 0xD8018001 if (x & 1) else (x >> 1)
table.append(x & 0xFFFFFFFF)
return table
_EDC_TABLE = _build_edc_table()
def _adjust_edc(buf: bytearray, start: int, length: int) -> None:
x = 0
end = start + length
for i in range(start, end):
x = _EDC_TABLE[(x ^ buf[i]) & 0xFF] ^ (x >> 8)
struct.pack_into("<I", buf, end, x & 0xFFFFFFFF)
def encode_mode2_form1(user: bytes, lba: int, submode: int = 0x08) -> bytes:
if len(user) != USER:
raise ValueError("user data must be 2048 bytes")
sector = bytearray(DST_SEC)
sector[0:12] = SYNC
t = lba + 150
mm, ss, ff = t // (75 * 60), (t // 75) % 60, t % 75
sector[12] = ((mm // 10) << 4) | (mm % 10)
sector[13] = ((ss // 10) << 4) | (ss % 10)
sector[14] = ((ff // 10) << 4) | (ff % 10)
sector[15] = 0x02
sub = bytes([0x00, 0x00, submode & 0xFF, 0x00])
sector[16:20] = sub
sector[20:24] = sub
sector[24 : 24 + USER] = user
_adjust_edc(sector, 0x10, 0x800 + 8)
return bytes(sector)
@dataclass
class KnownImage:
size: int = 0
md5: str = ""
sha1: str = ""
@dataclass
class PrepareConfig:
project_root: Path
out_dir: Path
bin_name: str
cue_name: str
boot_exe: str
serial: str = ""
known: list[KnownImage] = field(default_factory=list)
skip_hash_check: bool = False
def file_hashes(path: Path) -> tuple[str, str, int]:
h_md5 = hashlib.md5()
h_sha1 = hashlib.sha1()
size = 0
with open(path, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
h_md5.update(chunk)
h_sha1.update(chunk)
return h_md5.hexdigest(), h_sha1.hexdigest(), size
def _parse_array_items(inner: str) -> list[object]:
items: list[object] = []
if not inner.strip():
return items
for part in re.split(r",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", inner):
part = part.strip().rstrip(",").strip()
if not part:
continue
if part.startswith('"') and part.endswith('"'):
items.append(part[1:-1])
else:
try:
items.append(int(part, 0))
except ValueError:
items.append(part)
return items
def _parse_toml_simple(text: str) -> dict[str, dict[str, str]]:
"""Minimal TOML reader for flat string/int/bool + string/int arrays.
Supports multi-line arrays (``key = [ … ]`` spanning lines).
"""
sections: dict[str, dict[str, object]] = {"": {}}
cur = ""
array_key: str | None = None
array_buf: list[str] = []
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if array_key is not None:
array_buf.append(line)
joined = " ".join(array_buf)
if "]" in joined:
inner = joined.split("]", 1)[0]
# Drop leading "[" already consumed when array opened
if inner.startswith("["):
inner = inner[1:]
sections[cur][array_key] = _parse_array_items(inner)
array_key = None
array_buf = []
continue
if not line:
continue
if line.startswith("[") and line.endswith("]"):
cur = line[1:-1].strip()
sections.setdefault(cur, {})
continue
if "=" not in line:
continue
key, val = line.split("=", 1)
key = key.strip()
val = val.strip()
if val.startswith("["):
if val.endswith("]") and val.count("[") == val.count("]"):
sections[cur][key] = _parse_array_items(val[1:-1])
else:
array_key = key
array_buf = [val[1:]] # after opening [
continue
if val.startswith('"') and val.endswith('"'):
sections[cur][key] = val[1:-1]
elif val.lower() in ("true", "false"):
sections[cur][key] = val.lower() == "true"
else:
try:
sections[cur][key] = int(val, 0)
except ValueError:
sections[cur][key] = val
return sections # type: ignore[return-value]
def load_config(project_root: Path, config_path: Path | None, out_dir_cli: str | None,
skip_hash: bool) -> PrepareConfig:
project_root = project_root.resolve()
game: dict = {}
prep: dict = {}
if config_path and config_path.is_file():
sections = _parse_toml_simple(config_path.read_text(encoding="utf-8"))
game = sections.get("game", {}) or {}
prep = sections.get("prepare_disc", {}) or {}
boot = str(prep.get("boot_exe") or "")
if not boot:
exe = str(game.get("exe") or "")
boot = Path(exe).name if exe else ""
if not boot:
boot = "SLUS_000.00"
out_rel = str(prep.get("out_dir") or "prepared_disc")
out_dir = Path(out_dir_cli).expanduser() if out_dir_cli else (project_root / out_rel)
disc = str(game.get("disc") or prep.get("bin_name") or "game.bin")
bin_name = str(prep.get("bin_name") or Path(disc).name)
cue_name = str(prep.get("cue_name") or (Path(bin_name).stem + ".cue"))
known: list[KnownImage] = []
sizes = prep.get("known_sizes") or []
md5s = prep.get("known_md5") or []
sha1s = prep.get("known_sha1") or []
if isinstance(sizes, list) or isinstance(md5s, list) or isinstance(sha1s, list):
n = max(
len(sizes) if isinstance(sizes, list) else 0,
len(md5s) if isinstance(md5s, list) else 0,
len(sha1s) if isinstance(sha1s, list) else 0,
)
for i in range(n):
known.append(
KnownImage(
size=int(sizes[i]) if isinstance(sizes, list) and i < len(sizes) else 0,
md5=str(md5s[i]).lower()
if isinstance(md5s, list) and i < len(md5s)
else "",
sha1=str(sha1s[i]).lower()
if isinstance(sha1s, list) and i < len(sha1s)
else "",
)
)
return PrepareConfig(
project_root=project_root,
out_dir=out_dir.resolve(),
bin_name=bin_name,
cue_name=cue_name,
boot_exe=boot,
serial=str(game.get("id") or ""),
known=known,
skip_hash_check=skip_hash,
)
def resolve_cue_bin(cue_path: Path) -> Path:
text = cue_path.read_text(encoding="utf-8", errors="replace")
m = re.search(r'FILE\s+"([^"]+)"\s+BINARY', text, re.I)
if not m:
m = re.search(r"FILE\s+(\S+)\s+BINARY", text, re.I)
if not m:
raise SystemExit(f"no BINARY FILE in cue: {cue_path}")
cand = Path(m.group(1))
if not cand.is_absolute():
cand = cue_path.parent / cand
if not cand.is_file():
raise SystemExit(f"cue references missing bin: {cand}")
return cand
def detect_kind(path: Path, size: int) -> str:
suf = path.suffix.lower()
if suf == ".cue":
return "cue"
if suf == ".iso":
return "iso"
# MotK-style 2448 before generic 2352 (some sizes divisible by both).
if size % SRC_2448 == 0 and size > SRC_2448 and size % DST_SEC != 0:
return "raw2448"
if size % SRC_2448 == 0 and size > SRC_2448:
# Peek: if sync looks like CD at +0, treat as 2448 when user data path works.
with open(path, "rb") as f:
head = f.read(16)
if head[:12] == SYNC:
# Could be 2352 or 2448; check sector stride via second sync.
fpos_2352_ok = False
fpos_2448_ok = False
with open(path, "rb") as f:
f.seek(DST_SEC)
fpos_2352_ok = f.read(12) == SYNC
f.seek(SRC_2448)
fpos_2448_ok = f.read(12) == SYNC
if fpos_2448_ok and not fpos_2352_ok:
return "raw2448"
if size % DST_SEC == 0 and size > DST_SEC:
with open(path, "rb") as f:
sec0 = f.read(12)
f.seek(16 * DST_SEC)
sync16 = f.read(12)
if sec0 == SYNC or sync16 == SYNC:
return "bin2352"
if size % USER == 0 and size > USER:
return "iso"
if size % SRC_2448 == 0 and size > SRC_2448:
return "raw2448"
raise SystemExit(
f"unrecognized disc image {path} (size={size}). "
"Need MODE2/2352 .bin/.cue, cooked 2048 .iso, or 2448-byte/sector dump."
)
def read_user_bin(data: bytes, lba: int) -> bytes:
off = lba * DST_SEC
if off + DST_SEC > len(data):
raise KeyError(lba)
if data[off : off + 12] != SYNC:
raise SystemExit(f"bad sync at LBA {lba}")
return data[off + USER_OFF : off + USER_OFF + USER]
def read_user_iso(data: bytes, lba: int) -> bytes:
off = lba * USER
if off + USER > len(data):
raise KeyError(lba)
return data[off : off + USER]
def parse_root_entries(root: bytes) -> dict[str, tuple[int, int]]:
entries: dict[str, tuple[int, int]] = {}
i = 0
while i < len(root):
reclen = root[i]
if reclen == 0:
i = ((i // USER) + 1) * USER
if i >= len(root):
break
continue
if i + reclen > len(root):
break
extent = struct.unpack_from("<I", root, i + 2)[0]
size = struct.unpack_from("<I", root, i + 10)[0]
namelen = root[i + 32]
name = root[i + 33 : i + 33 + namelen]
if b";" in name:
name = name.split(b";")[0]
if name not in (b"\x00", b"\x01"):
entries[name.decode("ascii", "replace")] = (extent, size)
i += reclen
return entries
def extract_via(
read_user, data: bytes, boot_exe: str
) -> tuple[dict[str, tuple[int, int]], dict[str, bytes]]:
pvd = read_user(data, 16)
if pvd[1:6] != b"CD001":
raise SystemExit("PVD not found at LBA 16 (expected CD001)")
root_extent = struct.unpack_from("<I", pvd, 158)[0]
root_size = struct.unpack_from("<I", pvd, 166)[0]
root = bytearray()
for i in range((root_size + USER - 1) // USER):
root += read_user(data, root_extent + i)
root = bytes(root[:root_size])
entries = parse_root_entries(root)
files: dict[str, bytes] = {}
for need in ("SYSTEM.CNF", boot_exe):
if need not in entries:
raise SystemExit(f"missing {need} on disc (found {sorted(entries)[:20]})")
extent, size = entries[need]
out = bytearray()
rem, lba = size, extent
while rem > 0:
sector = read_user(data, lba)
take = min(USER, rem)
out += sector[:take]
rem -= take
lba += 1
files[need] = bytes(out)
if files[boot_exe][:8] != b"PS-X EXE":
raise SystemExit(f"{boot_exe} is not a PS-X EXE")
return entries, files
def iso_to_bin(iso_data: bytes) -> bytes:
if len(iso_data) % USER != 0:
raise SystemExit(f"ISO size {len(iso_data)} is not a multiple of 2048")
n = len(iso_data) // USER
out = bytearray()
for lba in range(n):
user = iso_data[lba * USER : (lba + 1) * USER]
out += encode_mode2_form1(user, lba, submode=0x08)
return bytes(out)
def raw2448_to_bin(src: Path) -> bytes:
out = bytearray()
n = 0
with open(src, "rb") as inf:
while True:
sec = inf.read(SRC_2448)
if not sec:
break
if len(sec) != SRC_2448:
raise SystemExit(f"truncated 2448 sector {n}: got {len(sec)} bytes")
out += sec[:DST_SEC]
n += 1
print(f" trimmed {n} sectors 2448 -> 2352")
return bytes(out)
def matches_known(cfg: PrepareConfig, size: int, md5: str, sha1: str) -> bool:
if not cfg.known:
return False
md5 = md5.lower()
sha1 = sha1.lower()
for k in cfg.known:
if k.md5 and k.md5.lower() == md5:
if not k.size or k.size == size:
return True
if k.sha1 and k.sha1.lower() == sha1:
if not k.size or k.size == size:
return True
return False
def main() -> int:
_configure_stdio()
here = Path(__file__).resolve().parent
# tools/ -> psxrecomp/ -> often game root is parent of psxrecomp
fw_root = here.parent
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("source", nargs="?", default="", help="path to .iso / .cue / .bin")
ap.add_argument(
"--config",
default="",
help="game.toml (reads [game] + [prepare_disc])",
)
ap.add_argument(
"--project-root",
default="",
help="game project root (default: parent of config, else cwd)",
)
ap.add_argument("--out-dir", default="", help="override prepare_disc.out_dir")
ap.add_argument("--bin-name", default="", help="override output .bin basename")
ap.add_argument("--cue-name", default="", help="override output .cue basename")
ap.add_argument("--boot-exe", default="", help="override boot EXE name on disc")
ap.add_argument(
"--skip-hash-check",
action="store_true",
help="do not require prepare_disc.known_* digests",
)
args = ap.parse_args()
config_path = Path(args.config).expanduser() if args.config else None
if config_path and not config_path.is_file():
print(f"config not found: {config_path}", file=sys.stderr)
return 1
if args.project_root:
project_root = Path(args.project_root).expanduser().resolve()
elif config_path:
project_root = config_path.parent.resolve()
else:
# Invoked from a game tree: prefer cwd if it has game.toml
cwd = Path.cwd()
project_root = cwd if (cwd / "game.toml").is_file() else fw_root.parent
if not config_path:
cand = project_root / "game.toml"
if cand.is_file():
config_path = cand
cfg = load_config(project_root, config_path, args.out_dir or None, args.skip_hash_check)
if args.bin_name:
cfg.bin_name = args.bin_name
if args.cue_name:
cfg.cue_name = args.cue_name
if args.boot_exe:
cfg.boot_exe = args.boot_exe
if not args.source:
# Convenience: existing working bin under out_dir
cand = cfg.out_dir / cfg.bin_name
if cand.is_file():
args.source = str(cand)
else:
print("source required (.iso / .cue / .bin)", file=sys.stderr)
return 1
src = Path(args.source).expanduser().resolve()
if not src.is_file():
print(f"source not found: {src}", file=sys.stderr)
return 1
cfg.out_dir.mkdir(parents=True, exist_ok=True)
src_md5, src_sha1, src_size = file_hashes(src)
print(f"source: {src}")
print(f" size {src_size}")
print(f" md5 {src_md5}")
print(f" sha1 {src_sha1}")
if cfg.serial:
print(f" game {cfg.serial} boot={cfg.boot_exe}")
kind = detect_kind(src, src_size)
if kind == "cue":
src = resolve_cue_bin(src)
src_md5, src_sha1, src_size = file_hashes(src)
print(f"cue -> bin: {src}")
print(f" size {src_size}")
print(f" md5 {src_md5}")
print(f" sha1 {src_sha1}")
kind = detect_kind(src, src_size)
known_hit = matches_known(cfg, src_size, src_md5, src_sha1)
if cfg.known and not cfg.skip_hash_check:
if known_hit:
print(" known digest OK")
elif kind in ("bin2352", "raw2448"):
print(
"source digests are not in prepare_disc.known_* "
"(pass --skip-hash-check to force)",
file=sys.stderr,
)
return 1
else:
print(
"ISO digests are not in prepare_disc.known_*; "
"verifying boot EXE only."
)
elif not cfg.known and not cfg.skip_hash_check and kind == "bin2352":
print(" no prepare_disc.known_* configured - verifying boot EXE only")
if kind == "bin2352":
bin_data = src.read_bytes()
entries, files = extract_via(read_user_bin, bin_data, cfg.boot_exe)
elif kind == "iso":
iso_data = src.read_bytes()
entries, files = extract_via(read_user_iso, iso_data, cfg.boot_exe)
print(f" converting {len(iso_data) // USER} sectors ISO -> MODE2/2352...")
bin_data = iso_to_bin(iso_data)
extract_via(read_user_bin, bin_data, cfg.boot_exe)
elif kind == "raw2448":
print(" converting 2448 -> 2352...")
bin_data = raw2448_to_bin(src)
entries, files = extract_via(read_user_bin, bin_data, cfg.boot_exe)
else:
print(f"unsupported kind {kind}", file=sys.stderr)
return 1
print(f" root entries: {sorted(entries)[:24]}")
pc0 = struct.unpack_from("<I", files[cfg.boot_exe], 0x10)[0]
print(f" {cfg.boot_exe} PC0={pc0:#010x} ({len(files[cfg.boot_exe])} bytes)")
for name, blob in files.items():
out_path = cfg.out_dir / name
out_path.write_bytes(blob)
print(f"wrote {out_path} ({len(blob)} bytes)")
bin_path = cfg.out_dir / cfg.bin_name
if src.resolve() == bin_path.resolve() and kind == "bin2352":
print(f"source already at {bin_path}")
else:
print(f"writing {bin_path}")
bin_path.write_bytes(bin_data)
cue_path = cfg.out_dir / cfg.cue_name
cue_path.write_text(
f'FILE "{cfg.bin_name}" BINARY\n'
f" TRACK 01 MODE2/2352\n"
f" INDEX 01 00:00:00\n",
encoding="ascii",
newline="\n",
)
print(f"wrote {cue_path}")
out_md5, out_sha1, out_size = file_hashes(bin_path)
print("working bin:")
print(f" size {out_size}")
print(f" md5 {out_md5}")
print(f" sha1 {out_sha1}")
if kind == "iso":
print(" rebuilt from ISO (EDC set; ECC zeroed - fine for software readers)")
elif kind == "raw2448":
print(" trimmed from 2448-byte/sector dump")
print(f"RESULT_CUE={cue_path.resolve()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env bash
# Stage psxrecomp emitters + OpenBIOS surface + optional portable toolchain
# into an existing setup-host release stage directory.
#
# Title packagers copy the host exe, game sources, and framework tree first,
# then call this to finish the RetComM/wizard-complete zip layout.
#
# Usage:
# stage_setup_sdk.sh --stage <stage-dir> [options]
#
# Options:
# --framework DIR psxrecomp source tree (default: <cwd>/psxrecomp)
# --sdk-overlay DIR Legacy optional overlay onto stage/psxrecomp
# (prefer shipping CLI/tools inside the submodule)
# --recompiler-build DIR Where psxrecomp-game/bios were built (repeatable)
# --toolchain-dir DIR Pack root with bin/; embedded as stage/toolchain/
# --allow-no-toolchain Warn instead of failing when toolchain unset
# --host-exe PATH Host PE for Windows DLL bundling (optional)
# --runtime-bin DIR MinGW runtime DLL search dir (repeatable)
# --search-dir DIR Extra DLL search dir (repeatable)
# --require-cli Require psxrecomp_cli.py after overlays (default on)
#
# Env aliases for toolchain dir (first wins):
# PSXRECOMP_TOOLCHAIN_DIR, TOOLCHAIN_DIR, BPE_TOOLCHAIN_DIR
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FW_TOOLS="${SCRIPT_DIR}"
STAGE=""
FRAMEWORK=""
SDK_OVERLAY=""
RECOMPILER_BUILDS=()
# Empty until --toolchain-dir or (when not allow-no) env aliases.
TOOLCHAIN_DIR=""
TOOLCHAIN_DIR_SET=0
ALLOW_NO_TOOLCHAIN=0
HOST_EXE=""
RUNTIME_BINS=()
SEARCH_DIRS=()
REQUIRE_CLI=1
usage() {
sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--stage) STAGE="${2:?}"; shift 2 ;;
--framework) FRAMEWORK="${2:?}"; shift 2 ;;
--sdk-overlay) SDK_OVERLAY="${2:?}"; shift 2 ;;
--recompiler-build) RECOMPILER_BUILDS+=("${2:?}"); shift 2 ;;
--toolchain-dir) TOOLCHAIN_DIR="${2:?}"; TOOLCHAIN_DIR_SET=1; shift 2 ;;
--allow-no-toolchain) ALLOW_NO_TOOLCHAIN=1; shift ;;
--host-exe) HOST_EXE="${2:?}"; shift 2 ;;
--runtime-bin) RUNTIME_BINS+=("${2:?}"); shift 2 ;;
--search-dir) SEARCH_DIRS+=("${2:?}"); shift 2 ;;
--require-cli) REQUIRE_CLI=1; shift ;;
--no-require-cli) REQUIRE_CLI=0; shift ;;
*)
echo "error: unknown arg: $1" >&2
usage
;;
esac
done
if [[ -z "${STAGE}" ]]; then
echo "error: --stage is required" >&2
usage
fi
STAGE="$(cd "${STAGE}" && pwd)"
# Env aliases only when embedding is expected (not --allow-no-toolchain alone).
if [[ "${TOOLCHAIN_DIR_SET}" -eq 0 && "${ALLOW_NO_TOOLCHAIN}" -eq 0 ]]; then
TOOLCHAIN_DIR="${PSXRECOMP_TOOLCHAIN_DIR:-${TOOLCHAIN_DIR:-${BPE_TOOLCHAIN_DIR:-}}}"
fi
if [[ -z "${FRAMEWORK}" ]]; then
if [[ -d "${PWD}/psxrecomp" ]]; then
FRAMEWORK="$(cd "${PWD}/psxrecomp" && pwd)"
else
FRAMEWORK="$(cd "${SCRIPT_DIR}/.." && pwd)"
fi
else
FRAMEWORK="$(cd "${FRAMEWORK}" && pwd)"
fi
mkdir -p "${STAGE}/psxrecomp"
# Optional SDK overlay (CLI / prepare_disc / progress helpers).
if [[ -n "${SDK_OVERLAY}" ]]; then
if [[ ! -d "${SDK_OVERLAY}" ]]; then
echo "error: --sdk-overlay not a directory: ${SDK_OVERLAY}" >&2
exit 1
fi
if command -v rsync >/dev/null 2>&1; then
rsync -a "${SDK_OVERLAY}/" "${STAGE}/psxrecomp/"
else
cp -a "${SDK_OVERLAY}/." "${STAGE}/psxrecomp/"
fi
echo "overlaid SDK from ${SDK_OVERLAY}"
fi
find_tool_bin() {
local name="$1"
local dir cand
local -a roots=()
local r
for r in "${RECOMPILER_BUILDS[@]+"${RECOMPILER_BUILDS[@]}"}"; do
roots+=("$(cd "${r}" && pwd)")
done
roots+=(
"${STAGE}/psxrecomp/recompiler/build"
"${FRAMEWORK}/recompiler/build"
"${PWD}/build-recompiler"
)
for dir in "${roots[@]}"; do
[[ -d "${dir}" ]] || continue
for cand in \
"${dir}/${name}" \
"${dir}/${name}.exe" \
"${dir}/Release/${name}.exe"
do
if [[ -f "${cand}" ]]; then
echo "${cand}"
return 0
fi
done
done
return 1
}
GAME_BIN="$(find_tool_bin psxrecomp-game || true)"
BIOS_BIN="$(find_tool_bin psxrecomp-bios || true)"
if [[ -z "${GAME_BIN}" ]]; then
echo "error: psxrecomp-game not found (pass --recompiler-build)" >&2
exit 1
fi
if [[ -z "${BIOS_BIN}" ]]; then
echo "error: psxrecomp-bios not found (required for OpenBIOS regen)" >&2
exit 1
fi
mkdir -p "${STAGE}/psxrecomp/recompiler/build"
cp -a "${GAME_BIN}" \
"${STAGE}/psxrecomp/recompiler/build/$(basename "${GAME_BIN}")"
cp -a "${BIOS_BIN}" \
"${STAGE}/psxrecomp/recompiler/build/$(basename "${BIOS_BIN}")"
chmod +x "${STAGE}/psxrecomp/recompiler/build/$(basename "${GAME_BIN}")" 2>/dev/null || true
chmod +x "${STAGE}/psxrecomp/recompiler/build/$(basename "${BIOS_BIN}")" 2>/dev/null || true
echo "staged emitters from ${GAME_BIN%/*}"
cat >"${STAGE}/psxrecomp/retcomm-sdk.json" <<'EOF'
{
"cli": "psxrecomp_cli.py",
"id": "psxrecomp-tools",
"game_bin": "recompiler/build/psxrecomp-game",
"bios_bin": "recompiler/build/psxrecomp-bios"
}
EOF
for f in OpenBIOS.toml openbios.bin OpenBIOS.LICENSE SCPH1001.toml; do
if [[ ! -f "${STAGE}/psxrecomp/bios/${f}" ]]; then
echo "error: missing psxrecomp/bios/${f} in staged tree" >&2
exit 1
fi
done
if [[ "${REQUIRE_CLI}" -eq 1 && ! -f "${STAGE}/psxrecomp/psxrecomp_cli.py" ]]; then
echo "error: missing psxrecomp/psxrecomp_cli.py in staged tree" >&2
exit 1
fi
# Never ship retail BIOS dumps from the stage.
rm -f "${STAGE}/psxrecomp/bios/SCPH1001.BIN" \
"${STAGE}/psxrecomp/bios/SCPH1001.bin" 2>/dev/null || true
if [[ -n "${TOOLCHAIN_DIR}" && -d "${TOOLCHAIN_DIR}" ]]; then
if [[ ! -d "${TOOLCHAIN_DIR}/bin" ]]; then
echo "error: toolchain dir missing bin/: ${TOOLCHAIN_DIR}" >&2
exit 1
fi
mkdir -p "${STAGE}/toolchain"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete "${TOOLCHAIN_DIR}/" "${STAGE}/toolchain/"
else
rm -rf "${STAGE}/toolchain"
mkdir -p "${STAGE}/toolchain"
cp -a "${TOOLCHAIN_DIR}/." "${STAGE}/toolchain/"
fi
echo "bundled toolchain from ${TOOLCHAIN_DIR}"
elif [[ "${ALLOW_NO_TOOLCHAIN}" -eq 1 ]]; then
echo "note: no embedded toolchain/ — RetComM/wizard will download cmake-clang-v1" \
"(or accept an offline zip / system cmake)" >&2
else
echo "error: toolchain dir required (pass --toolchain-dir or set PSXRECOMP_TOOLCHAIN_DIR)" >&2
exit 1
fi
# Windows MinGW DLL bundling for host + emitters.
BUNDLE="${FW_TOOLS}/bundle_mingw_dlls.sh"
if [[ ! -f "${BUNDLE}" ]]; then
# Staged copy (after sdk overlay) may own the helper.
if [[ -f "${STAGE}/psxrecomp/tools/bundle_mingw_dlls.sh" ]]; then
BUNDLE="${STAGE}/psxrecomp/tools/bundle_mingw_dlls.sh"
fi
fi
need_dlls=0
if [[ -n "${HOST_EXE}" && "${HOST_EXE}" == *.exe ]]; then
need_dlls=1
fi
if [[ -f "${STAGE}/psxrecomp/recompiler/build/psxrecomp-game.exe" ]]; then
need_dlls=1
fi
if [[ "${need_dlls}" -eq 1 ]]; then
if [[ ! -f "${BUNDLE}" ]]; then
echo "error: bundle_mingw_dlls.sh not found next to stage_setup_sdk.sh" >&2
exit 1
fi
chmod +x "${BUNDLE}" 2>/dev/null || true
for emitter in psxrecomp-game.exe psxrecomp-bios.exe; do
if [[ ! -f "${STAGE}/psxrecomp/recompiler/build/${emitter}" ]]; then
echo "error: missing ${emitter} in staged tree" >&2
exit 1
fi
done
args=()
for d in "${RUNTIME_BINS[@]+"${RUNTIME_BINS[@]}"}"; do
args+=(--runtime-bin "${d}")
done
for d in "${SEARCH_DIRS[@]+"${SEARCH_DIRS[@]}"}"; do
args+=(--search-dir "${d}")
done
if [[ -n "${HOST_EXE}" && -f "${HOST_EXE}" ]]; then
args+=(
--exe "${HOST_EXE}"
--dest "${STAGE}"
--label "$(basename "${HOST_EXE}")"
)
fi
args+=(
--exe "${STAGE}/psxrecomp/recompiler/build/psxrecomp-game.exe"
--dest "${STAGE}/psxrecomp/recompiler/build"
--label "psxrecomp-game.exe"
--exe "${STAGE}/psxrecomp/recompiler/build/psxrecomp-bios.exe"
--dest "${STAGE}/psxrecomp/recompiler/build"
--label "psxrecomp-bios.exe"
)
# Host (MSYS2 GCC) still needs these when imported. llvm-mingw static
# emitters typically import neither — --require is skipped per-exe then.
if [[ -n "${HOST_EXE}" && -f "${HOST_EXE}" ]]; then
args+=(
--require libgcc_s_seh-1.dll
--require libstdc++-6.dll
)
fi
bash "${BUNDLE}" "${args[@]}"
fi
echo "stage_setup_sdk: ready under ${STAGE}/psxrecomp"
+404
View File
@@ -0,0 +1,404 @@
"""Resolve / download / unpack portable cmake-clang-v1 toolchain packs.
Used by psxrecomp_cli (ensure-toolchain, rebuild) and mirrors RetComM's
shared-cache layout when possible.
"""
from __future__ import annotations
import os
import platform
import shutil
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
from typing import Optional
DEFAULT_REPO = "TechnicallyComputers/retcomm-toolchains"
PACK_ID = "cmake-clang-v1"
_ASSET = {
"linux-x64": "cmake-clang-v1-linux-x64.zip",
"windows-x64": "cmake-clang-v1-windows-x64.zip",
"macos-arm64": "cmake-clang-v1-macos-universal.zip",
"macos-x64": "cmake-clang-v1-macos-universal.zip",
}
def sys_platform_is_windows() -> bool:
return sys.platform == "win32"
def host_artifact() -> str:
if sys_platform_is_windows():
return "windows-x64"
if sys.platform == "darwin":
return "macos-arm64" if platform.machine().lower() in ("arm64", "aarch64") else "macos-x64"
return "linux-x64"
def cmake_name() -> str:
return "cmake.exe" if sys_platform_is_windows() else "cmake"
def bin_looks_usable(bin_dir: Path) -> bool:
return bin_dir.is_dir() and (bin_dir / cmake_name()).is_file()
def pack_root_looks_usable(root: Path) -> bool:
return bin_looks_usable(root / "bin")
def unwrap_pack_root(path: Path) -> Path:
"""If *path* is a single nested directory with bin/, return that child."""
if pack_root_looks_usable(path):
return path
try:
kids = [p for p in path.iterdir() if p.is_dir() and not p.name.startswith(".")]
except OSError:
return path
if len(kids) == 1 and pack_root_looks_usable(kids[0]):
return kids[0]
return path
def resolve_embedded_bin(project_root: Path) -> Optional[Path]:
root = project_root / "toolchain"
if not root.is_dir():
return None
direct = root / "bin"
if bin_looks_usable(direct):
return direct
try:
kids = [p for p in root.iterdir() if p.is_dir()]
except OSError:
return None
if len(kids) == 1:
nested = kids[0] / "bin"
if bin_looks_usable(nested):
return nested
for kid in kids:
nested = kid / "bin"
if bin_looks_usable(nested):
return nested
return None
def env_toolchain_roots() -> list[Path]:
out: list[Path] = []
for key in ("PSXRECOMP_TOOLCHAIN_DIR", "RETCOMM_TOOLCHAIN_DIR", "TOOLCHAIN_DIR",
"BPE_TOOLCHAIN_DIR"):
raw = os.environ.get(key)
if raw:
out.append(Path(raw).expanduser())
return out
def shared_cache_roots() -> list[Path]:
"""Candidate parent dirs that contain <tag>/ packs (or a flat pack)."""
roots: list[Path] = []
if sys_platform_is_windows():
local = os.environ.get("LOCALAPPDATA")
if local:
roots.append(Path(local) / "retcomm" / "toolchains" / PACK_ID)
roots.append(Path(local) / "psxrecomp" / "toolchains" / PACK_ID)
xdg = os.environ.get("XDG_DATA_HOME")
home = Path.home()
if xdg:
roots.append(Path(xdg) / "retcomm" / "toolchains" / PACK_ID)
roots.append(Path(xdg) / "psxrecomp" / "toolchains" / PACK_ID)
else:
roots.append(home / ".local" / "share" / "retcomm" / "toolchains" / PACK_ID)
roots.append(home / ".local" / "share" / "psxrecomp" / "toolchains" / PACK_ID)
# Dedup while preserving order.
seen: set[str] = set()
uniq: list[Path] = []
for r in roots:
key = str(r)
if key not in seen:
seen.add(key)
uniq.append(r)
return uniq
def preferred_install_root() -> Path:
"""Where newly downloaded / offline-unpacked packs land."""
for r in shared_cache_roots():
# Prefer psxrecomp-owned cache for writes; fall through to first.
if "psxrecomp" in r.parts:
r.mkdir(parents=True, exist_ok=True)
return r
r = shared_cache_roots()[0]
r.mkdir(parents=True, exist_ok=True)
return r
STAMP_NAME = ".psxrecomp-bin"
def is_windows_store_python() -> bool:
"""Microsoft Store Python redirects %LOCALAPPDATA% writes into LocalCache."""
if not sys_platform_is_windows():
return False
try:
exe = str(Path(sys.executable).resolve()).lower()
except OSError:
exe = str(sys.executable).lower()
return "windowsapps" in exe or "pythonsoftwarefoundation" in exe
def write_toolchain_stamp(project_root: Path, bin_dir: Path) -> None:
"""Write project_root/toolchain/.psxrecomp-bin for the C host to read."""
stamp_dir = project_root / "toolchain"
try:
stamp_dir.mkdir(parents=True, exist_ok=True)
(stamp_dir / STAMP_NAME).write_text(
str(bin_dir.resolve()) + "\n", encoding="utf-8"
)
except OSError:
pass
def materialize_into_project(
project_root: Path, pack_root: Path, log=None
) -> Path:
"""Copy a usable pack into project_root/toolchain/ (bin/ at top level).
The setup host resolves this path first; installing here keeps cmake
visible even when the shared-cache path is long or Store-redirected.
"""
src = unwrap_pack_root(pack_root)
if not pack_root_looks_usable(src):
raise RuntimeError(f"toolchain pack unusable: {pack_root}")
dest = project_root / "toolchain"
# Already have a usable tree with the same cmake — keep it.
existing = resolve_embedded_bin(project_root)
if existing:
try:
if (existing / cmake_name()).resolve() == (src / "bin" / cmake_name()).resolve():
write_toolchain_stamp(project_root, existing)
return unwrap_pack_root(dest) if pack_root_looks_usable(dest) else src
except OSError:
pass
if dest.exists():
shutil.rmtree(dest, ignore_errors=True)
shutil.copytree(src, dest)
if log:
log(f"Installed project toolchain at {dest}")
root = unwrap_pack_root(dest)
write_toolchain_stamp(project_root, root / "bin")
return root
def find_cached_pack() -> Optional[Path]:
for base in shared_cache_roots():
if not base.is_dir():
continue
if pack_root_looks_usable(base):
return unwrap_pack_root(base)
try:
kids = sorted(
[p for p in base.iterdir() if p.is_dir()],
key=lambda p: p.stat().st_mtime,
reverse=True,
)
except OSError:
continue
for kid in kids:
root = unwrap_pack_root(kid)
if pack_root_looks_usable(root):
return root
return None
def resolve_toolchain_bin(project_root: Optional[Path] = None) -> Optional[Path]:
for env_root in env_toolchain_roots():
root = unwrap_pack_root(env_root)
if pack_root_looks_usable(root):
return root / "bin"
if project_root is not None:
embedded = resolve_embedded_bin(project_root)
if embedded:
return embedded
cached = find_cached_pack()
if cached:
return cached / "bin"
return None
def activate_toolchain_bin(bin_dir: Path, log=None) -> None:
prefix = str(bin_dir)
pack_root = bin_dir.parent
cur = os.environ.get("PATH", "")
parts = cur.split(os.pathsep) if cur else []
if not parts or Path(parts[0]) != bin_dir:
os.environ["PATH"] = prefix + (os.pathsep + cur if cur else "")
# Windows cmake-clang-v1 ships zlib under the pack root; help FindZLIB.
pack_s = str(pack_root)
os.environ["ZLIB_ROOT"] = pack_s
prev_prefix = os.environ.get("CMAKE_PREFIX_PATH", "")
if not prev_prefix:
os.environ["CMAKE_PREFIX_PATH"] = pack_s
elif pack_s not in prev_prefix.split(os.pathsep):
os.environ["CMAKE_PREFIX_PATH"] = pack_s + os.pathsep + prev_prefix
for name, env_key in (
("clang", "CC"),
("clang++", "CXX"),
("clang.exe", "CC"),
("clang++.exe", "CXX"),
):
cand = bin_dir / name
if cand.is_file() and env_key not in os.environ:
os.environ[env_key] = str(cand)
if log:
log(f"Using toolchain: {bin_dir}")
def unpack_zip_to(zip_path: Path, dest: Path) -> Path:
"""Extract *zip_path* into *dest* (replaced) and return usable pack root."""
if dest.exists():
shutil.rmtree(dest, ignore_errors=True)
dest.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(dest)
root = unwrap_pack_root(dest)
if not pack_root_looks_usable(root):
raise RuntimeError(f"toolchain zip missing bin/{cmake_name()}: {zip_path}")
# If unwrap pointed at a child, normalize to dest by moving contents up when
# dest itself is not the pack root.
if root != dest and pack_root_looks_usable(root):
# Leave nested layout — resolve/activate handle it via unwrap.
return root
return root
def install_from_zip(
zip_path: Path,
tag: str = "offline",
*,
project_root: Optional[Path] = None,
log=None,
) -> Path:
zip_path = zip_path.expanduser().resolve()
if not zip_path.is_file():
raise FileNotFoundError(f"toolchain zip not found: {zip_path}")
dest = preferred_install_root() / tag
root = unpack_zip_to(zip_path, dest)
if project_root is not None:
return materialize_into_project(project_root, root, log=log)
return root
def download_url(url: str, dest: Path, token: Optional[str] = None) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
req = urllib.request.Request(url, headers={"User-Agent": "psxrecomp-toolchain"})
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=120) as resp, open(dest, "wb") as out:
shutil.copyfileobj(resp, out)
def download_latest_pack(
artifact: Optional[str] = None,
repo: str = DEFAULT_REPO,
log=None,
*,
project_root: Optional[Path] = None,
) -> Path:
art = artifact or host_artifact()
asset = _ASSET.get(art)
if not asset:
raise RuntimeError(f"unknown toolchain artifact: {art}")
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or ""
url = f"https://github.com/{repo}/releases/latest/download/{asset}"
if log:
log(f"Downloading {asset} from {repo}...")
with tempfile.TemporaryDirectory(prefix="psxrecomp-tc-") as tmp:
zpath = Path(tmp) / asset
try:
download_url(url, zpath, token=token or None)
except urllib.error.HTTPError as e:
raise RuntimeError(f"toolchain download failed ({e.code}): {url}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"toolchain download failed: {e.reason}") from e
dest = preferred_install_root() / "latest"
root = unpack_zip_to(zpath, dest)
if log:
log(f"Installed toolchain pack at {root}")
if project_root is not None:
return materialize_into_project(project_root, root, log=log)
return root
def ensure_toolchain(
project_root: Optional[Path] = None,
*,
from_zip: Optional[Path] = None,
download: bool = False,
repo: str = DEFAULT_REPO,
log=None,
) -> Path:
"""Return usable toolchain *bin* directory, installing if requested.
Resolution order: env override → project toolchain/ → shared cache →
optional --from-zip → optional download.
New installs also land in project_root/toolchain/ when project_root is set
(plus the shared cache), and write toolchain/.psxrecomp-bin for the host.
"""
if is_windows_store_python() and log:
log(
"Warning: Microsoft Store Python redirects AppData writes. "
"Prefer python.org Python if toolchain setup fails to find cmake."
)
if from_zip is not None:
root = install_from_zip(
Path(from_zip), project_root=project_root, log=log
)
bin_dir = unwrap_pack_root(root) / "bin"
if not bin_looks_usable(bin_dir):
bin_dir = root / "bin"
if project_root is not None:
write_toolchain_stamp(project_root, bin_dir)
activate_toolchain_bin(bin_dir, log=log)
return bin_dir
existing = resolve_toolchain_bin(project_root)
if existing:
# Prefer a project-local copy so the C host always sees the same tree.
if project_root is not None and not resolve_embedded_bin(project_root):
try:
root = materialize_into_project(
project_root, existing.parent, log=log
)
existing = root / "bin"
except OSError as exc:
if log:
log(f"Could not copy toolchain into project: {exc}")
write_toolchain_stamp(project_root, existing)
elif project_root is not None:
write_toolchain_stamp(project_root, existing)
activate_toolchain_bin(existing, log=log)
return existing
if download:
root = download_latest_pack(
repo=repo, log=log, project_root=project_root
)
bin_dir = unwrap_pack_root(root) / "bin"
if not bin_looks_usable(bin_dir):
bin_dir = root / "bin"
if project_root is not None:
write_toolchain_stamp(project_root, bin_dir)
activate_toolchain_bin(bin_dir, log=log)
return bin_dir
raise RuntimeError(
"No portable toolchain found. Pass --from-zip PATH to a "
"cmake-clang-v1-*.zip, use --download, set PSXRECOMP_TOOLCHAIN_DIR / "
"RETCOMM_TOOLCHAIN_DIR, or install cmake on PATH."
)