diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml new file mode 100644 index 0000000..2445098 --- /dev/null +++ b/.github/workflows/build-linux.yml @@ -0,0 +1,157 @@ +name: Build Linux + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + name: Linux ROM-free build + runs-on: ubuntu-24.04 + env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 750M + CMAKE_C_COMPILER_LAUNCHER: ccache + CMAKE_CXX_COMPILER_LAUNCHER: ccache + + steps: + - name: Check out title sources + uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git curl ca-certificates ccache \ + libsdl2-dev libgl1-mesa-dev libx11-dev libxext-dev libxrandr-dev \ + libxcursor-dev libxi-dev libxinerama-dev libwayland-dev + + - name: Restore compiler cache + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.ccache + key: >- + mph-linux-x86_64-ccache-v1-${{ hashFiles( + 'ndsrecomp.pin', + 'config/mph_rom_profiles.json', + 'tools/patch_ndsrecomp_mph_runtime.py', + 'tools/patch_ndsrecomp_rom_free_release.py' + ) }} + restore-keys: | + mph-linux-x86_64-ccache-v1- + + - name: Configure compiler cache + run: | + set -euo pipefail + ccache --version + ccache --set-config=max_size="$CCACHE_MAXSIZE" + ccache --zero-stats + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Fetch pinned ndsrecomp + run: | + set -euo pipefail + nds_pin="$(tr -d '\r\n' < ndsrecomp.pin)" + git clone --filter=blob:none https://github.com/mstan/ndsrecomp.git ../ndsrecomp + git -C ../ndsrecomp checkout --detach "$nds_pin" + git -C ../ndsrecomp submodule update --init --recursive + test "$(git -C ../ndsrecomp rev-parse HEAD)" = "$nds_pin" + + - name: Apply MPH and ROM-free runner integration + run: | + set -euo pipefail + python tools/patch_ndsrecomp_mph_runtime.py \ + --framework-root ../ndsrecomp \ + --profiles config/mph_rom_profiles.json + python tools/patch_ndsrecomp_rom_free_release.py \ + --framework-root ../ndsrecomp + python tools/patch_ndsrecomp_mph_runtime.py \ + --framework-root ../ndsrecomp \ + --profiles config/mph_rom_profiles.json + python tools/patch_ndsrecomp_rom_free_release.py \ + --framework-root ../ndsrecomp + + - name: Generate redistributable FreeBIOS banks + run: | + set -euo pipefail + python tools/ci/prepare_freebios_banks.py \ + --framework-root ../ndsrecomp \ + --build-dir ../ndsrecomp/build-freebios-recompiler + test ! -e ../ndsrecomp/generated/arm9_bios.c + test ! -e ../ndsrecomp/generated/arm7_bios.c + + - name: Build ROM-free runner + run: | + set -euo pipefail + cmake -S ../ndsrecomp/runner -B ../ndsrecomp/runner/build-mph-nightly \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DNDS_BOOTSTRAP_FIRMWARE=ON \ + -DNDS_RETAIL_BIOS_BANKS=OFF \ + -DNDS_ENABLE_COMPUTE_RENDERER=ON + cmake --build ../ndsrecomp/runner/build-mph-nightly + test -x ../ndsrecomp/runner/build-mph-nightly/nds_runner + if grep -a -E -q 'g_dispatch_mph_arm(9|7)|mph_arm9_fmv_runtime' \ + ../ndsrecomp/runner/build-mph-nightly/nds_runner; then + echo 'ROM-derived MPH title bank leaked into ROM-free runner' >&2 + exit 1 + fi + + - name: Install pinned AppImage packaging tools + run: | + set -euo pipefail + mkdir -p .ci-tools + curl -fL --retry 3 \ + https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage \ + -o .ci-tools/appimagetool + echo 'ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 .ci-tools/appimagetool' | sha256sum -c - + curl -fL --retry 3 \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage \ + -o .ci-tools/linuxdeploy + echo '421ca71d5c69ea97c6309276232990d43df1dcece0edfaa26bbf926ff96ed12e .ci-tools/linuxdeploy' | sha256sum -c - + chmod +x .ci-tools/appimagetool .ci-tools/linuxdeploy + + - name: Package Linux Nightly payload + run: | + set -euo pipefail + version="$(sed -n 's/^project(MetroidPrimeHuntersRecomp VERSION \([0-9.]*\).*/\1/p' CMakeLists.txt)" + test -n "$version" + bash tools/package-linux-appimage.sh \ + --version "$version" \ + --runner ../ndsrecomp/runner/build-mph-nightly/nds_runner \ + --appimage-tool "$PWD/.ci-tools/appimagetool" \ + --linuxdeploy "$PWD/.ci-tools/linuxdeploy" + image="release-stage/MetroidPrimeHuntersRecomp-linux-v${version}-x86_64.AppImage" + test -s "$image" + mkdir -p /tmp/mph-appimage-audit + (cd /tmp/mph-appimage-audit && "$GITHUB_WORKSPACE/$image" --appimage-extract >/dev/null) + if find /tmp/mph-appimage-audit/squashfs-root -type f \( \ + -iname '*.nds' -o -iname '*.sav' -o -iname '*.dsv' -o \ + -iname 'biosnds9.rom' -o -iname 'biosnds7.rom' -o -iname 'firmware.bin' \ + \) -print -quit | grep -q .; then + echo 'Forbidden ROM/save/BIOS/firmware material found inside AppImage' >&2 + exit 1 + fi + + - name: Show compiler cache stats + if: always() + run: | + if command -v ccache >/dev/null 2>&1; then + ccache --show-stats || true + fi + + - name: Upload Linux Nightly payload + uses: actions/upload-artifact@v4 + with: + name: mph-nightly-linux + path: release-stage/MetroidPrimeHuntersRecomp-linux-v*-x86_64.AppImage + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..8c32a16 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,204 @@ +name: Build Windows + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + windows: + name: Windows ROM-free build + runs-on: windows-2025 + env: + # Keep compiler output reusable across PR iterations. ccache validates the + # compiler command/source content itself; this directory is only a cache, + # never a source of build truth. + CCACHE_DIR: ${{ github.workspace }}\.ccache + CCACHE_MAXSIZE: 750M + CMAKE_C_COMPILER_LAUNCHER: ccache + CMAKE_CXX_COMPILER_LAUNCHER: ccache + # Hosted CMake is a native Windows binary. Point it at MinGW's package + # prefix so SDL2's CMake package remains discoverable without installing + # a second copy of CMake inside MSYS2. + CMAKE_PREFIX_PATH: C:\msys64\mingw64 + + steps: + - name: Check out title sources + uses: actions/checkout@v4 + + - name: Install MinGW build dependencies + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + # windows-2025 already includes C:\msys64 plus native Git, CMake, + # Ninja and Python. Inherit the hosted PATH and install only the + # MinGW-specific compiler/runtime dependencies we actually need. + path-type: inherit + release: false + update: false + cache: true + install: >- + mingw-w64-x86_64-gcc + mingw-w64-x86_64-SDL2 + mingw-w64-x86_64-ccache + + - name: Restore compiler cache + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}\.ccache + key: >- + mph-windows-mingw64-ccache-v1-${{ hashFiles( + 'ndsrecomp.pin', + 'recomp-ui.pin', + 'launcher/recomp-ui/**', + 'tools/patch_ndsrecomp_mph_runtime.py', + 'tools/patch_ndsrecomp_rom_free_release.py', + 'tools/patch_recomp_ui_mph_multirom.py' + ) }} + restore-keys: | + mph-windows-mingw64-ccache-v1- + + - name: Verify hosted build tools + shell: msys2 {0} + run: | + set -euo pipefail + command -v git + command -v python + command -v cmake + command -v ninja + command -v gcc + command -v ccache + git --version + python --version + cmake --version | head -n1 + ninja --version + gcc --version | head -n1 + + - name: Configure compiler cache + shell: msys2 {0} + run: | + set -euo pipefail + ccache --version + ccache --set-config=max_size="$CCACHE_MAXSIZE" + ccache --zero-stats + + - name: Fetch pinned ndsrecomp and recomp-ui + shell: msys2 {0} + run: | + set -euo pipefail + nds_pin="$(tr -d '\r\n' < ndsrecomp.pin)" + ui_pin="$(tr -d '\r\n' < recomp-ui.pin)" + git clone --filter=blob:none https://github.com/mstan/ndsrecomp.git ../ndsrecomp + git -C ../ndsrecomp checkout --detach "$nds_pin" + git -C ../ndsrecomp submodule update --init --recursive + git clone --filter=blob:none https://github.com/mstan/recomp-ui.git ../recomp-ui + git -C ../recomp-ui checkout --detach "$ui_pin" + test "$(git -C ../ndsrecomp rev-parse HEAD)" = "$nds_pin" + test "$(git -C ../recomp-ui rev-parse HEAD)" = "$ui_pin" + + - name: Apply MPH and ROM-free runner integration + shell: msys2 {0} + run: | + set -euo pipefail + python tools/patch_ndsrecomp_mph_runtime.py \ + --framework-root ../ndsrecomp \ + --profiles config/mph_rom_profiles.json + python tools/patch_ndsrecomp_rom_free_release.py \ + --framework-root ../ndsrecomp + # Both patch stacks are required to be idempotent. + python tools/patch_ndsrecomp_mph_runtime.py \ + --framework-root ../ndsrecomp \ + --profiles config/mph_rom_profiles.json + python tools/patch_ndsrecomp_rom_free_release.py \ + --framework-root ../ndsrecomp + + - name: Generate redistributable FreeBIOS banks + shell: msys2 {0} + run: | + set -euo pipefail + python tools/ci/prepare_freebios_banks.py \ + --framework-root ../ndsrecomp \ + --build-dir ../ndsrecomp/build-freebios-recompiler + test ! -e ../ndsrecomp/generated/arm9_bios.c + test ! -e ../ndsrecomp/generated/arm7_bios.c + + - name: Build ROM-free runner + shell: msys2 {0} + run: | + set -euo pipefail + cmake -S ../ndsrecomp/runner -B ../ndsrecomp/runner/build-mph-nightly \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DNDS_BOOTSTRAP_FIRMWARE=ON \ + -DNDS_RETAIL_BIOS_BANKS=OFF \ + -DNDS_ENABLE_COMPUTE_RENDERER=ON + cmake --build ../ndsrecomp/runner/build-mph-nightly + test -s ../ndsrecomp/runner/build-mph-nightly/nds_runner.exe + if grep -a -E -q 'g_dispatch_mph_arm(9|7)|mph_arm9_fmv_runtime' \ + ../ndsrecomp/runner/build-mph-nightly/nds_runner.exe; then + echo 'ROM-derived MPH title bank leaked into ROM-free runner' >&2 + exit 1 + fi + + - name: Build and test launcher + shell: msys2 {0} + run: | + set -euo pipefail + cmake -S launcher/recomp-ui -B launcher/recomp-ui/build-nightly \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DNDSRECOMP_ROOT="$PWD/../ndsrecomp" \ + -DRECOMP_UI_ROOT="$PWD/../recomp-ui" \ + '-DMPH_LAUNCHER_REGION=Auto (runtime detected)' + cmake --build launcher/recomp-ui/build-nightly + ctest --test-dir launcher/recomp-ui/build-nightly --output-on-failure + test -s launcher/recomp-ui/build-nightly/mph-recomp-ui.exe + grep -q 'game.region = "Auto (runtime detected)";' \ + launcher/recomp-ui/build-nightly/launcher_main_profile.cpp + grep -q 'game.known_sha1_hex = nullptr;' \ + launcher/recomp-ui/build-nightly/launcher_main_profile.cpp + # Fresh ROM-free extraction must start with no ROM selected. The + # conventional filename is auto-selected only when the file exists. + grep -q 'std::filesystem::is_regular_file(default_rom, initial_rom_error)' \ + launcher/recomp-ui/build-nightly/launcher_main_profile.cpp + grep -q 'exe.string().c_str(), initial_rom.c_str(),' \ + launcher/recomp-ui/build-nightly/launcher_main_profile.cpp + grep -q 'MPH_MULTIROM_DELEGATED_VERIFY' \ + ../recomp-ui/src/common/backends/imgui/launcher_imgui.cpp + grep -q 'const bool readable = m->rom_present && std::strcmp(m->rom_size, "--") != 0;' \ + ../recomp-ui/src/common/backends/imgui/launcher_imgui.cpp + + - name: Package Windows Nightly payload + shell: msys2 {0} + run: | + set -euo pipefail + version="$(sed -n 's/^project(MetroidPrimeHuntersRecomp VERSION \([0-9.]*\).*/\1/p' CMakeLists.txt)" + test -n "$version" + runtime_bin="$(cygpath -w /mingw64/bin)" + ps_exe="$(cygpath -u "${SYSTEMROOT}")/System32/WindowsPowerShell/v1.0/powershell.exe" + script_path="$(cygpath -w "$PWD/tools/package-windows-nightly.ps1")" + test -x "$ps_exe" + "$ps_exe" -NoProfile -ExecutionPolicy Bypass -File "$script_path" \ + -Version "$version" \ + -RunnerBuildDir '..\ndsrecomp\runner\build-mph-nightly' \ + -LauncherBuildDir 'launcher\recomp-ui\build-nightly' \ + -RuntimeBinDir "$runtime_bin" + test -s "release-stage/MetroidPrimeHuntersRecomp-windows-x64-v${version}.zip" + + - name: Show compiler cache stats + if: always() + shell: msys2 {0} + run: | + if command -v ccache >/dev/null 2>&1; then + ccache --show-stats || true + fi + + - name: Upload Windows Nightly payload + uses: actions/upload-artifact@v4 + with: + name: mph-nightly-windows + path: release-stage/MetroidPrimeHuntersRecomp-windows-x64-v*.zip + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ec8b778 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,55 @@ +name: Build + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mph-build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + policy: + name: ROM-free release policy + runs-on: ubuntu-24.04 + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Check ROM-free helper syntax + shell: bash + run: | + set -euo pipefail + python -m py_compile \ + tools/patch_ndsrecomp_rom_free_release.py \ + tools/patch_recomp_ui_mph_multirom.py \ + tools/ci/check_rom_free_release_sources.py \ + tools/ci/prepare_freebios_banks.py \ + tools/ci/verify-nightly-assets.py + bash -n tools/package-linux-appimage.sh + pwsh -NoProfile -Command ' + $tokens = $null; $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path "tools/package-windows-nightly.ps1"), + [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { + Write-Error ($errors | Out-String) + exit 1 + } + ' + + - name: Verify no ROM-secret release path + run: python tools/ci/check_rom_free_release_sources.py + + windows: + name: Build Windows + needs: policy + uses: ./.github/workflows/build-windows.yml + + linux: + name: Build Linux + needs: policy + uses: ./.github/workflows/build-linux.yml diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml new file mode 100644 index 0000000..85057d4 --- /dev/null +++ b/.github/workflows/nightly-release.yml @@ -0,0 +1,164 @@ +name: Nightly Release + +# ROM-free public Nightly. The same Windows/Linux workflow used by PR CI builds +# the release payload, so CI and Nightly cannot silently drift apart. No ROM, +# ROM URL, ROM secret, proprietary BIOS, firmware dump, save, or ROM-derived +# title bank is fetched or uploaded by this workflow. + +on: + push: + branches: + - develop + workflow_dispatch: + +concurrency: + group: mph-nightly-release + cancel-in-progress: false + +permissions: + contents: read + +env: + NIGHTLY_TAG: nightly-release + NIGHTLY_NAME: Nightly Build + +jobs: + windows: + name: Build Windows + uses: ./.github/workflows/build-windows.yml + + linux: + name: Build Linux + uses: ./.github/workflows/build-linux.yml + + publish: + name: Publish Nightly release + needs: [windows, linux] + if: github.repository == 'Zection6V/MetroidPrimeHuntersRecomp' + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Download Windows payload + uses: actions/download-artifact@v4 + with: + name: mph-nightly-windows + path: dist + + - name: Download Linux payload + uses: actions/download-artifact@v4 + with: + name: mph-nightly-linux + path: dist + + - name: Resolve project version + id: version + shell: bash + run: | + set -euo pipefail + version="$(sed -n 's/^project(MetroidPrimeHuntersRecomp VERSION \([0-9.]*\).*/\1/p' CMakeLists.txt)" + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Nightly project version: $version" + + - name: Verify Nightly payload + run: | + set -euo pipefail + find dist -maxdepth 1 -type f -printf '%f (%s bytes)\n' | sort + python tools/ci/verify-nightly-assets.py \ + --dist dist \ + --version '${{ steps.version.outputs.version }}' \ + --write-sums + test -s dist/SHA256SUMS.txt + cat dist/SHA256SUMS.txt + + - name: Compose release notes + shell: bash + run: | + set -euo pipefail + cat > nightly-notes.md </\` beside the executable/AppImage for the future local optimization/JIT cache. + + These builds are automatic development snapshots and may be slower or less stable than optimized tagged releases. + EOF + cat nightly-notes.md + + - name: Move Nightly tag to this commit + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${NIGHTLY_TAG}" >/dev/null 2>&1; then + gh api --method PATCH \ + "repos/${GITHUB_REPOSITORY}/git/refs/tags/${NIGHTLY_TAG}" \ + -f sha="${GITHUB_SHA}" -F force=true >/dev/null + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${NIGHTLY_TAG}" -f sha="${GITHUB_SHA}" >/dev/null + fi + + - name: Create or update Nightly release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if gh release view "${NIGHTLY_TAG}" >/dev/null 2>&1; then + gh release edit "${NIGHTLY_TAG}" \ + --title "${NIGHTLY_NAME}" \ + --notes-file nightly-notes.md \ + --prerelease \ + --draft=false + else + gh release create "${NIGHTLY_TAG}" \ + --title "${NIGHTLY_NAME}" \ + --notes-file nightly-notes.md \ + --prerelease + fi + + - name: Upload Nightly assets + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + gh release upload "${NIGHTLY_TAG}" dist/* --clobber + + - name: Remove stale Nightly assets + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + (cd dist && ls -1) > published.txt + gh release view "${NIGHTLY_TAG}" --json assets --jq '.assets[].name' > attached.txt + while IFS= read -r asset; do + [ -n "$asset" ] || continue + if ! grep -Fxq "$asset" published.txt; then + gh release delete-asset "${NIGHTLY_TAG}" "$asset" --yes + fi + done < attached.txt + + - name: Summarize + shell: bash + run: | + { + echo '### Nightly Build published' + echo + echo "- Tag: \`${NIGHTLY_TAG}\` -> \`${GITHUB_SHA}\`" + echo '- ROM/ROM URL/ROM secret: **not used**' + echo '- Title-bank mode: ROM-free / Tier-3 fallback' + echo '- Assets:' + (cd dist && ls -1 | sed 's/^/ - /') + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 1608302..7b9be66 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /saves/ /captures/ /scratch/ +/cache/ *.sav *.log /rom.cfg diff --git a/README.md b/README.md index 98de666..ba035c6 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,27 @@ decoded textures. It is disabled by default; native rendering remains the reference path. This branch keeps that upstream feature alongside the multi-ROM-safe Adaptive Widescreen and Prime Controls work. +## Nightly builds and local optimization cache + +The `develop` branch publishes a fixed `nightly-release` prerelease after the +Windows and Linux build workflows and release-payload checks succeed. This +public Nightly path is deliberately **ROM-free**: GitHub Actions does not fetch +or receive a Metroid Prime Hunters ROM, private ROM URL/secret, proprietary BIOS +or firmware dump, save data, or ROM-derived MPH title bank. + +When a content-specific native title bank is not linked, direct-booted MPH code +uses ndsrecomp's Tier-3 correctness fallback. This makes a ROM-free Nightly +possible, but it can be slower than an optimized tagged build, especially in +known hot runtime-code paths such as opening movies. + +Nightly packages reserve the portable-first optimization-cache namespace +`cache/banks//` beside the executable/AppImage. Whole-ROM SHA-1 is +used there only as exact cache/content identity; runtime base-profile selection +continues to use the executable-compatible MPH detector. The current Nightly +does **not** generate a native title bank in this directory yet. The intended +next step is a compiler-free local bank/JIT cache. See +[`docs/LOCAL_BANK_CACHE.md`](docs/LOCAL_BANK_CACHE.md). + ## Quick Start Windows: @@ -104,6 +125,10 @@ content/capture coverage before it is considered fully brought up. movies, fades, or screen-routing behavior may be wrong. - HD texture upscaling remains opt-in and should not be treated as the native reference rendering path. +- ROM-free Nightly builds can be slower while MPH title code is using Tier-3 + instead of a validated native optimization bank. +- The local `cache/banks//` path is currently a cache contract; + dynamic native/JIT bank generation is not implemented yet. - Online play is experimental. Wiimmfi can reach the lobby in validated flows, but in-game play is ultimately untested. There is no guarantee that a match will connect, stay connected, or avoid desync. diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 025cd37..f5b17ab 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -140,3 +140,26 @@ overlay table contains 576 bytes (18 records), not 576 separate overlays. sites must be patched. The host adaptive viewport is enabled as an explicit bring-up baseline, but it is not considered visually complete until those title-side behaviors pass sustained gameplay review. + +## ROM-free Nightly and local optimization cache + +The public Windows/Linux Nightly path is deliberately ROM-free. GitHub Actions +does not receive a Metroid Prime Hunters ROM, private ROM URL/secret, +proprietary BIOS/firmware dump, save, or generated ROM-derived MPH title bank. +The pinned runner is built with the redistributable BSD-2-Clause FreeBIOS native +banks and with `NDS_RETAIL_BIOS_BANKS=OFF`. + +When no content-specific MPH native bank is linked, direct-booted ARM9/ARM7 code +uses the existing Tier-3 interpreter path after guest writes establish RAM code +provenance. This is a correctness-first Nightly mode and can be substantially +slower than the historical optimized US1.0 release, especially in the FMV hot +runtime-code path described above. + +Nightly packages reserve `cache/banks//` beside the executable or +AppImage as the future portable optimization-cache namespace. Whole-ROM SHA-1 +is suitable here because cache payloads must be bound to exact content, but it +must not become the runtime base-profile selector. The current static +recompiler emits C and links it into the runner, so first-launch C/C++ +compilation is intentionally **not** the target UX. The intended progression is +Tier-3 -> compiler-free local bank/IR support if useful -> hot-block JIT with a +validated persistent cache. See `docs/LOCAL_BANK_CACHE.md`. diff --git a/docs/LOCAL_BANK_CACHE.md b/docs/LOCAL_BANK_CACHE.md new file mode 100644 index 0000000..3bcdf50 --- /dev/null +++ b/docs/LOCAL_BANK_CACHE.md @@ -0,0 +1,97 @@ +# Local optimization cache architecture + +## Status + +The public Nightly path is intentionally ROM-free. GitHub Actions builds the +runner, launcher, and redistributable FreeBIOS banks without receiving a +Metroid Prime Hunters ROM, a private ROM URL, proprietary BIOS dumps, firmware +dumps, saves, or generated title-bank source. + +Today, when no content-specific native title bank is linked, Metroid Prime +Hunters code loaded by direct boot executes through ndsrecomp Tier-3. This is +the correctness fallback, not the final performance target. + +## Portable-first cache root + +The preferred future optimization cache lives beside the distributed +executable/AppImage: + +```text +MetroidPrimeHuntersRecomp/ +├─ MetroidPrimeHuntersRecomp.exe # Windows +├─ Metroid Prime Hunters.nds # user-owned, optional filename +└─ cache/ + └─ banks/ + └─ / + ├─ manifest.json + └─ ... generated optimization payload ... +``` + +Linux AppImage packaging resolves the same `cache/banks` directory beside the +AppImage. If that directory is not writable, it falls back to +`$XDG_CACHE_HOME/MetroidPrimeHuntersRecomp/banks` (or `~/.cache/...`). Windows +implementations should analogously fall back to +`%LOCALAPPDATA%\MetroidPrimeHuntersRecomp\cache\banks` when the portable +location cannot be written. + +Save data, firmware/WFC identity, and other persistent user state are not +optimization cache data and must remain in their existing persistent app-data +locations. + +## Identity model + +Whole-ROM SHA-1 is appropriate for the cache namespace because cache payloads +must be bound to exact content. It must **not** become the runtime base-profile +selector. + +Runtime base identity remains the seven-profile MPH detector: + +- US1.0 +- US1.1 +- EU1.0 +- EU1.1 +- JP1.0 +- JP1.1 +- KR1.0 + +The authoritative fast path uses the executable checksum table. Exact supported +header tuples are only a candidate fallback, and dangerous host writes continue +to fail closed when executable compatibility is not authoritative. + +A future cache manifest should bind at least: + +```text +bank_format_version +runner_abi_version +ndsrecomp_codegen_version +host_arch +base_profile +content_sha1 +executable_crc32 +coverage_or_hotset_hash +validated_guest_code_hashes +``` + +Any incompatible field invalidates the cache and falls back to Tier-3 rather +than guessing compatibility. + +## Why first-run C compilation is not the target + +The current static ndsrecomp title-bank pipeline emits C and links it into +`nds_runner`. Reproducing that pipeline on a player's first launch would require +shipping or requiring a host C/C++ compiler and linker, complicating updates, +code signing, antivirus behavior, and cache ABI compatibility. + +Therefore the intended progression is: + +1. **Current:** ROM-free binary + Tier-3 fallback. +2. **Foundation:** portable-first per-content cache namespace. +3. **Next:** a compiler-free portable bank/IR cache if useful. +4. **Target:** Tier-3 hot-block detection -> host JIT -> validated persistent + local cache. + +Runtime/overlay code generated by the game should be eligible for the same hot +JIT path, avoiding the need to pre-capture one clean ROM's FMV runtime image. +This is particularly important for modified ROMs such as translations or other +code/data modifications, whose exact content must never silently reuse a clean +ROM's optimization payload. diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index cbf4a90..3c3005e 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -6,6 +6,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(SDL2 CONFIG REQUIRED) +find_package(Python3 COMPONENTS Interpreter REQUIRED) set(NDSRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../ndsrecomp" CACHE PATH "Path to the ndsrecomp framework checkout (for the shared SHA-1 helper)") @@ -52,10 +53,42 @@ mph_launcher_replace_required( "exe / \"${MPH_LAUNCHER_DEFAULT_ROM}\";" "the default MPH ROM filename") +# recomp-ui treats any non-empty initial ROM string as selected before it tries +# to open the file. A release package intentionally contains no ROM, so do not +# feed the conventional filename to the model unless that file really exists. +# This preserves the useful portable behavior where a player may deliberately +# place a ROM with the conventional name beside the executable, while a fresh +# extraction correctly starts at "No ROM loaded". +mph_launcher_replace_required( + " char selected_rom[1024]{};\n const int result = recomp_launcher_run_window(" + " std::error_code initial_rom_error;\n const std::string initial_rom =\n std::filesystem::is_regular_file(default_rom, initial_rom_error)\n ? default_rom.string()\n : std::string();\n char selected_rom[1024]{};\n const int result = recomp_launcher_run_window(" + "the initial ROM selection block") +mph_launcher_replace_required( + "exe.string().c_str(), default_rom.string().c_str()," + "exe.string().c_str(), initial_rom.c_str()," + "the launcher initial ROM argument") + set(MPH_PROFILE_LAUNCHER_SOURCE "${CMAKE_CURRENT_BINARY_DIR}/launcher_main_profile.cpp") file(WRITE "${MPH_PROFILE_LAUNCHER_SOURCE}" "${MPH_LAUNCHER_SOURCE}") +# Configure-time regression guard for the fresh-release UX. The generated +# launcher must never pass a missing conventional ROM path into recomp-ui as if +# the user had selected it. This deliberately tests the generated TU rather +# than the untransformed upstream-tracking source. +string(FIND "${MPH_LAUNCHER_SOURCE}" + "std::filesystem::is_regular_file(default_rom, initial_rom_error)" + _mph_initial_rom_exists_guard) +if(_mph_initial_rom_exists_guard EQUAL -1) + message(FATAL_ERROR "generated launcher lost the default-ROM existence guard") +endif() +string(FIND "${MPH_LAUNCHER_SOURCE}" + "exe.string().c_str(), initial_rom.c_str()," + _mph_initial_rom_argument_guard) +if(_mph_initial_rom_argument_guard EQUAL -1) + message(FATAL_ERROR "generated launcher still passes the unconditional default ROM path") +endif() + add_executable(mph-recomp-ui "${MPH_PROFILE_LAUNCHER_SOURCE}" "${NDSRECOMP_ROOT}/recompiler/support/sha1.cpp") target_include_directories(mph-recomp-ui PRIVATE @@ -65,6 +98,32 @@ target_link_libraries(mph-recomp-ui PRIVATE SDL2::SDL2) set(RECOMP_UI_ROOT "F:/Projects/recomp-ui" CACHE PATH "Path to the shared recomp-ui checkout") +option(MPH_PATCH_RECOMP_UI_MULTIROM + "Patch pinned recomp-ui so fingerprint-free MPH ROMs show runtime-delegated validation" + ON) +set(_mph_recomp_ui_imgui + "${RECOMP_UI_ROOT}/src/common/backends/imgui/launcher_imgui.cpp") +if(MPH_PATCH_RECOMP_UI_MULTIROM AND EXISTS "${_mph_recomp_ui_imgui}") + execute_process( + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/patch_recomp_ui_mph_multirom.py" + --recomp-ui-root "${RECOMP_UI_ROOT}" + RESULT_VARIABLE _mph_recomp_ui_patch_result + OUTPUT_VARIABLE _mph_recomp_ui_patch_stdout + ERROR_VARIABLE _mph_recomp_ui_patch_stderr) + if(NOT _mph_recomp_ui_patch_result EQUAL 0) + message(FATAL_ERROR + "Failed to apply MPH multi-ROM recomp-ui patch:\n" + "${_mph_recomp_ui_patch_stdout}${_mph_recomp_ui_patch_stderr}") + endif() + message(STATUS "${_mph_recomp_ui_patch_stdout}") +elseif(MPH_PATCH_RECOMP_UI_MULTIROM) + # Static launcher-profile tests intentionally provide a minimal recomp-ui + # CMake stub and do not compile the UI backend. A real launcher build has + # the pinned source above and must take the fail-closed patch path. + message(STATUS + "MPH recomp-ui presentation patch skipped: UI backend source is not present") +endif() enable_testing() add_executable(mph-mod-provider-test tests/launcher_mod_provider_test.cpp diff --git a/packaging/CACHE_README.txt b/packaging/CACHE_README.txt new file mode 100644 index 0000000..53c33b0 --- /dev/null +++ b/packaging/CACHE_README.txt @@ -0,0 +1,21 @@ +Metroid Prime Hunters Recomp optimization cache +================================================ + +This directory is reserved for locally generated optimization banks/caches. +The preferred portable layout is: + + cache/banks// + +The whole-ROM SHA-1 is used only as the content/cache namespace. Runtime base +profile selection continues to use the executable-compatible MPH detector and +must never guess a base profile from this cache path. + +Current ROM-free Nightly builds do not generate native title banks here yet; +missing title banks execute through the ndsrecomp Tier-3 reference interpreter. +A future local JIT/portable-bank backend will populate this directory without +requiring a C/C++ compiler on the player's machine. + +If the application directory is not writable, implementations should fall back +to the operating system cache location (LOCALAPPDATA on Windows, XDG cache on +Linux). Saves and firmware identity/state are persistent user data and do not +belong in this regenerable cache. diff --git a/recomp-ui.pin b/recomp-ui.pin new file mode 100644 index 0000000..f428d8e --- /dev/null +++ b/recomp-ui.pin @@ -0,0 +1 @@ +8e385a0bff407e379414ba5ccbbcde1fac27e5cd diff --git a/tools/ci/check_rom_free_release_sources.py b/tools/ci/check_rom_free_release_sources.py new file mode 100644 index 0000000..5813bac --- /dev/null +++ b/tools/ci/check_rom_free_release_sources.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Pin the public Nightly's no-ROM-secret source policy.""" + +from __future__ import annotations + +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = [ + ROOT / ".github" / "workflows" / "build-windows.yml", + ROOT / ".github" / "workflows" / "build-linux.yml", + ROOT / ".github" / "workflows" / "build.yml", + ROOT / ".github" / "workflows" / "nightly-release.yml", +] + + +def main() -> int: + failures: list[str] = [] + for path in WORKFLOWS: + if not path.is_file(): + failures.append(f"missing release workflow: {path.relative_to(ROOT)}") + continue + text = path.read_text(encoding="utf-8") + if "MPH_US10_ROM_URL" in text: + failures.append(f"{path.relative_to(ROOT)} still references MPH_US10_ROM_URL") + if "secrets." in text: + failures.append( + f"{path.relative_to(ROOT)} references repository/environment secrets" + ) + + nightly = (ROOT / ".github" / "workflows" / "nightly-release.yml").read_text( + encoding="utf-8" + ) + for required in ( + "uses: ./.github/workflows/build-windows.yml", + "uses: ./.github/workflows/build-linux.yml", + "NIGHTLY_TAG: nightly-release", + "verify-nightly-assets.py", + ): + if required not in nightly: + failures.append(f"nightly workflow missing required contract: {required}") + + for workflow in ("build-windows.yml", "build-linux.yml"): + text = (ROOT / ".github" / "workflows" / workflow).read_text(encoding="utf-8") + for required in ( + "patch_ndsrecomp_rom_free_release.py", + "prepare_freebios_banks.py", + "-DNDS_RETAIL_BIOS_BANKS=OFF", + ): + if required not in text: + failures.append(f"{workflow} missing ROM-free build contract: {required}") + + if failures: + print("ROM-free release policy check FAILED:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + + print("OK: public build/Nightly workflow uses no ROM secret path") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/prepare_freebios_banks.py b/tools/ci/prepare_freebios_banks.py new file mode 100644 index 0000000..7751bd3 --- /dev/null +++ b/tools/ci/prepare_freebios_banks.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Build the redistributable ndsrecomp FreeBIOS native banks for CI/releases.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import subprocess + + +def run(*args: str) -> None: + print("+", " ".join(args), flush=True) + subprocess.run(args, check=True) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + parser.add_argument("--build-dir", type=Path, required=True) + args = parser.parse_args() + + root = args.framework_root.resolve() + build = args.build_dir.resolve() + generated = root / "generated" + freebios = root / "third_party" / "freebios" + arm9_bin = freebios / "drastic_bios_arm9.bin" + arm7_bin = freebios / "drastic_bios_arm7.bin" + arm9_cfg = root / "bios" / "freebios9.toml" + arm7_cfg = root / "bios" / "freebios7.toml" + + for path in (arm9_bin, arm7_bin, arm9_cfg, arm7_cfg): + if not path.is_file(): + raise SystemExit( + f"missing FreeBIOS source {path}; initialize the pinned " + "ndsrecomp third_party/freebios submodule" + ) + + run( + "cmake", + "-S", str(root / "recompiler"), + "-B", str(build), + "-G", "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + ) + run("cmake", "--build", str(build), "--target", "nds_recompile") + + exe = build / ("nds_recompile.exe" if os.name == "nt" else "nds_recompile") + if not exe.is_file(): + raise SystemExit(f"nds_recompile missing after build: {exe}") + + generated.mkdir(parents=True, exist_ok=True) + for cpu, config, image in ( + ("arm9", arm9_cfg, arm9_bin), + ("arm7", arm7_cfg, arm7_bin), + ): + run( + str(exe), + "--config", str(config), + "--bin", str(image), + "--out", str(generated), + "--bank", f"freebios_{cpu}", + ) + + expected = [ + generated / "freebios_arm9.c", + generated / "freebios_arm9_dispatch.c", + generated / "freebios_arm7.c", + generated / "freebios_arm7_dispatch.c", + ] + missing = [str(path) for path in expected if not path.is_file()] + if missing: + raise SystemExit(f"FreeBIOS bank generation incomplete: {missing}") + + print("FreeBIOS banks ready (BSD-2-Clause source path only).") + + +if __name__ == "__main__": + main() diff --git a/tools/ci/verify-nightly-assets.py b/tools/ci/verify-nightly-assets.py new file mode 100644 index 0000000..2d8f341 --- /dev/null +++ b/tools/ci/verify-nightly-assets.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Validate MPH Nightly release assets before publishing them.""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import re +import sys +import zipfile + + +FORBIDDEN_PARTS = { + "biosnds9.rom", + "biosnds7.rom", + "firmware.bin", +} +FORBIDDEN_SUFFIXES = { + ".nds", + ".sav", + ".dsv", +} +FORBIDDEN_DIRS = { + "generated", + "capture", + "captures", + "saves", +} + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def safe_member(name: str) -> bool: + normalized = name.replace("\\", "/") + parts = [part for part in normalized.split("/") if part not in ("", ".")] + if normalized.startswith("/") or any(part == ".." for part in parts): + return False + lowered = [part.lower() for part in parts] + if any(part in FORBIDDEN_PARTS for part in lowered): + return False + if any(part in FORBIDDEN_DIRS for part in lowered): + return False + if parts and Path(parts[-1]).suffix.lower() in FORBIDDEN_SUFFIXES: + return False + return True + + +def verify_windows(path: Path) -> None: + required = { + "MetroidPrimeHuntersRecomp.exe", + "nds_runner.exe", + "game.toml", + "README.md", + "LICENSE", + "bios/README.txt", + "cache/banks/README.txt", + } + with zipfile.ZipFile(path) as archive: + names = { + name.replace("\\", "/").rstrip("/") + for name in archive.namelist() + if name and not name.endswith("/") + } + unsafe = sorted(name for name in names if not safe_member(name)) + if unsafe: + raise SystemExit(f"{path.name}: forbidden/unsafe ZIP entries: {unsafe}") + missing = sorted(required - names) + if missing: + raise SystemExit(f"{path.name}: required release entries missing: {missing}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dist", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--write-sums", action="store_true") + args = parser.parse_args() + + if not re.fullmatch(r"\d+\.\d+\.\d+", args.version): + raise SystemExit(f"invalid version: {args.version!r}") + + expected = { + f"MetroidPrimeHuntersRecomp-windows-x64-v{args.version}.zip", + f"MetroidPrimeHuntersRecomp-linux-v{args.version}-x86_64.AppImage", + } + actual = {p.name for p in args.dist.iterdir() if p.is_file()} + extra = actual - expected + missing = expected - actual + if extra or missing: + raise SystemExit( + f"nightly payload mismatch; missing={sorted(missing)} extra={sorted(extra)}" + ) + + windows = args.dist / f"MetroidPrimeHuntersRecomp-windows-x64-v{args.version}.zip" + linux = args.dist / f"MetroidPrimeHuntersRecomp-linux-v{args.version}-x86_64.AppImage" + if windows.stat().st_size <= 0 or linux.stat().st_size <= 0: + raise SystemExit("nightly payload contains an empty asset") + + verify_windows(windows) + + sums = "\n".join( + f"{sha256(args.dist / name)} {name}" for name in sorted(expected) + ) + "\n" + if args.write_sums: + (args.dist / "SHA256SUMS.txt").write_text(sums, encoding="utf-8") + else: + sys.stdout.write(sums) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/package-linux-appimage.sh b/tools/package-linux-appimage.sh new file mode 100644 index 0000000..f5c9036 --- /dev/null +++ b/tools/package-linux-appimage.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="0.4.0" +MPH_VERSION="US1_0" +RUNNER="" +OUT="$ROOT/release-stage" +APPIMAGE_TOOL="${APPIMAGE_TOOL:-appimagetool}" +LINUXDEPLOY_BIN="${LINUXDEPLOY_BIN:-linuxdeploy}" + +usage() { + cat <<'EOF' +Package an already-built ROM-free MPH runner as a Linux x86_64 AppImage. + +Usage: + tools/package-linux-appimage.sh --runner PATH [options] + +Options: + --version VERSION Package version + --mph-version PROFILE Content profile metadata (default: US1_0) + --runner PATH Built nds_runner executable (required) + --out PATH Output directory (default: release-stage) + --appimage-tool PATH appimagetool executable/AppImage + --linuxdeploy PATH linuxdeploy executable/AppImage +EOF +} + +while (($#)); do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --mph-version) MPH_VERSION="$2"; shift 2 ;; + --runner) RUNNER="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --appimage-tool) APPIMAGE_TOOL="$2"; shift 2 ;; + --linuxdeploy) LINUXDEPLOY_BIN="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ -z "$RUNNER" || ! -x "$RUNNER" ]]; then + printf 'Built runner is required: %s\n' "$RUNNER" >&2 + exit 1 +fi +if [[ ! -x "$APPIMAGE_TOOL" ]] && ! command -v "$APPIMAGE_TOOL" >/dev/null 2>&1; then + printf 'appimagetool not found: %s\n' "$APPIMAGE_TOOL" >&2 + exit 1 +fi +if [[ ! -x "$LINUXDEPLOY_BIN" ]] && ! command -v "$LINUXDEPLOY_BIN" >/dev/null 2>&1; then + printf 'linuxdeploy not found: %s\n' "$LINUXDEPLOY_BIN" >&2 + exit 1 +fi + +PROFILE_FILE="$ROOT/config/mph_rom_profiles.json" +GAME_CONFIG_REL="$(python3 - "$PROFILE_FILE" "$MPH_VERSION" <<'PY' +import json, sys +registry = json.load(open(sys.argv[1], encoding='utf-8')) +profile = registry.get('profiles', {}).get(sys.argv[2]) +if not isinstance(profile, dict): + raise SystemExit(f'unknown MPH profile: {sys.argv[2]}') +print(profile['game_config']) +PY +)" +GAME_CONFIG="$ROOT/$GAME_CONFIG_REL" +[[ -f "$GAME_CONFIG" ]] || { printf 'Game config missing: %s\n' "$GAME_CONFIG" >&2; exit 1; } + +mkdir -p "$OUT" +APP_NAME="MetroidPrimeHuntersRecomp" +APPDIR="$OUT/${APP_NAME}-${MPH_VERSION}-linux-x86_64.AppDir" +rm -rf "$APPDIR" +mkdir -p \ + "$APPDIR/usr/bin/bios" \ + "$APPDIR/usr/share/mph-recomp" \ + "$APPDIR/usr/share/applications" \ + "$APPDIR/usr/share/icons/hicolor/256x256/apps" + +cp "$RUNNER" "$APPDIR/usr/bin/nds_runner" +cp "$GAME_CONFIG" "$APPDIR/usr/bin/game.toml" +cp "$ROOT/README.md" "$APPDIR/usr/bin/README.md" +cp "$ROOT/LICENSE" "$APPDIR/usr/bin/LICENSE" +cp "$ROOT/packaging/BIOS_README.txt" "$APPDIR/usr/bin/bios/README.txt" +cp "$ROOT/packaging/CACHE_README.txt" "$APPDIR/usr/share/mph-recomp/CACHE_README.txt" +chmod 0755 "$APPDIR/usr/bin/nds_runner" + +ICON="$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_NAME.png" +python3 - "$ICON" <<'PY' +import struct, sys, zlib +out = sys.argv[1] +n = 256 +raw = b''.join(bytes([0]) + bytes([162, 62, 64]) * n for _ in range(n)) +def chunk(kind, data): + body = kind + data + return struct.pack('>I', len(data)) + body + struct.pack('>I', zlib.crc32(body) & 0xffffffff) +png = b'\x89PNG\r\n\x1a\n' +png += chunk(b'IHDR', struct.pack('>IIBBBBB', n, n, 8, 2, 0, 0, 0)) +png += chunk(b'IDAT', zlib.compress(raw, 9)) + chunk(b'IEND', b'') +open(out, 'wb').write(png) +PY + +DESKTOP="$APPDIR/usr/share/applications/$APP_NAME.desktop" +cat > "$DESKTOP" </dev/null + +cat > "$APPDIR/AppRun" <<'EOF' +#!/bin/sh +set -eu +HERE="$(dirname "$(readlink -f "$0")")" +export LD_LIBRARY_PATH="$HERE/usr/lib:${LD_LIBRARY_PATH:-}" +export SDL_JOYSTICK_HIDAPI_STEAM=1 +export SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=1 +SELF="${APPIMAGE:-$0}" +RUNDIR="$(dirname "$(readlink -f "$SELF")")" +mkdir -p "$RUNDIR/bios" 2>/dev/null || true +if [ ! -f "$RUNDIR/bios/README.txt" ] && [ -f "$HERE/usr/bin/bios/README.txt" ]; then + cp "$HERE/usr/bin/bios/README.txt" "$RUNDIR/bios/README.txt" 2>/dev/null || true +fi + +# Portable-first optimization cache. A future local JIT/portable-bank backend +# namespaces children by whole-ROM content SHA-1; the hash is cache identity, +# never the runtime base-profile selector. If the AppImage directory cannot be +# written, fall back to the standard XDG cache location. +PORTABLE_CACHE="$RUNDIR/cache/banks" +CACHE_ROOT="$PORTABLE_CACHE" +if mkdir -p "$PORTABLE_CACHE" 2>/dev/null && + : > "$PORTABLE_CACHE/.mph-write-test" 2>/dev/null; then + rm -f "$PORTABLE_CACHE/.mph-write-test" +else + CACHE_BASE="${XDG_CACHE_HOME:-${HOME:-$RUNDIR}/.cache}" + CACHE_ROOT="$CACHE_BASE/MetroidPrimeHuntersRecomp/banks" + mkdir -p "$CACHE_ROOT" +fi +if [ ! -f "$CACHE_ROOT/README.txt" ] && [ -f "$HERE/usr/share/mph-recomp/CACHE_README.txt" ]; then + cp "$HERE/usr/share/mph-recomp/CACHE_README.txt" "$CACHE_ROOT/README.txt" 2>/dev/null || true +fi +export MPH_BANK_CACHE_ROOT="$CACHE_ROOT" + +ROM="" +for f in "$RUNDIR"/*.nds "$RUNDIR"/*.NDS; do + [ -e "$f" ] && ROM="$f" && break +done +cd "$RUNDIR" 2>/dev/null || true +if [ "$#" -eq 0 ]; then + if [ -n "$ROM" ]; then + exec "$HERE/usr/bin/nds_runner" "$RUNDIR/bios" --interactive --rom "$ROM" \ + --config "$HERE/usr/bin/game.toml" --screen-layout separate \ + --adaptive-widescreen top --startup-mode automatic \ + --freebios --generated-firmware --boot direct + fi + exec "$HERE/usr/bin/nds_runner" "$RUNDIR/bios" --interactive \ + --config "$HERE/usr/bin/game.toml" --screen-layout separate \ + --adaptive-widescreen top --startup-mode automatic \ + --freebios --generated-firmware --boot direct +fi +exec "$HERE/usr/bin/nds_runner" "$@" +EOF +chmod 0755 "$APPDIR/AppRun" + +# Safety gate: the AppDir may contain only runner/package support material. +if find "$APPDIR" -type f \( \ + -iname '*.nds' -o -iname '*.sav' -o -iname '*.dsv' -o \ + -iname 'biosnds9.rom' -o -iname 'biosnds7.rom' -o -iname 'firmware.bin' \ + \) -print -quit | grep -q .; then + echo 'Refusing to package ROM/save/BIOS/firmware material.' >&2 + exit 1 +fi + +if [[ "$MPH_VERSION" == "US1_0" ]]; then + OUTPUT="$OUT/${APP_NAME}-linux-v${VERSION}-x86_64.AppImage" +else + OUTPUT="$OUT/${APP_NAME}-${MPH_VERSION}-linux-v${VERSION}-x86_64.AppImage" +fi +rm -f "$OUTPUT" +ARCH=x86_64 "$APPIMAGE_TOOL" --appimage-extract-and-run "$APPDIR" "$OUTPUT" >/dev/null +chmod 0755 "$OUTPUT" +test -s "$OUTPUT" +sha256sum "$OUTPUT" +printf 'Created %s\n' "$OUTPUT" diff --git a/tools/package-windows-nightly.ps1 b/tools/package-windows-nightly.ps1 new file mode 100644 index 0000000..c223753 --- /dev/null +++ b/tools/package-windows-nightly.ps1 @@ -0,0 +1,125 @@ +<# +Package a ROM-free Metroid Prime Hunters Recomp Windows Nightly. + +Unlike tools/make_release.ps1, this packager intentionally does not require a +ROM-derived MPH/FMV native bank. The title executes through Tier-3 when no +content-specific optimization bank exists. FreeBIOS native banks are built from +the redistributable BSD-2-Clause FreeBIOS source path. +#> +param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$RunnerBuildDir, + [Parameter(Mandatory = $true)][string]$LauncherBuildDir, + [Parameter(Mandatory = $true)][string]$RuntimeBinDir, + [string]$OutputDir = 'release-stage' +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$runnerBuild = [IO.Path]::GetFullPath((Join-Path $root $RunnerBuildDir)) +$launcherBuild = [IO.Path]::GetFullPath((Join-Path $root $LauncherBuildDir)) +$runtimeBin = [IO.Path]::GetFullPath($RuntimeBinDir) +$runner = Join-Path $runnerBuild 'nds_runner.exe' +$launcher = Join-Path $launcherBuild 'mph-recomp-ui.exe' +$assets = Join-Path $launcherBuild 'assets' + +foreach ($required in @($runner, $launcher, $assets)) { + if (-not (Test-Path -LiteralPath $required)) { + throw "Nightly input missing: $required" + } +} + +$projectText = Get-Content (Join-Path $root 'CMakeLists.txt') -Raw +if ($projectText -notmatch + "project\(MetroidPrimeHuntersRecomp VERSION $([regex]::Escape($Version)) ") { + throw "CMake project version does not match Nightly package $Version." +} + +# A ROM-free Nightly must not accidentally link a title-specific generated +# bank. The symbols are intentionally left visible in MinGW builds; reject the +# known MPH bank identities if they ever leak back into this package path. +$runnerText = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($runner)) +foreach ($forbiddenBank in @('g_dispatch_mph_arm9', 'g_dispatch_mph_arm7', + 'mph_arm9_fmv_runtime')) { + if ($runnerText.Contains($forbiddenBank)) { + throw "ROM-free Nightly unexpectedly contains title bank: $forbiddenBank" + } +} + +$out = [IO.Path]::GetFullPath((Join-Path $root $OutputDir)) +$stageName = "MetroidPrimeHuntersRecomp-windows-x64-v$Version" +$stage = Join-Path $out $stageName +$zip = Join-Path $out "$stageName.zip" + +if (Test-Path -LiteralPath $stage) { Remove-Item $stage -Recurse -Force } +if (Test-Path -LiteralPath $zip) { Remove-Item $zip -Force } +New-Item -ItemType Directory -Path $stage -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $stage 'bios') -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $stage 'cache\banks') -Force | Out-Null + +Copy-Item -LiteralPath $launcher -Destination (Join-Path $stage 'MetroidPrimeHuntersRecomp.exe') +Copy-Item -LiteralPath $runner -Destination $stage +Copy-Item -LiteralPath $assets -Destination $stage -Recurse +Copy-Item -LiteralPath (Join-Path $root 'game.toml') -Destination $stage +Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $stage +Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $stage +Copy-Item -LiteralPath (Join-Path $root 'packaging\BIOS_README.txt') ` + -Destination (Join-Path $stage 'bios\README.txt') +Copy-Item -LiteralPath (Join-Path $root 'packaging\CACHE_README.txt') ` + -Destination (Join-Path $stage 'cache\banks\README.txt') + +$runtimeDlls = @( + 'SDL2.dll', + 'libgcc_s_seh-1.dll', + 'libstdc++-6.dll', + 'libwinpthread-1.dll' +) +foreach ($name in $runtimeDlls) { + $source = Join-Path $runtimeBin $name + if (-not (Test-Path -LiteralPath $source)) { + throw "Required MinGW runtime DLL missing: $source" + } + Copy-Item -LiteralPath $source -Destination $stage +} + +$forbidden = @(Get-ChildItem -LiteralPath $stage -File -Recurse | + Where-Object { + $_.Extension.ToLowerInvariant() -in @('.nds', '.sav', '.dsv', '.gpr') -or + $_.Name.ToLowerInvariant() -in @('biosnds9.rom', 'biosnds7.rom', 'firmware.bin') -or + $_.FullName -match '[\\/](generated|capture|captures|saves)[\\/]' + }) +if ($forbidden.Count -ne 0) { + throw "Nightly stage contains forbidden material: $($forbidden.FullName -join ', ')" +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$stageFull = [IO.Path]::GetFullPath($stage) +$stagePrefix = $stageFull.TrimEnd('\') + '\' +$files = @(Get-ChildItem -LiteralPath $stage -File -Recurse | Sort-Object FullName) +$archive = [IO.Compression.ZipFile]::Open( + $zip, [IO.Compression.ZipArchiveMode]::Create) +try { + foreach ($file in $files) { + $fileFull = [IO.Path]::GetFullPath($file.FullName) + if (-not $fileFull.StartsWith($stagePrefix, + [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to archive a file outside release stage: $fileFull" + } + $entryName = $fileFull.Substring($stagePrefix.Length).Replace('\', '/') + if ($entryName.StartsWith('/') -or $entryName -match '(^|/)\.\.(/|$)') { + throw "Unsafe ZIP entry name: $entryName" + } + [IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, $fileFull, $entryName, + [IO.Compression.CompressionLevel]::Optimal) | Out-Null + } +} finally { + $archive.Dispose() +} + +if (-not (Test-Path -LiteralPath $zip) -or (Get-Item $zip).Length -eq 0) { + throw 'Nightly ZIP was not created.' +} +Get-FileHash -LiteralPath $zip -Algorithm SHA256 | Format-Table -AutoSize +Write-Host "Created $zip" diff --git a/tools/patch_ndsrecomp_mph_diagnostics.py b/tools/patch_ndsrecomp_mph_diagnostics.py new file mode 100644 index 0000000..fe22c30 --- /dev/null +++ b/tools/patch_ndsrecomp_mph_diagnostics.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Add end-user MPH startup diagnostics to the pinned ndsrecomp runner. + +This layer intentionally runs after patch_ndsrecomp_mph_runtime_core.py: + +* interactive launches write stderr to MetroidPrimeHuntersRecomp.log beside + nds_runner, so CREATE_NO_WINDOW launcher starts still leave a useful trace; +* runtime-profile selection reports gameCode/revision/executable CRC32, + authoritative-vs-header-fallback source, selected profile and host-write + safety state; +* ROM-free builds treat a successfully selected MPH runtime profile as the + compatibility authority instead of re-applying the legacy US1.0 whole-ROM + SHA-1 gate. Native/title-bank builds keep the existing exact-content policy. + +Whole-ROM SHA-1 remains useful content/cache identity; it is not used as the +base-version detector in the ROM-free Tier-3 path. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +def replace_once(path: Path, old: str, new: str, marker: str) -> None: + text = path.read_text(encoding="utf-8") + if marker in text: + return + count = text.count(old) + if count != 1: + raise SystemExit( + f"{path}: expected exactly one source anchor for {marker!r}, got {count}" + ) + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + parser.add_argument("--profiles", type=Path, required=False) + args = parser.parse_args() + root = args.framework_root.resolve() + + title_cpp = root / "runner" / "src" / "title_patches.cpp" + main_cpp = root / "runner" / "src" / "main.cpp" + for path in (title_cpp, main_cpp): + if not path.is_file(): + raise SystemExit(f"missing pinned ndsrecomp source: {path}") + + # The launcher intentionally creates no console window. Redirecting stderr + # from inside the runner is therefore more reliable than relying on the + # parent's inherited standard handles. Only the normal --interactive UI + # path gets the persistent file; CLI/scenario runs keep stderr untouched. + replace_once( + main_cpp, + "int main(int argc, char** argv) {\n" + " // Wiimmfi: Winsock (Windows only) MUST be initialized before ANY\n", + "int main(int argc, char** argv) {\n" + " // MPH_DIAGNOSTIC_LOG: keep the latest interactive-run log beside\n" + " // nds_runner.exe/AppImage payload so a no-console startup failure is\n" + " // still diagnosable by the player. CLI/scenario stderr is unchanged.\n" + " bool mph_interactive_log = false;\n" + " for (int i = 1; i < argc; ++i) {\n" + " if (argv[i] && std::strcmp(argv[i], \"--interactive\") == 0) {\n" + " mph_interactive_log = true;\n" + " break;\n" + " }\n" + " }\n" + " if (mph_interactive_log) {\n" + " try {\n" + " const std::filesystem::path log_path =\n" + " std::filesystem::weakly_canonical(\n" + " std::filesystem::absolute(argv[0])).parent_path() /\n" + " \"MetroidPrimeHuntersRecomp.log\";\n" + "#if defined(_WIN32)\n" + " FILE* mph_log = _wfreopen(log_path.wstring().c_str(), L\"w\", stderr);\n" + "#else\n" + " FILE* mph_log = std::freopen(log_path.string().c_str(), \"w\", stderr);\n" + "#endif\n" + " if (mph_log) {\n" + " std::setvbuf(stderr, nullptr, _IONBF, 0);\n" + " std::fprintf(stderr,\n" + " \"=== Metroid Prime Hunters Recomp diagnostic log ===\\n\"\n" + " \"[startup] interactive runner started\\n\");\n" + " }\n" + " } catch (...) {\n" + " // Logging must never make a previously launchable build fail.\n" + " }\n" + " }\n\n" + " // Wiimmfi: Winsock (Windows only) MUST be initialized before ANY\n", + "MPH_DIAGNOSTIC_LOG", + ) + + # Turn the previously ignored selector result into an explicit policy + # signal. The selector itself still performs executable/header validation. + replace_once( + main_cpp, + " nds_title_patches_select_mph_runtime_profile(\n" + " rom.data(), static_cast(rom.size()), rom_sha1.c_str(),\n" + " frontend_options.expected_rom_sha1.c_str());\n", + " const bool mph_runtime_profile_selected =\n" + " nds_title_patches_select_mph_runtime_profile(\n" + " rom.data(), static_cast(rom.size()),\n" + " rom_sha1.c_str(),\n" + " frontend_options.expected_rom_sha1.c_str());\n", + "mph_runtime_profile_selected =", + ) + + # NDS_RETAIL_BIOS_INTERPRETER is defined only by the public ROM-free build + # policy when proprietary retail BIOS banks are not linked. That is also + # the build with no MPH native title bank, so Tier-3 + the seven-version + # runtime detector is the correct authority. Optimized/native-bank builds + # deliberately retain the stricter content-SHA policy below. + replace_once( + main_cpp, + " if (!frontend_options.expected_rom_sha1.empty() &&\n" + " rom_sha1 != frontend_options.expected_rom_sha1 &&\n" + " !nds_title_patches_mph_allows_rom_sha1_mismatch()) {\n", + "#if defined(NDS_RETAIL_BIOS_INTERPRETER)\n" + " // MPH_ROMFREE_MULTIROM_GATE: public Nightly has no ROM-derived\n" + " // title bank, so a successfully selected runtime base profile\n" + " // is authoritative. This is what permits US1.1/EU/JP/KR and\n" + " // compatible modified ROMs to reach Tier-3 execution.\n" + " if (!frontend_options.expected_rom_sha1.empty() &&\n" + " rom_sha1 != frontend_options.expected_rom_sha1 &&\n" + " !mph_runtime_profile_selected) {\n" + "#else\n" + " if (!frontend_options.expected_rom_sha1.empty() &&\n" + " rom_sha1 != frontend_options.expected_rom_sha1 &&\n" + " !nds_title_patches_mph_allows_rom_sha1_mismatch()) {\n" + "#endif\n", + "MPH_ROMFREE_MULTIROM_GATE", + ) + + # Give every rejection enough context to distinguish a malformed ROM from + # a supported version, a known modified executable, or a header-only mod. + replace_once( + title_cpp, + " uint32_t checksum = 0;\n" + " if (!mph_compute_executable_checksum(rom_data, rom_size, &checksum))\n" + " return false;\n", + " uint32_t checksum = 0;\n" + " if (!mph_compute_executable_checksum(rom_data, rom_size, &checksum)) {\n" + " std::fprintf(stderr,\n" + " \"[mph] runtime detector: invalid ROM executable ranges\"\n" + " \" (size=%llu)\\n\",\n" + " static_cast(rom_size));\n" + " return false;\n" + " }\n" + " std::fprintf(stderr,\n" + " \"[mph] identity: gameCode=%.4s revision=%u \"\n" + " \"execCRC32=0x%08X\\n\",\n" + " reinterpret_cast(rom_data + 0x0Cu),\n" + " static_cast(rom_data[0x1Eu]), checksum);\n", + "[mph] identity: gameCode=", + ) + + replace_once( + title_cpp, + " if (!profile) return false;\n\n" + " // A known clean whole-ROM hash can only describe its own base profile.\n", + " if (!profile) {\n" + " std::fprintf(stderr,\n" + " \"[mph] runtime detector: unsupported/ambiguous ROM \"\n" + " \"(execCRC32=0x%08X)\\n\", checksum);\n" + " return false;\n" + " }\n\n" + " // A known clean whole-ROM hash can only describe its own base profile.\n", + "unsupported/ambiguous ROM", + ) + + replace_once( + title_cpp, + " const NdsMphRuntimeProfile* actual_clean = mph_find_clean_sha1(rom_sha1);\n" + " if (actual_clean && actual_clean != profile) return false;\n", + " const NdsMphRuntimeProfile* actual_clean = mph_find_clean_sha1(rom_sha1);\n" + " if (actual_clean && actual_clean != profile) {\n" + " std::fprintf(stderr,\n" + " \"[mph] runtime detector: clean SHA/profile conflict \"\n" + " \"shaProfile=%s detectedProfile=%s\\n\",\n" + " actual_clean->key, profile->key);\n" + " return false;\n" + " }\n", + "clean SHA/profile conflict", + ) + + replace_once( + title_cpp, + " g_mph_allow_rom_sha1_mismatch =\n" + " std::strcmp(rom_sha1, expected_rom_sha1) != 0 &&\n" + " expected_clean == profile && checksum == profile->base_checksum;\n" + " return true;\n", + " g_mph_allow_rom_sha1_mismatch =\n" + " std::strcmp(rom_sha1, expected_rom_sha1) != 0 &&\n" + " expected_clean == profile && checksum == profile->base_checksum;\n" + " std::fprintf(stderr,\n" + " \"[mph] runtime profile: %s detector=%s variant=%s \"\n" + " \"hostWrites=%s legacyShaReuse=%s\\n\",\n" + " profile->key,\n" + " checksum_hit ? \"executable-checksum\" : \"header-fallback\",\n" + " checksum_hit ? checksum_hit->name : \"unknown-mod\",\n" + " g_mph_host_writes_compatible ? \"enabled\" : \"disabled\",\n" + " g_mph_allow_rom_sha1_mismatch ? \"yes\" : \"no\");\n" + " return true;\n", + "[mph] runtime profile:", + ) + + print("Patched MPH runtime diagnostics and ROM-free multi-ROM content gate") + + +if __name__ == "__main__": + main() diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py index 2bc0249..4034b1d 100755 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -3,8 +3,9 @@ The core detector is kept separately so upstream-facing additions can be layered without weakening its whole-ROM/content identity rules. The later stages add -the melonPrimeDS/mphCodex profile-aware 21:9 projection/culling patch and make -that patch re-eligible after an in-process guest reset. +the melonPrimeDS/mphCodex profile-aware 21:9 projection/culling patch, make +that patch re-eligible after an in-process guest reset, and finally add +end-user startup diagnostics plus the ROM-free multi-ROM content-gate policy. """ from __future__ import annotations @@ -21,6 +22,7 @@ def main() -> None: here / "patch_ndsrecomp_mph_runtime_core.py", here / "patch_ndsrecomp_mph_widescreen.py", here / "patch_ndsrecomp_mph_widescreen_reset.py", + here / "patch_ndsrecomp_mph_diagnostics.py", ): subprocess.run([sys.executable, str(script), *args], check=True) diff --git a/tools/patch_ndsrecomp_rom_free_release.py b/tools/patch_ndsrecomp_rom_free_release.py new file mode 100644 index 0000000..256a063 --- /dev/null +++ b/tools/patch_ndsrecomp_rom_free_release.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Make the pinned ndsrecomp runner buildable without proprietary BIOS banks. + +The public/no-dump build keeps the BSD-licensed FreeBIOS banks native. Retail +BIOS dumps remain usable when a user supplies them, but their immutable BIOS +code executes through the existing reference interpreter instead of requiring +ROM/BIOS-derived generated C in the distributed build. + +This patch is intentionally separate from the MPH title-profile patch stack: +it changes only the shared runner's immutable-BIOS build policy and is useful +for ROM-free CI/release packaging. It is idempotent and pinned to the currently +expected ndsrecomp source shape; drift fails closed. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +def replace_once(path: Path, old: str, new: str, marker: str) -> None: + text = path.read_text(encoding="utf-8") + # The replacement may intentionally contain the original anchor (for + # example, adding one declaration immediately after an existing one). + # Therefore exact replacement text, not marker/anchor absence, is the + # reliable idempotency test. + if new in text: + return + count = text.count(old) + if count != 1: + raise SystemExit( + f"{path}: expected exactly one source anchor for {marker!r}, got {count}" + ) + path.write_text(text.replace(old, new), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + args = parser.parse_args() + root = args.framework_root.resolve() + + cmake = root / "runner" / "CMakeLists.txt" + state = root / "runner" / "src" / "state.h" + runtime = root / "runner" / "src" / "runtime_arm.cpp" + tier3 = root / "runner" / "src" / "tier3.cpp" + main_cpp = root / "runner" / "src" / "main.cpp" + for path in (cmake, state, runtime, tier3, main_cpp): + if not path.is_file(): + raise SystemExit(f"missing pinned ndsrecomp source: {path}") + + replace_once( + cmake, + '''option(NDS_BOOTSTRAP_FIRMWARE\n "Build with BIOS banks only so guest-produced firmware RAM can be captured"\n OFF)\n''', + '''option(NDS_BOOTSTRAP_FIRMWARE\n "Build with BIOS banks only so guest-produced firmware RAM can be captured"\n OFF)\noption(NDS_RETAIL_BIOS_BANKS\n "Link generated proprietary retail-BIOS banks instead of interpreter fallback"\n ON)\n''', + "NDS_RETAIL_BIOS_BANKS", + ) + + replace_once( + cmake, + '''add_library(nds_banks STATIC\n ${GEN}/arm9_bios.c\n ${GEN}/arm9_bios_dispatch.c\n ${GEN}/arm7_bios.c\n ${GEN}/arm7_bios_dispatch.c\n ${GEN}/freebios_arm9.c\n ${GEN}/freebios_arm9_dispatch.c\n ${GEN}/freebios_arm7.c\n ${GEN}/freebios_arm7_dispatch.c\n ${FW_BANK_BODIES}\n ${FW_BANK_DISPATCH}\n ${SM64DS_BANK_SOURCES}\n ${TITLE_BANK_SOURCES})\n''', + '''# Public/no-dump builds need only the redistributable FreeBIOS static banks.\n# Retail BIOS dumps can still be supplied at runtime when NDS_RETAIL_BIOS_BANKS=OFF;\n# their immutable code then uses the reference interpreter instead of generated C.\nset(IMMUTABLE_BIOS_BANK_SOURCES\n ${GEN}/freebios_arm9.c\n ${GEN}/freebios_arm9_dispatch.c\n ${GEN}/freebios_arm7.c\n ${GEN}/freebios_arm7_dispatch.c)\nif(NDS_RETAIL_BIOS_BANKS)\n list(APPEND IMMUTABLE_BIOS_BANK_SOURCES\n ${GEN}/arm9_bios.c\n ${GEN}/arm9_bios_dispatch.c\n ${GEN}/arm7_bios.c\n ${GEN}/arm7_bios_dispatch.c)\nelse()\n add_compile_definitions(NDS_RETAIL_BIOS_INTERPRETER=1)\nendif()\nadd_library(nds_banks STATIC\n ${IMMUTABLE_BIOS_BANK_SOURCES}\n ${FW_BANK_BODIES}\n ${FW_BANK_DISPATCH}\n ${SM64DS_BANK_SOURCES}\n ${TITLE_BANK_SOURCES})\n''', + "IMMUTABLE_BIOS_BANK_SOURCES", + ) + + replace_once( + cmake, + '''set_source_files_properties(\n ${GEN}/arm9_bios.c ${GEN}/arm9_bios_dispatch.c\n ${GEN}/arm7_bios.c ${GEN}/arm7_bios_dispatch.c\n ${GEN}/freebios_arm9.c ${GEN}/freebios_arm9_dispatch.c\n ${GEN}/freebios_arm7.c ${GEN}/freebios_arm7_dispatch.c\n''', + '''set_source_files_properties(\n ${IMMUTABLE_BIOS_BANK_SOURCES}\n''', + "set_source_files_properties(\n ${IMMUTABLE_BIOS_BANK_SOURCES}", + ) + + replace_once( + cmake, + '''set(ARM9_BANK_SOURCES ${GEN}/arm9_bios.c ${GEN}/arm9_bios_dispatch.c\n ${GEN}/freebios_arm9.c ${GEN}/freebios_arm9_dispatch.c)\nset(ARM7_BANK_SOURCES ${GEN}/arm7_bios.c ${GEN}/arm7_bios_dispatch.c\n ${GEN}/freebios_arm7.c ${GEN}/freebios_arm7_dispatch.c)\n''', + '''set(ARM9_BANK_SOURCES\n ${GEN}/freebios_arm9.c ${GEN}/freebios_arm9_dispatch.c)\nset(ARM7_BANK_SOURCES\n ${GEN}/freebios_arm7.c ${GEN}/freebios_arm7_dispatch.c)\nif(NDS_RETAIL_BIOS_BANKS)\n list(APPEND ARM9_BANK_SOURCES\n ${GEN}/arm9_bios.c ${GEN}/arm9_bios_dispatch.c)\n list(APPEND ARM7_BANK_SOURCES\n ${GEN}/arm7_bios.c ${GEN}/arm7_bios_dispatch.c)\nendif()\n''', + "if(NDS_RETAIL_BIOS_BANKS)\n list(APPEND ARM9_BANK_SOURCES", + ) + + replace_once( + state, + '''extern bool g_discover_static_misses;\n''', + '''extern bool g_discover_static_misses;\n// Public ROM-free builds do not carry generated retail-BIOS code. When a\n// user explicitly supplies retail dumps, allow only immutable BIOS addresses\n// to use the same reference interpreter used by coverage discovery.\nextern bool g_allow_static_bios_interpreter;\n''', + "g_allow_static_bios_interpreter", + ) + + replace_once( + runtime, + '''bool g_discover_static_misses = false;\n''', + '''bool g_discover_static_misses = false;\nbool g_allow_static_bios_interpreter = false;\n''', + "g_allow_static_bios_interpreter = false", + ) + + replace_once( + runtime, + ''' if (g_discover_static_misses && static_bios_pc(pc)) {\n runtime_discovery_note_static(pc, thumb ? 1u : 0u);\n tier3_run(pc);\n return;\n }\n''', + ''' if ((g_discover_static_misses || g_allow_static_bios_interpreter) &&\n static_bios_pc(pc)) {\n if (g_discover_static_misses)\n runtime_discovery_note_static(pc, thumb ? 1u : 0u);\n tier3_run(pc);\n return;\n }\n''', + "g_allow_static_bios_interpreter) &&", + ) + + replace_once( + tier3, + ''' if (!bus_range_has_write_provenance(fetch_addr, fetch_size) &&\n !(g_discover_static_misses && static_bios_pc(pc & ~1u))) {\n''', + ''' if (!bus_range_has_write_provenance(fetch_addr, fetch_size) &&\n !((g_discover_static_misses || g_allow_static_bios_interpreter) &&\n static_bios_pc(pc & ~1u))) {\n''', + "g_allow_static_bios_interpreter) &&", + ) + + replace_once( + main_cpp, + '''extern "C" const DispatchEntry g_dispatch_arm9_bios[];\nextern "C" const unsigned g_dispatch_arm9_bios_len;\nextern "C" const DispatchEntry g_dispatch_arm7_bios[];\nextern "C" const unsigned g_dispatch_arm7_bios_len;\n''', + '''#if !defined(NDS_RETAIL_BIOS_INTERPRETER)\nextern "C" const DispatchEntry g_dispatch_arm9_bios[];\nextern "C" const unsigned g_dispatch_arm9_bios_len;\nextern "C" const DispatchEntry g_dispatch_arm7_bios[];\nextern "C" const unsigned g_dispatch_arm7_bios_len;\n#endif\n''', + "#if !defined(NDS_RETAIL_BIOS_INTERPRETER)", + ) + + replace_once( + main_cpp, + ''' } else {\n nds_register_dispatch(NDS_ARM9, g_dispatch_arm9_bios,\n g_dispatch_arm9_bios_len, 0xFFFF0000u);\n nds_register_dispatch(NDS_ARM7, g_dispatch_arm7_bios,\n g_dispatch_arm7_bios_len, 0x00000000u);\n }\n''', + ''' } else {\n#if defined(NDS_RETAIL_BIOS_INTERPRETER)\n g_allow_static_bios_interpreter = true;\n std::fprintf(stderr,\n "[dispatch] retail BIOS uses reference interpreter "\n "(ROM-free build)\\n");\n#else\n nds_register_dispatch(NDS_ARM9, g_dispatch_arm9_bios,\n g_dispatch_arm9_bios_len, 0xFFFF0000u);\n nds_register_dispatch(NDS_ARM7, g_dispatch_arm7_bios,\n g_dispatch_arm7_bios_len, 0x00000000u);\n#endif\n }\n''', + "retail BIOS uses reference interpreter", + ) + + print(f"Patched ROM-free release support in {root}") + + +if __name__ == "__main__": + main() diff --git a/tools/patch_recomp_ui_mph_multirom.py b/tools/patch_recomp_ui_mph_multirom.py new file mode 100644 index 0000000..20e310b --- /dev/null +++ b/tools/patch_recomp_ui_mph_multirom.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Patch pinned recomp-ui to render delegated MPH ROM validation honestly. + +MPH runtime compatibility is not a whole-ROM SHA-1 gate. When GameInfo omits +all cartridge fingerprints, the stock recomp-ui model deliberately cannot call +the ROM "verified" and the ImGui dashboard therefore renders "ROM not +recognized" even though launcher_model_can_play() correctly allows the host to +perform its own launch-time validation. + +For this project, a fingerprint-free cartridge means exactly that: acceptance +is delegated to nds_runner's MPH executable-compatible detector. This patch +changes only that dashboard presentation. Fingerprinted games keep stock +verified/not-recognized semantics, and the runner remains the authoritative +fail-closed validator. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +OLD = ''' const bool verified = launcher_model_rom_verified(m);\n char line[64];\n if (!m->rom_present) snprintf(line, sizeof(line), "No %s loaded", noun);\n else if (verified) snprintf(line, sizeof(line), "%s verified", noun);\n else snprintf(line, sizeof(line), "%s not recognized", noun);\n float w = ImGui::GetTextLineHeight() + px(6) + ImGui::CalcTextSize(line).x;\n ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (availw - w) * 0.5f);\n state_mark(verified, th);\n ImGui::SameLine(0, px(6));\n ImGui::TextColored(verified ? col(th.good) : col(th.warn), "%s", line);''' + +NEW = ''' const bool verified = launcher_model_rom_verified(m);\n // MPH_MULTIROM_DELEGATED_VERIFY: no generic fingerprint means the host\n // intentionally delegates compatibility to its runtime detector. The\n // model marks any non-empty initial path as rom_present before opening\n // it, so require a successfully measured file as well; a missing\n // conventional default filename must never appear as selected.\n const bool readable = m->rom_present && std::strcmp(m->rom_size, "--") != 0;\n const bool delegated = readable && !m->has_expected_crc &&\n m->num_known_sha256 == 0 &&\n m->num_known_sha1 == 0;\n const bool accepted = verified || delegated;\n char line[96];\n if (!m->rom_present) snprintf(line, sizeof(line), "No %s loaded", noun);\n else if (!readable) snprintf(line, sizeof(line), "No %s loaded", noun);\n else if (verified) snprintf(line, sizeof(line), "%s verified", noun);\n else if (delegated) snprintf(line, sizeof(line), "%s selected - runtime validation", noun);\n else snprintf(line, sizeof(line), "%s not recognized", noun);\n float w = ImGui::GetTextLineHeight() + px(6) + ImGui::CalcTextSize(line).x;\n ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (availw - w) * 0.5f);\n state_mark(accepted, th);\n ImGui::SameLine(0, px(6));\n ImGui::TextColored(accepted ? col(th.good) : col(th.warn), "%s", line);''' + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--recomp-ui-root", type=Path, required=True) + args = parser.parse_args() + + root = args.recomp_ui_root.resolve() + path = root / "src" / "common" / "backends" / "imgui" / "launcher_imgui.cpp" + if not path.is_file(): + raise SystemExit(f"missing pinned recomp-ui source: {path}") + + text = path.read_text(encoding="utf-8-sig") + if NEW in text: + print(f"recomp-ui MPH multi-ROM presentation already patched: {path}") + return + count = text.count(OLD) + if count != 1: + raise SystemExit( + f"{path}: expected exactly one launcher ROM-verdict anchor, got {count}; " + "recomp-ui pin/source shape drifted" + ) + path.write_text(text.replace(OLD, NEW), encoding="utf-8") + print(f"Patched delegated MPH ROM validation presentation in {path}") + + +if __name__ == "__main__": + main()