mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 10:19:47 -04:00
rollback patches
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
+1003
-16
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
@@ -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 */
|
||||
+1
-1
Submodule lib/recomp-net updated: 23eec611eb...8b31aad3a7
Executable
+1342
File diff suppressed because it is too large
Load Diff
+25
-19
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 */
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+941
-188
File diff suppressed because it is too large
Load Diff
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+240
@@ -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
|
||||
Executable
+106
@@ -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}"
|
||||
Executable
+25
@@ -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)"
|
||||
Executable
+50
@@ -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
|
||||
Executable
+38
@@ -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
|
||||
Executable
+133
@@ -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
|
||||
Executable
+294
@@ -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}"
|
||||
@@ -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())
|
||||
Executable
+266
@@ -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"
|
||||
@@ -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."
|
||||
)
|
||||
Reference in New Issue
Block a user