diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml new file mode 100644 index 0000000..4df8af0 --- /dev/null +++ b/.github/workflows/mph-multirom-static.yml @@ -0,0 +1,274 @@ +name: MPH Multi-ROM Static Checks + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + static-multirom: + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check script syntax + shell: bash + run: | + set -euo pipefail + python -m py_compile \ + tools/mph_profile.py \ + tools/prepare_mph.py \ + tools/check_mph_multirom_profiles.py \ + tools/check_mph_multirom_profiles_legacy.py \ + tools/check_melonprime_detector.py \ + tools/patch_ndsrecomp_mph_runtime.py \ + tools/patch_ndsrecomp_mph_runtime_core.py \ + tools/patch_ndsrecomp_mph_widescreen.py \ + tools/patch_ndsrecomp_mph_widescreen_reset.py \ + tools/promote_mph_static_coverage.py \ + tools/promote_mph_runtime_coverage.py \ + tools/probe_mph_wfc.py \ + tools/probe_mph_online_first_run.py + bash -n tools/build-linux.sh + pwsh -NoProfile -Command ' + $failed = $false + foreach ($path in @("tools/build-windows.ps1", "tools/make_release.ps1")) { + $tokens = $null; $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $path), [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { $failed = $true; Write-Error ($errors | Out-String) } + } + if ($failed) { exit 1 } + ' + + - name: Verify upstream launcher and QA sync + shell: bash + run: | + set -euo pipefail + base=https://raw.githubusercontent.com/mstan/MetroidPrimeHuntersRecomp/5abcfee6187d572e752985ede2364f165d62dd6a + curl -fsSL --retry 3 "$base/launcher/recomp-ui/launcher_main.cpp" -o /tmp/upstream-launcher.cpp + curl -fsSL --retry 3 "$base/launcher/recomp-ui/tests/launcher_mod_provider_test.cpp" -o /tmp/upstream-launcher-test.cpp + curl -fsSL --retry 3 "$base/tools/mph_screens.py" -o /tmp/upstream-mph-screens.py + curl -fsSL --retry 3 "$base/tools/probe_mph_wfc.py" -o /tmp/upstream-probe-wfc.py + curl -fsSL --retry 3 "$base/tools/probe_mph_online_first_run.py" -o /tmp/upstream-probe-first-run.py + cmp launcher/recomp-ui/launcher_main.cpp /tmp/upstream-launcher.cpp + cmp launcher/recomp-ui/tests/launcher_mod_provider_test.cpp /tmp/upstream-launcher-test.cpp + cmp tools/mph_screens.py /tmp/upstream-mph-screens.py + cmp tools/probe_mph_wfc.py /tmp/upstream-probe-wfc.py + cmp tools/probe_mph_online_first_run.py /tmp/upstream-probe-first-run.py + grep -q 'copy_text(output->id, "hd-rendering")' launcher/recomp-ui/launcher_main.cpp + grep -q -- '--firmware-state-path' launcher/recomp-ui/launcher_main.cpp + grep -q 'feature_count == 3' launcher/recomp-ui/tests/launcher_mod_provider_test.cpp + + - name: Cross-check melonPrimeDS detector and widescreen table + shell: bash + run: | + set -euo pipefail + curl -fsSL --retry 3 \ + https://raw.githubusercontent.com/ag-advania/melonPrimeDS/develop_hud/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h \ + -o /tmp/MelonPrimeGameRomAddrTable.h + curl -fsSL --retry 3 \ + https://raw.githubusercontent.com/ag-advania/melonPrimeDS/develop_hud/src/frontend/qt_sdl/MelonPrimeGameRomDetect.cpp \ + -o /tmp/MelonPrimeGameRomDetect.cpp + python tools/check_mph_multirom_profiles.py \ + --melonprime-table /tmp/MelonPrimeGameRomAddrTable.h + python tools/check_melonprime_detector.py \ + --profiles config/mph_rom_profiles.json \ + --detector /tmp/MelonPrimeGameRomDetect.cpp + python - <<'PY' + import json + p=json.load(open('config/mph_rom_profiles.json', encoding='utf-8')) + assert p['schema'] == 5 + assert len(p['runtime_profiles']) == 7 + assert len(p['runtime_checksums']) == 17 + assert p['profiles']['US1_0']['adaptive_widescreen'] is True + assert p['profiles']['EU1_1']['adaptive_widescreen'] is True + for r in p['runtime_profiles'].values(): + for k in ('morph_state','aim_x','aim_y','scale_patch_addr1','scale_patch_addr2','scale_value_addr'): + assert k in r['runtime'] + PY + + - name: Verify profile-aware coverage routing + shell: bash + run: | + set -euo pipefail + mkdir -p /tmp/eu-inputs + cat > /tmp/eu-inputs/arm9.toml <<'EOF' + [program] + id = "mph_amhp1_arm9" + load_address = 0x02004000 + size = 0x00001000 + entry_pc = 0x02004000 + EOF + cat > /tmp/eu-inputs/arm7.toml <<'EOF' + [program] + id = "mph_amhp1_arm7" + load_address = 0x02380000 + size = 0x00001000 + entry_pc = 0x02380000 + EOF + cat > /tmp/eu-trace.json <<'EOF' + { + "mph_profile": "EU1_1", + "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "scenario": "scenarios/adventure_start.json", + "tier3_coverage": {"entries": [ + {"cpu": 9, "pc": 33570848, "thumb": 0, "kind": 2, "hits": 7}, + {"cpu": 7, "pc": 37224480, "thumb": 0, "kind": 3, "hits": 4} + ]} + } + EOF + python tools/promote_mph_static_coverage.py \ + --version EU1_1 --inputs /tmp/eu-inputs --trace /tmp/eu-trace.json \ + --out /tmp/eu-coverage.json \ + --runner-commit 0123456789abcdef0123456789abcdef01234567 + python - <<'PY' + import json + p=json.load(open('/tmp/eu-coverage.json', encoding='utf-8')) + assert p['profile'] == 'EU1_1' + assert p['game_sha1'] == 'bdcd1dea293e24c98d4c481430e90d21198985a5' + assert p['entry_points']['arm9'] and p['entry_points']['arm7'] + PY + grep -q 'rom_bytes\[0x1E\]' tools/prepare_mph.py + ! grep -q 'rom_bytes\[0x1C\]' tools/prepare_mph.py + ! grep -q -- '--adaptive-widescreen' tools/build-linux.sh + + - name: Fetch exact pinned ndsrecomp revision + shell: bash + run: | + set -euo pipefail + pin="$(tr -d '\r\n' < ndsrecomp.pin)" + test "$pin" = 6c6a03bdcf99093f64555c4d05d16e522dc58634 + git init /tmp/ndsrecomp + git -C /tmp/ndsrecomp remote add origin https://github.com/mstan/ndsrecomp.git + git -C /tmp/ndsrecomp fetch --depth 1 origin "$pin" + git -C /tmp/ndsrecomp checkout --detach FETCH_HEAD + test "$(git -C /tmp/ndsrecomp rev-parse HEAD)" = "$pin" + + - name: Render US1.0 and EU1.1 launcher profiles + shell: bash + run: | + set -euo pipefail + mkdir -p /tmp/fake-prefix/lib/cmake/SDL2 /tmp/fake-recomp-ui + cat > /tmp/fake-prefix/lib/cmake/SDL2/SDL2Config.cmake <<'EOF' + if(NOT TARGET SDL2::SDL2) + add_library(SDL2::SDL2 INTERFACE IMPORTED) + endif() + EOF + cat > /tmp/fake-recomp-ui/recomp_ui.cmake <<'EOF' + function(recomp_target_launcher_ui) + endfunction() + EOF + cmake -S launcher/recomp-ui -B /tmp/mph-launcher-us10 \ + -DNDSRECOMP_ROOT=/tmp/ndsrecomp -DRECOMP_UI_ROOT=/tmp/fake-recomp-ui \ + -DCMAKE_PREFIX_PATH=/tmp/fake-prefix + cmake -S launcher/recomp-ui -B /tmp/mph-launcher-eu11 \ + -DNDSRECOMP_ROOT=/tmp/ndsrecomp -DRECOMP_UI_ROOT=/tmp/fake-recomp-ui \ + -DCMAKE_PREFIX_PATH=/tmp/fake-prefix \ + -DMPH_LAUNCHER_ROM_SHA1=bdcd1dea293e24c98d4c481430e90d21198985a5 \ + -DMPH_LAUNCHER_REGION=Europe \ + '-DMPH_LAUNCHER_DEFAULT_ROM=Metroid Prime Hunters (Europe Rev 1).nds' + for file in /tmp/mph-launcher-us10/launcher_main_profile.cpp /tmp/mph-launcher-eu11/launcher_main_profile.cpp; do + grep -q 'game.known_sha1_hex = nullptr;' "$file" + grep -q 'game.num_known_sha1 = 0;' "$file" + grep -A1 'int mod_feature_count' "$file" | grep -q 'return 3;' + grep -q 'copy_text(output->id, "hd-rendering")' "$file" + grep -q -- '--firmware-state-path' "$file" + grep -q 'bool adaptive_widescreen = true;' "$file" + done + grep -q 'game.region = "Europe";' /tmp/mph-launcher-eu11/launcher_main_profile.cpp + grep -q 'exe / "Metroid Prime Hunters (Europe Rev 1).nds";' /tmp/mph-launcher-eu11/launcher_main_profile.cpp + + - name: Patch pinned runner and verify idempotency + shell: bash + run: | + set -euo pipefail + python tools/patch_ndsrecomp_mph_runtime.py \ + --framework-root /tmp/ndsrecomp --profiles config/mph_rom_profiles.json + sha256sum \ + /tmp/ndsrecomp/runner/src/mph_runtime_profiles.generated.h \ + /tmp/ndsrecomp/runner/src/mph_widescreen_profiles.generated.h \ + /tmp/ndsrecomp/runner/src/title_patches.h \ + /tmp/ndsrecomp/runner/src/title_patches.cpp \ + /tmp/ndsrecomp/runner/src/frontend.cpp \ + /tmp/ndsrecomp/runner/src/main.cpp > /tmp/first.sha256 + python tools/patch_ndsrecomp_mph_runtime.py \ + --framework-root /tmp/ndsrecomp --profiles config/mph_rom_profiles.json + sha256sum \ + /tmp/ndsrecomp/runner/src/mph_runtime_profiles.generated.h \ + /tmp/ndsrecomp/runner/src/mph_widescreen_profiles.generated.h \ + /tmp/ndsrecomp/runner/src/title_patches.h \ + /tmp/ndsrecomp/runner/src/title_patches.cpp \ + /tmp/ndsrecomp/runner/src/frontend.cpp \ + /tmp/ndsrecomp/runner/src/main.cpp > /tmp/second.sha256 + diff -u /tmp/first.sha256 /tmp/second.sha256 + grep -q 'nds_title_patches_select_mph_runtime_profile' /tmp/ndsrecomp/runner/src/main.cpp + grep -q 'nds_title_patches_set_mph_adaptive' /tmp/ndsrecomp/runner/src/main.cpp + grep -q '0x02111B5Cu, 0x0211D208u, 0x02111380u' /tmp/ndsrecomp/runner/src/mph_widescreen_profiles.generated.h + ! grep -q 'kMphUs10MorphState' /tmp/ndsrecomp/runner/src/frontend.cpp + ! grep -q 'kMphUs10AimX' /tmp/ndsrecomp/runner/src/title_patches.cpp + + - name: Compile patched runner translation units + shell: bash + run: | + set -euo pipefail + mkdir -p /tmp/ndsrecomp/generated + for source in \ + arm9_bios.c arm9_bios_dispatch.c arm7_bios.c arm7_bios_dispatch.c \ + freebios_arm9.c freebios_arm9_dispatch.c freebios_arm7.c freebios_arm7_dispatch.c; do + : > "/tmp/ndsrecomp/generated/$source" + done + cmake -S /tmp/ndsrecomp/runner -B /tmp/nds-runner-profile-check \ + -DCMAKE_BUILD_TYPE=Release -DNDS_BOOTSTRAP_FIRMWARE=ON \ + -DNDS_ENABLE_COMPUTE_RENDERER=OFF -DNDS_ENABLE_PCAP_BACKEND=OFF + cmake --build /tmp/nds-runner-profile-check --target src/title_patches.o -j2 + cmake --build /tmp/nds-runner-profile-check --target src/frontend.o -j2 + cmake --build /tmp/nds-runner-profile-check --target src/main.o -j2 + + - name: Test seven-version runtime dispatch + shell: bash + run: | + set -euo pipefail + c++ -std=c++20 -Wall -Wextra -Wno-unused-parameter \ + -I/tmp/ndsrecomp/runner/src \ + -I/tmp/ndsrecomp/recompiler/armv4t \ + tools/tests/mph_runtime_profile_test.cpp \ + /tmp/ndsrecomp/runner/src/title_patches.cpp \ + -o /tmp/mph-runtime-profile-test + /tmp/mph-runtime-profile-test + + - name: Compile exact-content ROM checkers + shell: bash + run: | + set -euo pipefail + for profile in US1_0 EU1_1; do + build="/tmp/mph-romcheck-$profile" + cmake -S . -B "$build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DNDSRECOMP_ROOT=/tmp/ndsrecomp \ + -DMPH_VERSION="$profile" \ + -DMPH_ROM="/tmp/nonexistent-$profile.nds" + cmake --build "$build" --target mph_romcheck -j2 + test -x "$build/MetroidPrimeHuntersRecomp" + done + strings /tmp/mph-romcheck-EU1_1/MetroidPrimeHuntersRecomp | grep -q bdcd1dea293e24c98d4c481430e90d21198985a5 + strings /tmp/mph-romcheck-EU1_1/MetroidPrimeHuntersRecomp | grep -q EU1_1 + + - name: Diff sanity + shell: bash + run: | + set -euo pipefail + grep -q 'known_sha1_hex = nullptr' launcher/recomp-ui/CMakeLists.txt + ! grep -q 'Adaptive Widescreen: disabled until validated' docs/EU1_1_BRINGUP.md + grep -q 'Adaptive Widescreen: exposed and revision-aware' docs/EU1_1_BRINGUP.md + git diff --check HEAD^ HEAD diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f7ea40..5dc2345 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ -cmake_minimum_required(VERSION 3.20) -project(MetroidPrimeHuntersRecomp VERSION 0.3.0 LANGUAGES C CXX) +cmake_minimum_required(VERSION 3.20) +project(MetroidPrimeHuntersRecomp VERSION 0.4.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -18,13 +18,76 @@ add_subdirectory( "${NDSRECOMP_ROOT}/recompiler" "${CMAKE_BINARY_DIR}/ndsrecomp-recompiler") +set(MPH_VERSION "US1_0" CACHE STRING + "Metroid Prime Hunters retail revision profile") +set(MPH_PROFILE_FILE + "${CMAKE_CURRENT_SOURCE_DIR}/config/mph_rom_profiles.json" + CACHE FILEPATH "Metroid Prime Hunters ROM profile registry") +if(NOT EXISTS "${MPH_PROFILE_FILE}") + message(FATAL_ERROR "MPH ROM profile registry not found: ${MPH_PROFILE_FILE}") +endif() +file(READ "${MPH_PROFILE_FILE}" MPH_PROFILES_JSON) +string(JSON MPH_PROFILE_COUNT LENGTH "${MPH_PROFILES_JSON}" profiles) +if(MPH_PROFILE_COUNT LESS 1) + message(FATAL_ERROR "MPH ROM profile registry contains no profiles") +endif() +math(EXPR _mph_profile_last "${MPH_PROFILE_COUNT} - 1") +set(MPH_PROFILE_KEYS) +foreach(_mph_profile_index RANGE 0 ${_mph_profile_last}) + string(JSON _mph_profile_key MEMBER + "${MPH_PROFILES_JSON}" profiles ${_mph_profile_index}) + list(APPEND MPH_PROFILE_KEYS "${_mph_profile_key}") +endforeach() +set_property(CACHE MPH_VERSION PROPERTY STRINGS ${MPH_PROFILE_KEYS}) +list(FIND MPH_PROFILE_KEYS "${MPH_VERSION}" MPH_PROFILE_INDEX) +if(MPH_PROFILE_INDEX EQUAL -1) + list(JOIN MPH_PROFILE_KEYS ", " _mph_profile_choices) + message(FATAL_ERROR + "Unknown MPH_VERSION '${MPH_VERSION}'. Configured profiles: " + "${_mph_profile_choices}") +endif() + +string(JSON MPH_PROFILE_NAME GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" display_name) +string(JSON MPH_PROFILE_GAME_CODE GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" game_code) +string(JSON MPH_PROFILE_REVISION GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" revision) +string(JSON MPH_PROFILE_ROM_SIZE GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" rom_size) +string(JSON MPH_PROFILE_SHA1 GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" sha1) +string(JSON MPH_PROFILE_COVERAGE GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" coverage) +string(JSON MPH_PROFILE_GAME_CONFIG GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" game_config) +string(JSON MPH_PROFILE_FMV_RUNTIME GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" fmv_runtime) +string(JSON MPH_PROFILE_FMV_RUNTIME_BANK GET + "${MPH_PROFILES_JSON}" profiles "${MPH_VERSION}" fmv_runtime_bank) + set(MPH_ROM "${CMAKE_CURRENT_SOURCE_DIR}/Metroid Prime Hunters.nds" - CACHE FILEPATH "Path to the verified USA AMHE revision-0 ROM") -set(MPH_GENERATED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/generated") + CACHE FILEPATH "Path to the verified ${MPH_PROFILE_NAME} ROM") +set(MPH_GENERATED_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/generated" CACHE PATH + "Root for ignored generated Metroid Prime Hunters artifacts") +if(MPH_VERSION STREQUAL "US1_0") + set(MPH_GENERATED_DIR "${MPH_GENERATED_ROOT}") +else() + set(MPH_GENERATED_DIR "${MPH_GENERATED_ROOT}/${MPH_VERSION}") +endif() set(MPH_INPUT_DIR "${MPH_GENERATED_DIR}/inputs") set(MPH_RECOMP_DIR "${MPH_GENERATED_DIR}/recomp") set(MPH_COVERAGE_SEEDS - "${CMAKE_CURRENT_SOURCE_DIR}/coverage/adventure-main-entry-points.json") + "${CMAKE_CURRENT_SOURCE_DIR}/${MPH_PROFILE_COVERAGE}") +if(NOT EXISTS "${MPH_COVERAGE_SEEDS}") + message(FATAL_ERROR + "Coverage seed file for ${MPH_VERSION} not found: ${MPH_COVERAGE_SEEDS}") +endif() + +message(STATUS + "MPH profile ${MPH_VERSION}: ${MPH_PROFILE_NAME}, " + "${MPH_PROFILE_GAME_CODE} revision ${MPH_PROFILE_REVISION}, " + "SHA-1 ${MPH_PROFILE_SHA1}") set(_mph_venv_python "${CMAKE_CURRENT_SOURCE_DIR}/.venv/Scripts/python.exe") if(EXISTS "${_mph_venv_python}") @@ -48,9 +111,11 @@ add_custom_command( --rom "${MPH_ROM}" --out "${MPH_INPUT_DIR}" --coverage "${MPH_COVERAGE_SEEDS}" + --version "${MPH_VERSION}" + --profiles "${MPH_PROFILE_FILE}" DEPENDS "${MPH_ROM}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/prepare_mph.py" - "${MPH_COVERAGE_SEEDS}" - COMMENT "Verifying AMHE0 and preparing main/overlay code images") + "${MPH_PROFILE_FILE}" "${MPH_COVERAGE_SEEDS}" + COMMENT "Verifying ${MPH_VERSION} and preparing main/overlay code images") add_custom_target(mph_prepare DEPENDS ${MPH_PREP_OUTPUTS}) set(MPH_ARM9_SOURCES) @@ -91,7 +156,7 @@ add_custom_command( --validate-live-bytes COMMAND ${CMAKE_COMMAND} -E touch "${MPH_ARM9_STAMP}" DEPENDS nds_recompile ${MPH_PREP_OUTPUTS} - COMMENT "Discovering and recompiling the AMHE0 ARM9 main closure") + COMMENT "Discovering and recompiling ${MPH_VERSION} ARM9 main closure") set(MPH_ARM7_STAMP "${MPH_RECOMP_DIR}/mph_arm7.stamp") add_custom_command( @@ -109,49 +174,56 @@ add_custom_command( --validate-live-bytes COMMAND ${CMAKE_COMMAND} -E touch "${MPH_ARM7_STAMP}" DEPENDS nds_recompile ${MPH_PREP_OUTPUTS} - COMMENT "Discovering and recompiling the AMHE0 ARM7 initial closure") + COMMENT "Discovering and recompiling ${MPH_VERSION} ARM7 initial closure") -set(MPH_FMV_RUNTIME_CONFIG - "${CMAKE_CURRENT_SOURCE_DIR}/config/mph_arm9_fmv_runtime.toml") -set(MPH_FMV_RUNTIME_IMAGE - "${MPH_GENERATED_DIR}/capture/mph_arm9_fmv_runtime.bin" - CACHE FILEPATH "Deterministic MPH FMV ITCM+main-RAM capture") set(MPH_FMV_RUNTIME_SOURCES) set(MPH_FMV_RUNTIME_STAMP) -if(EXISTS "${MPH_FMV_RUNTIME_CONFIG}" AND EXISTS "${MPH_FMV_RUNTIME_IMAGE}") - foreach(shard RANGE 0 31) - if(shard LESS 10) - set(shard_name "0${shard}") - else() - set(shard_name "${shard}") - endif() +if(MPH_PROFILE_FMV_RUNTIME) + set(MPH_FMV_RUNTIME_CONFIG + "${CMAKE_CURRENT_SOURCE_DIR}/config/${MPH_PROFILE_FMV_RUNTIME_BANK}.toml") + set(MPH_FMV_RUNTIME_IMAGE + "${MPH_GENERATED_DIR}/capture/${MPH_PROFILE_FMV_RUNTIME_BANK}.bin" + CACHE FILEPATH "Deterministic ${MPH_VERSION} FMV ITCM+main-RAM capture") + if(EXISTS "${MPH_FMV_RUNTIME_CONFIG}" AND EXISTS "${MPH_FMV_RUNTIME_IMAGE}") + foreach(shard RANGE 0 31) + if(shard LESS 10) + set(shard_name "0${shard}") + else() + set(shard_name "${shard}") + endif() + list(APPEND MPH_FMV_RUNTIME_SOURCES + "${MPH_RECOMP_DIR}/${MPH_PROFILE_FMV_RUNTIME_BANK}_${shard_name}.c") + endforeach() list(APPEND MPH_FMV_RUNTIME_SOURCES - "${MPH_RECOMP_DIR}/mph_arm9_fmv_runtime_${shard_name}.c") - endforeach() - list(APPEND MPH_FMV_RUNTIME_SOURCES - "${MPH_RECOMP_DIR}/mph_arm9_fmv_runtime_dispatch.c") - set(MPH_FMV_RUNTIME_STAMP - "${MPH_RECOMP_DIR}/mph_arm9_fmv_runtime.stamp") - add_custom_command( - OUTPUT "${MPH_FMV_RUNTIME_STAMP}" - BYPRODUCTS ${MPH_FMV_RUNTIME_SOURCES} - "${MPH_RECOMP_DIR}/mph_arm9_fmv_runtime.h" - COMMAND $ - --config "${MPH_FMV_RUNTIME_CONFIG}" - --bin "${MPH_FMV_RUNTIME_IMAGE}" - --out "${MPH_RECOMP_DIR}" - --bank mph_arm9_fmv_runtime - --shards 32 - --stable-address-shards - --max-function-bytes 512 - --validate-live-bytes - COMMAND ${CMAKE_COMMAND} -E touch "${MPH_FMV_RUNTIME_STAMP}" - DEPENDS nds_recompile "${MPH_FMV_RUNTIME_CONFIG}" - "${MPH_FMV_RUNTIME_IMAGE}" - COMMENT "Recompiling content-validated AMHE0 ARM9 FMV runtime code") + "${MPH_RECOMP_DIR}/${MPH_PROFILE_FMV_RUNTIME_BANK}_dispatch.c") + set(MPH_FMV_RUNTIME_STAMP + "${MPH_RECOMP_DIR}/${MPH_PROFILE_FMV_RUNTIME_BANK}.stamp") + add_custom_command( + OUTPUT "${MPH_FMV_RUNTIME_STAMP}" + BYPRODUCTS ${MPH_FMV_RUNTIME_SOURCES} + "${MPH_RECOMP_DIR}/${MPH_PROFILE_FMV_RUNTIME_BANK}.h" + COMMAND $ + --config "${MPH_FMV_RUNTIME_CONFIG}" + --bin "${MPH_FMV_RUNTIME_IMAGE}" + --out "${MPH_RECOMP_DIR}" + --bank "${MPH_PROFILE_FMV_RUNTIME_BANK}" + --shards 32 + --stable-address-shards + --max-function-bytes 512 + --validate-live-bytes + COMMAND ${CMAKE_COMMAND} -E touch "${MPH_FMV_RUNTIME_STAMP}" + DEPENDS nds_recompile "${MPH_FMV_RUNTIME_CONFIG}" + "${MPH_FMV_RUNTIME_IMAGE}" + COMMENT "Recompiling content-validated ${MPH_VERSION} ARM9 FMV runtime code") + else() + message(STATUS + "MPH FMV runtime bank disabled for ${MPH_VERSION}: expected " + "${MPH_FMV_RUNTIME_CONFIG} and ${MPH_FMV_RUNTIME_IMAGE}") + endif() else() message(STATUS - "MPH FMV runtime bank disabled: capture image is not present") + "MPH FMV runtime bank disabled for ${MPH_VERSION}: no revision-specific " + "runtime capture has been validated") endif() add_custom_target(mph_generate_banks @@ -180,6 +252,12 @@ set_target_properties(mph_romcheck PROPERTIES OUTPUT_NAME "MetroidPrimeHuntersRecomp") target_include_directories(mph_romcheck PRIVATE "${NDSRECOMP_ROOT}/recompiler/support") +target_compile_definitions(mph_romcheck PRIVATE + MPH_BUILD_VERSION="${MPH_VERSION}" + MPH_EXPECTED_SHA1="${MPH_PROFILE_SHA1}" + MPH_EXPECTED_GAME_CODE="${MPH_PROFILE_GAME_CODE}" + MPH_EXPECTED_REVISION=${MPH_PROFILE_REVISION} + MPH_EXPECTED_SIZE=${MPH_PROFILE_ROM_SIZE}) if(NOT MSVC) target_compile_options(mph_romcheck PRIVATE -Wall -Wextra) endif() diff --git a/README.md b/README.md index c22a26e..98de666 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,11 @@ > audio issues, input quirks, networking failures, and possible desyncs. Testing, > issues, and PRs are welcome. -MetroidPrimeHuntersRecomp runs the USA revision-0 release of **Metroid Prime -Hunters** as a native recompilation target. You provide your own legally +MetroidPrimeHuntersRecomp currently ships generated content profiles for the +validated ROM revisions tracked by this branch. Runtime base detection is +prepared for all seven retail Metroid Prime Hunters revisions and uses +melonPrimeDS-compatible executable checksums; whole-ROM SHA-1 is content +provenance, not the runtime address selector. You provide your own legally obtained ROM. No Nintendo ROM, BIOS, firmware, save data, or generated ROM-derived source is distributed. @@ -19,35 +22,37 @@ Click the image to watch the gameplay preview on YouTube. ## Current Release -Latest release: -**[v0.3.0-alpha](https://github.com/mstan/MetroidPrimeHuntersRecomp/releases/tag/v0.3.0-alpha)**. +Latest upstream release: +**[v0.4.0-alpha](https://github.com/mstan/MetroidPrimeHuntersRecomp/releases/tag/v0.4.0-alpha)**. Downloads: - Windows: - `MetroidPrimeHuntersRecomp-windows-x64-v0.3.0.zip` + `MetroidPrimeHuntersRecomp-windows-x64-v0.4.0.zip` - Linux: - `MetroidPrimeHuntersRecomp-linux-x86_64-v0.3.0.AppImage` + `MetroidPrimeHuntersRecomp-linux-x86_64-v0.4.0.AppImage` -This is the first release line in the ndsrecomp ecosystem and it is still very -early. Campaign entry, widescreen output, Prime-style controls, gamepad support, -and Wiimmfi lobby connectivity have all seen active bring-up, but this should -still be treated as an alpha test build rather than a polished game release. +This is an early ndsrecomp title and should still be treated as an alpha test +build rather than a polished game release. + +New in upstream v0.4.0 is an opt-in **HD Rendering** mod on the launcher Mods +page. It can raise the internal 3D resolution up to 4x and optionally upscale +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. ## Quick Start Windows: -1. Download and fully extract the `v0.3.0-alpha` Windows ZIP. -2. Put your own Metroid Prime Hunters USA revision-0 `.nds` ROM next to - `MetroidPrimeHuntersRecomp.exe`. +1. Download and fully extract the Windows ZIP. +2. Put your own supported Metroid Prime Hunters `.nds` ROM next to the launcher. 3. Run `MetroidPrimeHuntersRecomp.exe` and press Play. Linux: -1. Download the `v0.3.0-alpha` AppImage. -2. Put your own Metroid Prime Hunters USA revision-0 `.nds` ROM next to the - AppImage. +1. Download the AppImage. +2. Put your own supported Metroid Prime Hunters `.nds` ROM next to the AppImage. 3. Run the AppImage. The current release can use the built-in FreeBIOS + generated firmware path, so @@ -56,31 +61,38 @@ startup path. If you choose to use your own BIOS/firmware dumps, they must be from hardware you own and must match the hashes listed in the release's `bios/README.txt`. -## Required ROM +## ROM identity and multi-ROM support -Only this ROM revision is supported: +Runtime address selection does **not** use whole-ROM SHA-1. The runner first +uses the melonPrimeDS executable checksum (CRC32 over header + ARM9 + ARM7), +then uses exact game code + supported revision only as a fallback base-profile +hint. Header-only matches never authorize host RAM/code writes. -| field | value | -|---|---| -| title | `MP HUNTERS` | -| game code | `AMHE` | -| region/revision | USA revision 0 | -| size | 64 MiB | -| SHA-1 | `90164d1ac127ee5f9815ea4ae7de798c7b5fc629` | -| SHA-256 | `7d0a98ff98e1b7c985d1f3d89b01730af1b2115061a4dfea847612d217a8b855` | +Known compatible executable checksums can use the revision-specific Prime +Controls and Adaptive Widescreen addresses. Whole-ROM SHA-1 remains the exact +content identity for generated banks, coverage, checkpoints and capture data, +so one modified ROM cannot silently reuse another modified ROM's generated +content. -If your ROM does not match, the launcher/runner should reject it. +The current branch has runtime address profiles for US1.0, US1.1, EU1.0, +EU1.1, JP1.0, JP1.1 and KR1.0. A revision still needs its own generated +content/capture coverage before it is considered fully brought up. ## What Works -- Boots the supported ROM through the ndsrecomp runner. +- Boots supported content profiles through the ndsrecomp runner. - Reaches Metroid Prime Hunters gameplay in tested routes. -- Includes an adaptive 21:9 upper-screen widescreen option. +- Includes an adaptive 21:9 upper-screen widescreen option using per-revision + projection/culling addresses from melonPrimeDS/mphCodex. - Includes Prime-style keyboard and mouse controls. - Includes full remappable gamepad bindings in the launcher. +- Includes upstream HD Rendering controls for internal resolution and texture + upscaling. - Supports mouse-driven touchscreen input. - Can authenticate through Wiimmfi and reach a Friends and Rivals lobby in validated flows. +- Persists mutable firmware/WFC state between launches through the upstream + firmware-state path. ## Known Limits @@ -90,6 +102,8 @@ If your ROM does not match, the launcher/runner should reject it. validated from start to finish. - Widescreen is still being audited. Some scenes, effects, HUD placement, 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. - 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. @@ -139,6 +153,12 @@ Nintendo WFC / Wiimmfi support is experimental. The current validated state is lobby connectivity: Metroid Prime Hunters can authenticate through Wiimmfi and reach a Friends and Rivals lobby where a locally hosted game is visible. +The launcher keeps the console firmware profile in +`%APPDATA%\MetroidPrimeHuntersRecomp`. Wi-Fi settings, console/game-card +pairing, and WFC updates survive both the in-game system shutdown flow and a +normal window close. Confirming the WFC settings shutdown prompt closes the +application automatically. + Actually joining a match and playing in-game online is not guaranteed. It may fail to connect, disconnect, or desync. @@ -151,8 +171,10 @@ emulated access point, and network backend foundation. - [melonDS](https://github.com/melonDS-emu/melonDS): Wi-Fi implementation foundation used by the shared ndsrecomp runner. -- [melonPrimeDS](https://github.com/makinori/melonPrimeDS): reference for the - Prime-style keyboard/mouse controls and touchscreen-helper behavior. +- [melonPrimeDS](https://github.com/ag-advania/melonPrimeDS): runtime-version + detection, Prime-style controls, and per-version aspect-ratio patch reference. +- [mphCodex](https://github.com/Zection6V/mphCodex): MPH game-code analysis, + including the seven-version widescreen projection/culling mapping. - [MphRead](https://github.com/NoneGiven/MphRead): Metroid Prime Hunters file format and behavior reference. diff --git a/config/game-eu11.toml b/config/game-eu11.toml new file mode 100644 index 0000000..f1e62d5 --- /dev/null +++ b/config/game-eu11.toml @@ -0,0 +1,34 @@ +[game] +name = "Metroid Prime Hunters" +id = "AMHP" +region = "Europe" +revision = 1 +status = "EU1.1 bootstrap: ROM-header main banks with interpreter fallback" +rom = "Metroid Prime Hunters (Europe Rev 1).nds" +rom_size = 0x04000000 +sha1 = "bdcd1dea293e24c98d4c481430e90d21198985a5" + +[display] +screen_layout = "separate" +supersampling = 1 +antialiasing = 0 +# melonPrimeDS and mphCodex identify the revision-specific projection, +# 3D-to-2D projection and Q12 culling-aspect locations for every retail MPH +# revision. The runner applies those writes only after authoritative executable +# checksum validation, so EU1.1 can expose the same 21:9 feature as US1.0. +adaptive_widescreen = "top" +adaptive_capability = "top" +adaptive_width = 448 +adaptive_skybox_fill = false +adaptive_hud_anchor = true +adaptive_hud_center_width = 128 + +[system] +startup_mode = "automatic" + +[cartridge] +# Metroid Prime Hunters uses the same 256 KiB flash save geometry across the +# retail revisions tracked by the project. Keep this game-owned rather than a +# shared-runner default. +save_type = "flash" +save_size = 262144 diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json new file mode 100644 index 0000000..838996a --- /dev/null +++ b/config/mph_rom_profiles.json @@ -0,0 +1,156 @@ +{ + "schema": 5, + "runtime_address_source": "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h", + "runtime_detection_source": "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/src/frontend/qt_sdl/MelonPrimeGameRomDetect.cpp", + "runtime_checksum_source": "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/src/NDSCart/CartCommon.cpp", + "aspect_ratio_patch_source": "https://github.com/ag-advania/melonPrimeDS/blob/main/src/frontend/qt_sdl/MelonPrimePatchAspectRatio.cpp", + "widescreen_analysis_source": "https://github.com/Zection6V/mphCodex/blob/main/mnt/data/analysis/mphAnalysis/_Commons/Widescreen.md", + "runtime_profiles": { + "US1_0": { + "game_code": "AMHE", + "revision": 0, + "base_checksum": "0x218DA42C", + "runtime": { + "morph_state": "0x020DA818", + "aim_x": "0x020DE526", + "aim_y": "0x020DE52E", + "scale_patch_addr1": "0x02110FFC", + "scale_patch_addr2": "0x0211C638", + "scale_value_addr": "0x02110820" + } + }, + "US1_1": { + "game_code": "AMHE", + "revision": 1, + "base_checksum": "0x91B46577", + "runtime": { + "morph_state": "0x020DB098", + "aim_x": "0x020DEDA6", + "aim_y": "0x020DEDAE", + "scale_patch_addr1": "0x02111ABC", + "scale_patch_addr2": "0x0211D168", + "scale_value_addr": "0x021112E0" + } + }, + "EU1_0": { + "game_code": "AMHP", + "revision": 0, + "base_checksum": "0xA4A8FE5A", + "runtime": { + "morph_state": "0x020DB0B8", + "aim_x": "0x020DEDC6", + "aim_y": "0x020DEDCE", + "scale_patch_addr1": "0x02111ADC", + "scale_patch_addr2": "0x0211D114", + "scale_value_addr": "0x02111300" + } + }, + "EU1_1": { + "game_code": "AMHP", + "revision": 1, + "base_checksum": "0x910018A5", + "runtime": { + "morph_state": "0x020DB138", + "aim_x": "0x020DEE46", + "aim_y": "0x020DEE4E", + "scale_patch_addr1": "0x02111B5C", + "scale_patch_addr2": "0x0211D208", + "scale_value_addr": "0x02111380" + } + }, + "JP1_0": { + "game_code": "AMHJ", + "revision": 0, + "base_checksum": "0xD75F539D", + "runtime": { + "morph_state": "0x020DC6D8", + "aim_x": "0x020E03E6", + "aim_y": "0x020E03EE", + "scale_patch_addr1": "0x0211313C", + "scale_patch_addr2": "0x0211E7E8", + "scale_value_addr": "0x02112960" + } + }, + "JP1_1": { + "game_code": "AMHJ", + "revision": 1, + "base_checksum": "0x42EBF348", + "runtime": { + "morph_state": "0x020DC698", + "aim_x": "0x020E03A6", + "aim_y": "0x020E03AE", + "scale_patch_addr1": "0x021130FC", + "scale_patch_addr2": "0x0211E7A8", + "scale_value_addr": "0x02112920" + } + }, + "KR1_0": { + "game_code": "AMHK", + "revision": 0, + "base_checksum": "0xE54682F3", + "runtime": { + "morph_state": "0x020D3EE4", + "aim_x": "0x020D7C0E", + "aim_y": "0x020D7C16", + "scale_patch_addr1": "0x02109B64", + "scale_patch_addr2": "0x02114838", + "scale_value_addr": "0x021091A4" + } + } + }, + "runtime_checksums": [ + {"crc32": "0x91B46577", "profile": "US1_1", "name": "US1.1"}, + {"crc32": "0x01476E8F", "profile": "US1_1", "name": "US1.1 ENCRYPTED"}, + {"crc32": "0x218DA42C", "profile": "US1_0", "name": "US1.0"}, + {"crc32": "0xE048CD92", "profile": "US1_0", "name": "US1.0 ENCRYPTED"}, + {"crc32": "0x910018A5", "profile": "EU1_1", "name": "EU1.1"}, + {"crc32": "0x31703770", "profile": "EU1_1", "name": "EU1.1 ENCRYPTED"}, + {"crc32": "0x948B1E48", "profile": "EU1_1", "name": "EU1.1 BALANCED"}, + {"crc32": "0x2970A14F", "profile": "EU1_1", "name": "EU1.1 BALANCED V1.2.11"}, + {"crc32": "0x9E20F3A8", "profile": "EU1_1", "name": "EU1.1 RUSSIANED"}, + {"crc32": "0xA4A8FE5A", "profile": "EU1_0", "name": "EU1.0"}, + {"crc32": "0x979BB267", "profile": "EU1_0", "name": "EU1.0 ENCRYPTED"}, + {"crc32": "0xD75F539D", "profile": "JP1_0", "name": "JP1.0"}, + {"crc32": "0xE795A10C", "profile": "JP1_0", "name": "JP1.0 ENCRYPTED"}, + {"crc32": "0x42EBF348", "profile": "JP1_1", "name": "JP1.1"}, + {"crc32": "0x0A1203A5", "profile": "JP1_1", "name": "JP1.1 ENCRYPTED"}, + {"crc32": "0xE54682F3", "profile": "KR1_0", "name": "KR1.0"}, + {"crc32": "0xC26916F3", "profile": "KR1_0", "name": "KR1.0 ENCRYPTED"} + ], + "profiles": { + "US1_0": { + "base_profile": "US1_0", + "known_clean": true, + "display_name": "Metroid Prime Hunters (USA rev 0)", + "region": "USA", + "game_code": "AMHE", + "revision": 0, + "rom_size": 67108864, + "sha1": "90164d1ac127ee5f9815ea4ae7de798c7b5fc629", + "program_id": "mph_amhe0", + "coverage": "coverage/adventure-main-entry-points.json", + "game_config": "game.toml", + "fmv_runtime": true, + "fmv_runtime_bank": "mph_arm9_fmv_runtime", + "launcher_default_rom": "Metroid Prime Hunters.nds", + "adaptive_widescreen": true + }, + "EU1_1": { + "base_profile": "EU1_1", + "known_clean": true, + "display_name": "Metroid Prime Hunters (Europe rev 1)", + "region": "Europe", + "game_code": "AMHP", + "revision": 1, + "rom_size": 67108864, + "sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "program_id": "mph_amhp1", + "coverage": "coverage/eu11-bootstrap-entry-points.json", + "game_config": "config/game-eu11.toml", + "fmv_runtime": false, + "fmv_runtime_bank": "mph_amhp1_arm9_fmv_runtime", + "launcher_default_rom": "Metroid Prime Hunters (Europe Rev 1).nds", + "adaptive_widescreen": true + } + } +} diff --git a/coverage/eu11-bootstrap-entry-points.json b/coverage/eu11-bootstrap-entry-points.json new file mode 100644 index 0000000..efb59c2 --- /dev/null +++ b/coverage/eu11-bootstrap-entry-points.json @@ -0,0 +1,10 @@ +{ + "schema": 1, + "game_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "scenario": null, + "selection": "EU1.1 bootstrap only: ROM-header ARM9/ARM7 roots; no USA coverage reused", + "entry_points": { + "arm9": [], + "arm7": [] + } +} diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md new file mode 100644 index 0000000..0871764 --- /dev/null +++ b/docs/EU1_1_BRINGUP.md @@ -0,0 +1,395 @@ +# Metroid Prime Hunters Multi-ROM / EU1.1 Bring-up + +This document describes the current multi-ROM architecture in this branch. +It supersedes the earlier whole-ROM-SHA-1-driven runtime-profile design and +includes the upstream Wi-Fi firmware-state persistence work integrated from +`mstan/MetroidPrimeHuntersRecomp` commit +`5abcfee6187d572e752985ede2364f165d62dd6a`. + +## 1. Status + +Runtime address layouts are statically prepared for all seven retail MPH base +revisions: + +| Runtime base | Game code | Revision | Morph / Alt Form | Aim X | Aim Y | +|---|---|---:|---:|---:|---:| +| `US1_0` | `AMHE` | 0 | `0x020DA818` | `0x020DE526` | `0x020DE52E` | +| `US1_1` | `AMHE` | 1 | `0x020DB098` | `0x020DEDA6` | `0x020DEDAE` | +| `EU1_0` | `AMHP` | 0 | `0x020DB0B8` | `0x020DEDC6` | `0x020DEDCE` | +| `EU1_1` | `AMHP` | 1 | `0x020DB138` | `0x020DEE46` | `0x020DEE4E` | +| `JP1_0` | `AMHJ` | 0 | `0x020DC6D8` | `0x020E03E6` | `0x020E03EE` | +| `JP1_1` | `AMHJ` | 1 | `0x020DC698` | `0x020E03A6` | `0x020E03AE` | +| `KR1_0` | `AMHK` | 0 | `0x020D3EE4` | `0x020D7C0E` | `0x020D7C16` | + +Adaptive Widescreen is also statically address-mapped for all seven base +revisions. It is no longer hidden for EU1.1. Actual guest code/data writes are +still fail-closed and require an authoritative executable checksum. + +Exact build/capture content profiles currently exist for: + +- `US1_0` clean retail ROM +- `EU1_1` clean retail ROM + +The other retail revisions and individual modified ROMs still require their own +exact-content build/capture profile, coverage and generated artifacts before +they can be called fully supported. + +## 2. Sources of truth + +Runtime detection and Aim/Morph addresses intentionally follow melonPrimeDS +`develop_hud`: + +- detector: `src/frontend/qt_sdl/MelonPrimeGameRomDetect.cpp` +- address table: `src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h` +- executable checksum algorithm: `src/NDSCart/CartCommon.cpp`, `CartCommon::Checksum()` +- CRC32 implementation: `src/CRC32.cpp` + +Adaptive Widescreen additionally uses: + +- melonPrimeDS `main/src/frontend/qt_sdl/MelonPrimePatchAspectRatio.cpp` +- melonPrimeDS `main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h` +- mphCodex `mnt/data/analysis/mphAnalysis/_Commons/Widescreen.md` + +The registry records these source locations in +`config/mph_rom_profiles.json`. + +## 3. Identity model + +The old design coupled runtime RAM addresses to an exact whole-ROM SHA-1. That +is intentionally no longer the design. + +There are three separate identities. + +### 3.1 Runtime Base Profile + +A runtime base profile is one of the seven region/revision layouts above. It +owns revision-specific host addresses for Aim, Morph and Adaptive Widescreen. + +The runtime detector uses: + +1. melonPrimeDS-compatible executable checksum, then +2. exact NDS header `gameCode + revision` fallback. + +NDS header offsets: + +- game code: `0x0C..0x0F` +- ROM revision/version: `0x1E` + +The Recomp fallback is deliberately stricter than melonPrimeDS's generic +`revision != 0 -> 1.1` fallback. Only the seven explicitly supported tuples are +accepted. A hypothetical rev2+ is unknown. + +### 3.2 Executable Compatibility Identity + +melonPrimeDS `CartCommon::Checksum()` is reproduced exactly: + +1. CRC32 over ROM header bytes `0x00..0x3F` +2. continue CRC32 over the ARM9 ROM image +3. continue CRC32 over the ARM7 ROM image + +A hit in the audited melonPrimeDS checksum table is authoritative for the base +runtime layout and may enable host-side Aim/Morph/Adaptive-Widescreen RAM/code +access. + +A header-only match is weaker. It identifies a candidate base profile, but it +**does not authorize host RAM/code reads or writes**. Unknown executable content +remains fail-closed. + +This prevents a modified ROM that merely preserves `AMHE` revision 0 from being +blindly treated as executable-compatible US1.0. + +### 3.3 Actual Content Identity + +Whole-ROM SHA-1 identifies the exact content used to generate or validate: + +- build inputs +- recomp banks +- static coverage +- runtime coverage +- checkpoints +- FMV/runtime captures + +This identity is not the runtime-address selector. + +Generated title banks remain registered against the actual ROM SHA-1. A bank +captured/generated for clean ROM or MOD A is therefore not silently reused for +MOD B. + +## 4. Current authoritative executable checksum table + +The registry mirrors the current `develop_hud` detector table. CI downloads the +detector on every run and fails if the local registry drifts. + +Current entries include: + +- all seven clean retail revisions +- encrypted variants for all seven revisions +- EU1.1 Balanced +- EU1.1 Balanced v1.2.11 +- EU1.1 Russian + +The current EU1.1 Russian executable checksum is `0x9E20F3A8`. + +A known checksum establishes the runtime layout. It does **not** by itself mean +the clean recomp build is byte-compatible with that modified executable. + +For example, a known code-modified EU1.1 executable may use EU1.1 runtime host +addresses, while still requiring a separate exact content profile, coverage and +generated banks. + +`Last Raven` is not present in the currently audited `develop_hud` checksum +table. It therefore remains fail-closed for Aim/Morph/Widescreen host writes +until its executable checksum and layout compatibility are explicitly +validated. + +## 5. Adaptive Widescreen across all seven revisions + +The previous EU1.1 bring-up temporarily disabled Adaptive Widescreen because the +US1.0 game-side projection/culling addresses were not known for other revisions. +That limitation is removed. + +melonPrimeDS and mphCodex identify three revision-specific locations: + +| Runtime base | Projection patch 1 | Projection patch 2 | Q12 culling aspect | +|---|---:|---:|---:| +| `US1_0` | `0x02110FFC` | `0x0211C638` | `0x02110820` | +| `US1_1` | `0x02111ABC` | `0x0211D168` | `0x021112E0` | +| `EU1_0` | `0x02111ADC` | `0x0211D114` | `0x02111300` | +| `EU1_1` | `0x02111B5C` | `0x0211D208` | `0x02111380` | +| `JP1_0` | `0x0211313C` | `0x0211E7E8` | `0x02112960` | +| `JP1_1` | `0x021130FC` | `0x0211E7A8` | `0x02112920` | +| `KR1_0` | `0x02109B64` | `0x02114838` | `0x021091A4` | + +For 21:9 the runner mirrors melonPrimeDS's guarded patch semantics: + +- projection 1 preimage: `0xE5991664` +- projection 2 preimage: `0xE59A1664` +- culling-aspect preimage: `0x1555` +- 21:9 projection instruction: `0xE3A0106D` +- 21:9 Q12 aspect: `0x2555` + +All three preimages are verified before the first write. If any guard does not +match, no partial aspect-ratio patch is applied. + +The UI may expose Adaptive Widescreen for a supported content profile, but the +runner is authoritative: + +- known executable checksum + compatible base profile: enable profile-specific + projection/culling patch and adaptive top-screen rendering +- header-only fallback: keep runtime base identification, but force the actual + adaptive top-screen patch path off because executable compatibility has not + been established +- unknown/non-MPH identity: normal fail-closed behavior + +The patch checks the guarded guest words again on later frames, so an in-process +guest reset that restores the stock instructions can be patched again safely. + +## 6. Clean ROM versus modified ROM startup + +The recomp-ui launcher intentionally does not enforce the clean whole-ROM SHA. +The launcher passes the selected `.nds` file to the runner, where the stronger +multi-layer detector decides safely. + +The runner rules are: + +- exact expected whole-ROM SHA: normal exact-content build +- different whole-ROM SHA + canonical clean header/ARM9/ARM7 checksum of the + expected base: clean build may be reused for a data-only variant +- known code-modified executable checksum: runtime layout may be trusted, but + the clean exact-content build is not automatically reused +- unknown checksum + supported header: candidate base may be identified, but + Aim/Morph/Widescreen host access and clean-build SHA bypass stay disabled +- unknown game code/revision or malformed ARM image ranges: reject/fail closed + +The launcher SHA check is disabled specifically so it cannot reject a mod before +these runner rules execute. + +## 7. Content profiles and `base_profile` + +`config/mph_rom_profiles.json` schema 5 separates content identity from runtime +base identity. + +Canonical clean profiles reserve the seven base keys. Example: + +```json +"US1_0": { + "base_profile": "US1_0", + "known_clean": true, + "game_code": "AMHE", + "revision": 0, + "sha1": "...exact clean whole-ROM SHA-1..." +} +``` + +A future exact mod profile must use a distinct key and reference the compatible +base layout: + +```json +"US1_0_LAST_RAVEN": { + "base_profile": "US1_0", + "known_clean": false, + "game_code": "AMHE", + "revision": 0, + "sha1": "...exact Last Raven whole-ROM SHA-1...", + "program_id": "mph_amhe0_last_raven", + "coverage": "coverage/last-raven-entry-points.json", + "game_config": "config/game-last-raven.toml", + "fmv_runtime": false, + "fmv_runtime_bank": "mph_amhe0_last_raven_arm9_fmv_runtime", + "launcher_default_rom": "Metroid Prime Hunters - Last Raven.nds", + "adaptive_widescreen": true +} +``` + +The example is architectural only. Setting `adaptive_widescreen` true exposes +the feature in the content profile; it does **not** bypass the executable +compatibility gate. Until Last Raven has an explicitly registered compatible +checksum, its host projection/Aim/Morph writes remain disabled. + +## 8. EU1.1 exact-content identity + +Current clean EU1.1 profile: + +- profile: `EU1_1` +- base profile: `EU1_1` +- game code: `AMHP` +- revision: `1` +- whole-ROM SHA-1: `bdcd1dea293e24c98d4c481430e90d21198985a5` +- default launcher ROM: `Metroid Prime Hunters (Europe Rev 1).nds` +- Adaptive Widescreen: exposed and revision-aware +- FMV runtime bank: disabled until an EU1.1-specific capture is validated + +Reserved EU1.1 runtime bank identity: + +- config: `config/mph_amhp1_arm9_fmv_runtime.toml` +- capture: `generated/EU1_1/capture/mph_amhp1_arm9_fmv_runtime.bin` +- bank: `mph_amhp1_arm9_fmv_runtime` + +No US1.0 FMV runtime capture is reused for EU1.1. + +## 9. Upstream Wi-Fi firmware-state persistence + +The branch integrates the upstream `5abcfee` Wi-Fi persistence behavior while +preserving the multi-ROM launcher generation and SHA policy. + +The launcher now assigns mutable firmware state under: + +`%APPDATA%\MetroidPrimeHuntersRecomp` + +using separate files for generated and retail firmware modes: + +- `firmware-generated.bin` +- `firmware-retail.bin` + +The selected state file is passed to the runner through +`--firmware-state-path`. Wi-Fi settings, WFC updates and console/game-card +pairing can therefore persist across normal launches and guest shutdowns. + +The dashboard console-MAC display prefers the persisted mutable firmware state +after it exists, falling back to the generated installation identity on the +first generated-firmware launch. + +This required updating the pinned ndsrecomp revision to: + +`6c6a03bdcf99093f64555c4d05d16e522dc58634` + +The new upstream runner still contains old US1.0 SHA/address assumptions in its +baseline source, so the local multi-ROM patch stack remains authoritative and +replaces them during the build. + +## 10. Preparing an exact content profile + +The preparation path intentionally remains exact-content gated. This protects +generated code provenance; it is not the runtime selector. + +Example EU1.1 preparation: + +```bash +python tools/prepare_mph.py \ + --version EU1_1 \ + --rom "/path/to/Metroid Prime Hunters (Europe Rev 1).nds" \ + --coverage coverage/eu11-bootstrap-entry-points.json \ + --out generated/EU1_1/inputs +``` + +`prepare_mph.py` checks: + +- exact whole-ROM SHA-1 +- expected ROM size +- game code at `0x0C` +- revision at `0x1E` +- coverage `game_sha1` + +It then extracts ARM9, ARM7 and overlays and emits content-specific seed +configs. + +## 11. Coverage and capture safety + +Static and runtime promotion is profile/content aware. + +For non-US content, traces carry both: + +- content profile key +- exact whole-ROM SHA-1 + +Main-image geometry is derived from the selected prepared ARM9/ARM7 configs, +not reused from US1.0 constants. + +FMV/runtime capture promotion verifies exact content provenance. A capture from +one content SHA must not be promoted into another content profile. + +## 12. CI and runtime safety tests + +CI downloads the current melonPrimeDS `develop_hud` detector/address table and +checks the registry against them, then fetches the exact pinned ndsrecomp +revision and applies the patch stack twice to verify idempotency. + +The runtime harness uses synthetic, non-copyrighted ROM images constructed to +produce the canonical melonPrimeDS executable checksums. It verifies: + +- all seven runtime profiles dispatch the correct Aim/Morph addresses +- authoritative checksums allow host accesses +- header-only fallback does not allow host RAM/code access +- unsupported revisions fail closed +- malformed ARM image ranges fail closed +- known-clean SHA/header contradictions fail closed +- canonical executable-equivalent data variants may pass the clean SHA gate +- known code-modified variants receive the correct runtime base but cannot + automatically reuse the clean exact-content build +- Adaptive Widescreen address triples match melonPrimeDS for all seven revisions +- patched `title_patches.cpp`, `frontend.cpp` and `main.cpp` compile against the + pinned runner +- US1.0/EU1.1 launcher generation and exact-content ROM checkers compile + +At the time of this update, `MPH Multi-ROM Static Checks` run #72 passes every +main validation step. + +## 13. Remaining work for full multi-ROM support + +The detector/address architecture is prepared for all seven base revisions, but +full player-facing support still requires exact content work per ROM: + +1. add verified clean content profiles for US1.1, EU1.0, JP1.0, JP1.1 and KR1.0 +2. prepare/extract each exact ROM +3. capture deterministic coverage and promote it under that exact SHA +4. generate revision-specific ARM9/ARM7/overlay banks +5. validate boot, Adventure, pause, save/load and multiplayer-menu paths +6. validate Prime Controls, Direct Mouse Aim and Adaptive Widescreen semantics + on real execution +7. compare checkpoints against reference/native execution +8. capture revision-specific FMV runtime code only where interpreter fallback + needs optimization +9. regression-test US1.0 after each new profile + +For a modified ROM, additionally: + +1. compute the exact whole-ROM SHA-1 +2. compute the melonPrimeDS-compatible header+ARM9+ARM7 CRC32 +3. determine and validate its runtime base layout +4. if code-modified, register the executable checksum only after Aim/Morph/ + Widescreen address compatibility is established +5. add a distinct content profile with `base_profile` +6. generate/promote coverage and banks under that mod's exact content identity + +Do not guess an unknown mod as US1.0 simply because its filename, region or +header resembles US1.0. diff --git a/game.toml b/game.toml index 5bc7202..1d99a24 100644 --- a/game.toml +++ b/game.toml @@ -18,7 +18,8 @@ antialiasing = 0 # The shared host-side adaptive renderer expands only the upper 3D viewport to # 21:9. The lower touchscreen remains native 256x192 and independently -# clickable. The exact game SHA-1 above gates this title-owned capability. +# clickable. Runtime executable compatibility, not whole-ROM SHA-1, gates the +# title-owned projection/culling patch. adaptive_widescreen = "top" adaptive_capability = "top" adaptive_width = 448 @@ -61,7 +62,7 @@ size = 0x00028464 [framework] path = "../ndsrecomp" -pin = "46b12e6c18dea47f87d2c1f98c3054149dcbca5d" +pin = "6c6a03bdcf99093f64555c4d05d16e522dc58634" branch = "main" [reference.mphread] diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index f4fe4a7..cbf4a90 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -9,11 +9,58 @@ find_package(SDL2 CONFIG REQUIRED) set(NDSRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../ndsrecomp" CACHE PATH "Path to the ndsrecomp framework checkout (for the shared SHA-1 helper)") +set(MPH_LAUNCHER_ROM_SHA1 + "90164d1ac127ee5f9815ea4ae7de798c7b5fc629" CACHE STRING + "Clean retail ROM SHA-1 metadata rendered for this profile-specific launcher") +set(MPH_LAUNCHER_REGION "USA" CACHE STRING + "Region label shown by the profile-specific launcher") +set(MPH_LAUNCHER_DEFAULT_ROM "Metroid Prime Hunters.nds" CACHE STRING + "Default ROM filename offered by this profile-specific launcher") -add_executable(mph-recomp-ui launcher_main.cpp +# launcher_main.cpp tracks the current upstream launcher, including HD Rendering +# and persistent firmware/WFC state. Generate a profile-specific TU with only +# the multi-ROM identity transformations layered on top. Runtime acceptance is +# deliberately NOT an exact whole-ROM SHA gate; nds_runner owns the +# executable-compatibility + actual-content decision. +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/launcher_main.cpp" MPH_LAUNCHER_SOURCE) + +function(mph_launcher_replace_required old new description) + string(FIND "${MPH_LAUNCHER_SOURCE}" "${old}" _mph_replace_pos) + if(_mph_replace_pos EQUAL -1) + message(FATAL_ERROR + "launcher_main.cpp no longer contains ${description}") + endif() + string(REPLACE "${old}" "${new}" + _mph_replaced_source "${MPH_LAUNCHER_SOURCE}") + set(MPH_LAUNCHER_SOURCE "${_mph_replaced_source}" PARENT_SCOPE) +endfunction() + +mph_launcher_replace_required( + "90164d1ac127ee5f9815ea4ae7de798c7b5fc629" + "${MPH_LAUNCHER_ROM_SHA1}" + "the US1.0 clean SHA-1 metadata") +mph_launcher_replace_required( + "game.known_sha1_hex = sha1;\n game.num_known_sha1 = std::size(sha1);" + "// MPH_MULTIROM_CONTENT_GATE: recomp-ui must not reject a modified ROM\n // before nds_runner can apply the base/executable compatibility detector.\n // The runner remains the authoritative fail-closed content gate.\n game.known_sha1_hex = nullptr;\n game.num_known_sha1 = 0;" + "the recomp-ui whole-ROM SHA-1 gate") +mph_launcher_replace_required( + "game.region = \"USA\";" + "game.region = \"${MPH_LAUNCHER_REGION}\";" + "the USA region baseline") +mph_launcher_replace_required( + "exe / \"Metroid Prime Hunters.nds\";" + "exe / \"${MPH_LAUNCHER_DEFAULT_ROM}\";" + "the default MPH ROM filename") + +set(MPH_PROFILE_LAUNCHER_SOURCE + "${CMAKE_CURRENT_BINARY_DIR}/launcher_main_profile.cpp") +file(WRITE "${MPH_PROFILE_LAUNCHER_SOURCE}" "${MPH_LAUNCHER_SOURCE}") + +add_executable(mph-recomp-ui "${MPH_PROFILE_LAUNCHER_SOURCE}" "${NDSRECOMP_ROOT}/recompiler/support/sha1.cpp") target_include_directories(mph-recomp-ui PRIVATE - "${NDSRECOMP_ROOT}/recompiler/support") + "${NDSRECOMP_ROOT}/recompiler/support" + "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(mph-recomp-ui PRIVATE SDL2::SDL2) set(RECOMP_UI_ROOT "F:/Projects/recomp-ui" CACHE PATH @@ -22,10 +69,7 @@ set(RECOMP_UI_ROOT "F:/Projects/recomp-ui" CACHE PATH enable_testing() add_executable(mph-mod-provider-test tests/launcher_mod_provider_test.cpp "${NDSRECOMP_ROOT}/recompiler/support/sha1.cpp") -# The test #includes launcher_main.cpp whole, so it needs the SAME sha1.h the -# launcher gets -- ndsrecomp's gba::sha1, not recomp-ui/src/common/sha1.h, -# which is a different API and would be picked up first from the include path -# below. Listed ahead of the recomp-ui directories for that reason. +# The test #includes launcher_main.cpp whole, so it needs ndsrecomp's sha1.h. target_include_directories(mph-mod-provider-test PRIVATE "${NDSRECOMP_ROOT}/recompiler/support" "${CMAKE_CURRENT_SOURCE_DIR}" diff --git a/launcher/recomp-ui/launcher_main.cpp b/launcher/recomp-ui/launcher_main.cpp index d4fadc3..8258166 100644 --- a/launcher/recomp-ui/launcher_main.cpp +++ b/launcher/recomp-ui/launcher_main.cpp @@ -17,6 +17,13 @@ namespace { struct ModState { bool adaptive_widescreen = true; + // HD rendering. Off by default: it costs GPU time and VRAM, and the + // faithful native output stays the reference. internal_resolution + // multiplies 3D sample density; texture_upscale filters each decoded DS + // texture once on a cache miss. Both are inert when hd_rendering is off. + bool hd_rendering = false; + int internal_resolution = 2; + int texture_upscale = 2; bool mouse_aim = true; int mouse_sensitivity = 30; bool mouse_invert_y = false; @@ -94,6 +101,24 @@ struct ModState { std::string last_error; }; +struct HdChoice { + int value; + const char* label; +}; + +constexpr std::array kInternalResolutionChoices{{ + {1, "1x (native)"}, + {2, "2x"}, + {3, "3x"}, + {4, "4x"}, +}}; + +constexpr std::array kTextureUpscaleChoices{{ + {1, "Off"}, + {2, "2x"}, + {4, "4x"}, +}}; + struct SensitivityChoice { int percent; const char* label; @@ -296,6 +321,27 @@ std::string read_identity_mac(const std::filesystem::path& bios_dir) { return text; } +std::filesystem::path firmware_state_path( + const std::filesystem::path& settings_path, bool generated) { + return settings_path.parent_path() / + (generated ? "firmware-generated.bin" : "firmware-retail.bin"); +} + +std::string read_firmware_state_mac(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary); + if (!file) return {}; + file.seekg(0x36, std::ios::beg); + unsigned char mac[6]{}; + file.read(reinterpret_cast(mac), sizeof(mac)); + if (file.gcount() != static_cast(sizeof(mac)) || + (mac[0] & 0x01u)) + return {}; + char text[32]; + std::snprintf(text, sizeof(text), "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return text; +} + template void copy_text(char (&target)[N], const char* source) { std::snprintf(target, N, "%s", source ? source : ""); @@ -328,6 +374,19 @@ void load_mod_state(ModState& state) { settings_version = static_cast(parsed); } else if (key == "adaptive_widescreen") { state.adaptive_widescreen = value != "false"; + } else if (key == "hd_rendering") { + state.hd_rendering = value == "true"; + } else if (key == "internal_resolution") { + char* end = nullptr; + const long parsed = std::strtol(value.c_str(), &end, 10); + if (end && *end == 0 && parsed >= 1 && parsed <= 4) + state.internal_resolution = static_cast(parsed); + } else if (key == "texture_upscale") { + char* end = nullptr; + const long parsed = std::strtol(value.c_str(), &end, 10); + if (end && *end == 0 && + (parsed == 1 || parsed == 2 || parsed == 4)) + state.texture_upscale = static_cast(parsed); } else if (key == "mouse_aim") { state.mouse_aim = value != "false"; } else if (key == "mouse_sensitivity") { @@ -410,6 +469,10 @@ bool save_mod_state(ModState& state) { file << "settings_version=2\n" << "adaptive_widescreen=" << (state.adaptive_widescreen ? "true" : "false") << '\n' + << "hd_rendering=" + << (state.hd_rendering ? "true" : "false") << '\n' + << "internal_resolution=" << state.internal_resolution << '\n' + << "texture_upscale=" << state.texture_upscale << '\n' << "mouse_aim=" << (state.prime_controls ? "true" : "false") << '\n' << "mouse_sensitivity=" << state.mouse_sensitivity << '\n' @@ -446,12 +509,12 @@ bool save_mod_state(ModState& state) { // directly under the controller card. Only the two real gameplay mods // remain here. int mod_feature_count(void*) { - return 2; + return 3; } int mod_feature_get(void* context, int index, RecompLauncherCModFeature* output) { - if (!context || !output || index < 0 || index > 1) return 0; + if (!context || !output || index < 0 || index > 2) return 0; const auto* state = static_cast(context); std::memset(output, 0, sizeof(*output)); if (index == 0) { @@ -469,6 +532,27 @@ int mod_feature_get(void* context, int index, copy_text(output->status, state->adaptive_widescreen ? "Enabled" : "Disabled"); output->enabled = state->adaptive_widescreen ? 1 : 0; + } else if (index == 2) { + copy_text(output->id, "hd-rendering"); + copy_text(output->package_id, "mph-hd-rendering"); + copy_text(output->package_version, "0.1.0"); + copy_text(output->package_name, "MPH HD Rendering"); + copy_text(output->name, "HD Rendering"); + copy_text(output->author, "ndsrecomp"); + copy_text( + output->description, + "Renders the 3D engine above one sample per DS pixel and " + "filters decoded textures, so the widescreen image gains detail " + "instead of just area. The 2D layers stay native, exactly as the " + "hardware draws them."); + copy_text(output->source_name, "Hyllian xBR-lv2 (MIT)"); + copy_text(output->source_url, + "https://github.com/libretro/glsl-shaders"); + copy_text(output->group, "Display enhancements"); + copy_text(output->status, + state->hd_rendering ? "Enabled" : "Disabled"); + output->enabled = state->hd_rendering ? 1 : 0; + output->option_count = 2; } else { copy_text(output->id, "prime-controls"); copy_text(output->package_id, "mph-prime-controls"); @@ -512,6 +596,11 @@ int mod_feature_enable(void* context, const char* package_id, state->mouse_aim = state->prime_controls; return 1; } + if (std::strcmp(package_id, "mph-hd-rendering") == 0 && + std::strcmp(feature_id, "hd-rendering") == 0) { + state->hd_rendering = enabled != 0; + return 1; + } return 0; } @@ -528,6 +617,41 @@ int mod_feature_option_get(void* context, const char* package_id, RecompLauncherCModOption* output) { if (!context || !package_id || !feature_id || !output || index < 0) return 0; + if (std::strcmp(package_id, "mph-hd-rendering") == 0 && + std::strcmp(feature_id, "hd-rendering") == 0) { + if (index > 1) return 0; + const auto* hd = static_cast(context); + std::memset(output, 0, sizeof(*output)); + if (index == 0) { + copy_text(output->id, "internal-resolution"); + copy_text(output->label, "Internal resolution"); + copy_text(output->description, + "Sample density of the 3D engine. Costs GPU time and " + "VRAM; 2D layers are unaffected."); + copy_text(output->group, "Resolution"); + std::snprintf(output->value, sizeof(output->value), "%d", + hd->internal_resolution); + copy_text(output->default_value, "2"); + output->type = RECOMP_MOD_OPTION_CHOICE; + output->choice_count = + static_cast(kInternalResolutionChoices.size()); + return 1; + } + copy_text(output->id, "texture-upscale"); + copy_text(output->label, "Texture upscaling"); + copy_text(output->description, + "Filters each decoded DS texture once when it enters the " + "cache, so higher internal resolution shows detail rather " + "than larger texels."); + copy_text(output->group, "Textures"); + std::snprintf(output->value, sizeof(output->value), "%d", + hd->texture_upscale); + copy_text(output->default_value, "2"); + output->type = RECOMP_MOD_OPTION_CHOICE; + output->choice_count = + static_cast(kTextureUpscaleChoices.size()); + return 1; + } if (std::strcmp(package_id, "mph-prime-controls") != 0 || std::strcmp(feature_id, "prime-controls") != 0 || index >= 4 + static_cast(kBindingOptions.size()) + @@ -615,6 +739,30 @@ int mod_feature_choice_get(void*, const char* package_id, int index, RecompLauncherCModChoice* output) { if (!package_id || !feature_id || !option_id || !output || index < 0) return 0; + if (std::strcmp(package_id, "mph-hd-rendering") == 0 && + std::strcmp(feature_id, "hd-rendering") == 0) { + if (std::strcmp(option_id, "internal-resolution") == 0) { + if (index >= static_cast(kInternalResolutionChoices.size())) + return 0; + std::memset(output, 0, sizeof(*output)); + const HdChoice& choice = kInternalResolutionChoices[index]; + std::snprintf(output->value, sizeof(output->value), "%d", + choice.value); + copy_text(output->label, choice.label); + return 1; + } + if (std::strcmp(option_id, "texture-upscale") == 0) { + if (index >= static_cast(kTextureUpscaleChoices.size())) + return 0; + std::memset(output, 0, sizeof(*output)); + const HdChoice& choice = kTextureUpscaleChoices[index]; + std::snprintf(output->value, sizeof(output->value), "%d", + choice.value); + copy_text(output->label, choice.label); + return 1; + } + return 0; + } if (std::strcmp(package_id, "mph-prime-controls") != 0 || std::strcmp(feature_id, "prime-controls") != 0) { return 0; @@ -663,6 +811,30 @@ int mod_feature_set_option(void* context, const char* package_id, const char* value) { if (!context || !package_id || !feature_id || !option_id || !value) return 0; + auto* hd_state = static_cast(context); + if (std::strcmp(package_id, "mph-hd-rendering") == 0 && + std::strcmp(feature_id, "hd-rendering") == 0) { + char* hd_end = nullptr; + const long hd_parsed = std::strtol(value, &hd_end, 10); + if (!hd_end || *hd_end != 0) return 0; + if (std::strcmp(option_id, "internal-resolution") == 0) { + for (const HdChoice& choice : kInternalResolutionChoices) { + if (choice.value != hd_parsed) continue; + hd_state->internal_resolution = static_cast(hd_parsed); + return 1; + } + return 0; + } + if (std::strcmp(option_id, "texture-upscale") == 0) { + for (const HdChoice& choice : kTextureUpscaleChoices) { + if (choice.value != hd_parsed) continue; + hd_state->texture_upscale = static_cast(hd_parsed); + return 1; + } + return 0; + } + return 0; + } if (std::strcmp(package_id, "mph-prime-controls") != 0 || std::strcmp(feature_id, "prime-controls") != 0) { return 0; @@ -908,6 +1080,8 @@ bool launch_runner(const std::filesystem::path& game_dir, const char* rom, std::filesystem::create_directories(bios, error); } } + const std::filesystem::path firmware_state = firmware_state_path( + mods.settings_path, no_dumps_mode); std::wstring command = quote(runner.wstring()) + L" " + quote(bios.wstring()) + @@ -919,6 +1093,12 @@ bool launch_runner(const std::filesystem::path& game_dir, const char* rom, : L"stacked") + L" --adaptive-widescreen " + (adaptive ? L"top" : L"none") + + // Inert unless the HD mod is on, so the faithful native output stays + // the default for anyone who never opens the Mods page. + L" --internal-resolution " + + std::to_wstring(mods.hd_rendering ? mods.internal_resolution : 1) + + L" --texture-upscale " + + std::to_wstring(mods.hd_rendering ? mods.texture_upscale : 1) + L" --supersampling " + std::to_wstring(supersampling) + L" --antialiasing " + std::to_wstring(antialiasing) + L" --relative-mouse-touch " + @@ -939,6 +1119,7 @@ bool launch_runner(const std::filesystem::path& game_dir, const char* rom, // a player launching through the UI expects Nintendo WFC to work, // so the launcher turns it on and points it at Wiimmfi. L" --network on --wfc on --wfc-provider wiimmfi"; + append_arg(command, L"--firmware-state-path", firmware_state.wstring()); // beads-yjp.16: the firmware console nickname. Passed only when the // player both configured a name and left the identity feature on; // otherwise the runner leaves the firmware's own name alone (a retail @@ -1005,15 +1186,31 @@ int main(int argc, char** argv) { copy_text(settings.bios_path, mod_state.bios_path.c_str()); copy_text(settings.player_name, mod_state.player_name.c_str()); - // Read-only identity detail for the dashboard ONLINE card. Captured once - // at startup: the MAC only changes on the first-ever no-dump launch. - const std::string identity_mac = read_identity_mac( + // Read-only identity detail for the dashboard ONLINE card. Prefer the + // mutable profile once it has been seeded; generated mode falls back to + // the installation identity before that first launch. + const std::filesystem::path selected_bios = mod_state.bios_path.empty() ? mod_state.default_bios_dir - : bios_dir_from_setting(mod_state.bios_path.c_str())); + : bios_dir_from_setting(mod_state.bios_path.c_str()); + bool generated_identity = false; + if (mod_state.bios_path.empty()) { + bool conventional_dumps = true; + for (const NdsDump& dump : kNdsDumps) { + if (!std::filesystem::is_regular_file(selected_bios / dump.file)) + conventional_dumps = false; + } + generated_identity = !conventional_dumps; + } + std::string identity_mac = read_firmware_state_mac(firmware_state_path( + mod_state.settings_path, generated_identity)); + if (identity_mac.empty() && generated_identity) + identity_mac = read_identity_mac(selected_bios); const std::string identity_detail = !identity_mac.empty() - ? "Console MAC: " + identity_mac + " (generated identity)" + ? "Console MAC: " + identity_mac + + (generated_identity + ? " (generated identity)" : " (firmware profile)") : std::string("Console MAC: from the firmware dump, or created " "on the first no-dump launch."); diff --git a/launcher/recomp-ui/tests/launcher_mod_provider_test.cpp b/launcher/recomp-ui/tests/launcher_mod_provider_test.cpp index 638b4f5..80e8a1b 100644 --- a/launcher/recomp-ui/tests/launcher_mod_provider_test.cpp +++ b/launcher/recomp-ui/tests/launcher_mod_provider_test.cpp @@ -13,6 +13,34 @@ bool require(bool condition, const char* label) { } // namespace int main() { + { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / + "mph_firmware_state_launcher_test"; + const std::filesystem::path settings = root / "mods.ini"; + const std::filesystem::path generated = + firmware_state_path(settings, true); + const std::filesystem::path retail = + firmware_state_path(settings, false); + if (!require(generated.filename() == "firmware-generated.bin", + "generated firmware state path")) return 85; + if (!require(retail.filename() == "firmware-retail.bin", + "retail firmware state path")) return 86; + std::filesystem::create_directories(root); + std::vector bytes(128u * 1024u, 0xFFu); + const unsigned char mac[6] = {0x00, 0x09, 0xBF, 0x12, 0x34, 0x56}; + std::memcpy(bytes.data() + 0x36, mac, sizeof(mac)); + { + std::ofstream file(generated, std::ios::binary); + file.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + } + if (!require(read_firmware_state_mac(generated) == + "00:09:BF:12:34:56", + "firmware state identity display")) return 87; + std::filesystem::remove_all(root); + } + { ModState legacy_state{}; legacy_state.settings_path = @@ -41,9 +69,11 @@ int main() { if (!require(provider.feature_count != nullptr, "feature_count callback")) return 3; - // Two gameplay mods; the online identity is a dashboard card, not a mod. + // Three gameplay mods; the online identity is a dashboard card, not a + // mod. Index 1 stays prime controls so the assertions below are + // unaffected by HD rendering being added at index 2. const int feature_count = provider.feature_count(provider.ctx); - if (!require(feature_count == 2, "feature_count == 2")) return 4; + if (!require(feature_count == 3, "feature_count == 3")) return 4; RecompLauncherCModFeature feature{}; if (!require(provider.feature_get(provider.ctx, 1, &feature), @@ -261,9 +291,9 @@ int main() { // The online identity is NOT a mod feature: it lives on the dashboard // ONLINE card (GameInfo.has_player_name + the NDS "identity" panel). - // Exactly the two gameplay mods remain. - if (!require(!provider.feature_get(provider.ctx, 2, &feature), - "exactly two mod features (identity is not a mod)")) { + // Exactly the three gameplay mods remain; index 3 must not resolve. + if (!require(!provider.feature_get(provider.ctx, 3, &feature), + "exactly three mod features (identity is not a mod)")) { return 37; } @@ -312,5 +342,57 @@ int main() { std::filesystem::remove(saved.settings_path); } + // HD rendering: off by default, so a player who never opens the Mods + // page gets the faithful native output. + { + RecompLauncherCModFeature hd{}; + if (!require(provider.feature_get(provider.ctx, 2, &hd), + "feature_get hd rendering")) return 60; + if (!require(std::strcmp(hd.id, "hd-rendering") == 0, + "hd feature id")) return 61; + if (!require(std::strcmp(hd.package_id, "mph-hd-rendering") == 0, + "hd package id")) return 62; + if (!require(hd.enabled == 0, "hd disabled by default")) return 63; + if (!require(hd.option_count == 2, "hd option count")) return 64; + + // Both options must reject values outside their choice list, so a + // hand-edited mods.ini cannot hand the runner a scale it refuses. + if (!require(provider.feature_set_option( + provider.ctx, "mph-hd-rendering", "hd-rendering", + "internal-resolution", "4") == 1, + "hd internal resolution accepts 4")) return 65; + if (!require(provider.feature_set_option( + provider.ctx, "mph-hd-rendering", "hd-rendering", + "internal-resolution", "8") == 0, + "hd internal resolution rejects 8")) return 66; + if (!require(provider.feature_set_option( + provider.ctx, "mph-hd-rendering", "hd-rendering", + "texture-upscale", "3") == 0, + "hd texture upscale rejects 3")) return 67; + if (!require(provider.feature_set_option( + provider.ctx, "mph-hd-rendering", "hd-rendering", + "texture-upscale", "4") == 1, + "hd texture upscale accepts 4")) return 68; + + ModState hd_saved{}; + hd_saved.settings_path = + std::filesystem::temp_directory_path() / + "mph_mod_provider_hd_settings.ini"; + hd_saved.hd_rendering = true; + hd_saved.internal_resolution = 3; + hd_saved.texture_upscale = 4; + if (!require(save_mod_state(hd_saved), "hd save")) return 69; + ModState hd_loaded{}; + hd_loaded.settings_path = hd_saved.settings_path; + load_mod_state(hd_loaded); + if (!require(hd_loaded.hd_rendering, "hd enable round trip")) + return 70; + if (!require(hd_loaded.internal_resolution == 3, + "hd internal resolution round trip")) return 71; + if (!require(hd_loaded.texture_upscale == 4, + "hd texture upscale round trip")) return 72; + std::filesystem::remove(hd_saved.settings_path); + } + return 0; } diff --git a/ndsrecomp.pin b/ndsrecomp.pin index 0d4b600..cfba97c 100644 --- a/ndsrecomp.pin +++ b/ndsrecomp.pin @@ -1 +1 @@ -46b12e6c18dea47f87d2c1f98c3054149dcbca5d +6c6a03bdcf99093f64555c4d05d16e522dc58634 diff --git a/src/main.cpp b/src/main.cpp index 19b7357..5c3c3d0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,14 +6,33 @@ #include #include +#ifndef MPH_BUILD_VERSION +#define MPH_BUILD_VERSION "US1_0" +#endif +#ifndef MPH_EXPECTED_SHA1 +#define MPH_EXPECTED_SHA1 "90164d1ac127ee5f9815ea4ae7de798c7b5fc629" +#endif +#ifndef MPH_EXPECTED_GAME_CODE +#define MPH_EXPECTED_GAME_CODE "AMHE" +#endif +#ifndef MPH_EXPECTED_REVISION +#define MPH_EXPECTED_REVISION 0 +#endif +#ifndef MPH_EXPECTED_SIZE +#define MPH_EXPECTED_SIZE 67108864 +#endif + namespace { constexpr const char* kDefaultRom = "Metroid Prime Hunters.nds"; -constexpr const char* kExpectedSha1 = - "90164d1ac127ee5f9815ea4ae7de798c7b5fc629"; +constexpr const char* kBuildVersion = MPH_BUILD_VERSION; +constexpr const char* kExpectedSha1 = MPH_EXPECTED_SHA1; constexpr const char* kExpectedTitle = "MP HUNTERS"; -constexpr const char* kExpectedGameCode = "AMHE"; -constexpr std::size_t kExpectedSize = 64u * 1024u * 1024u; +constexpr const char* kExpectedGameCode = MPH_EXPECTED_GAME_CODE; +constexpr unsigned kExpectedRevision = + static_cast(MPH_EXPECTED_REVISION); +constexpr std::size_t kExpectedSize = + static_cast(MPH_EXPECTED_SIZE); std::vector read_file(const std::string& path) { std::ifstream file(path, std::ios::binary | std::ios::ate); @@ -84,7 +103,7 @@ int main(int argc, char** argv) { valid = false; } if (title != kExpectedTitle || game_code != kExpectedGameCode || - revision != 0u) { + revision != kExpectedRevision) { std::fprintf(stderr, "ROM identity mismatch: title=%s code=%s revision=%u\n", title.c_str(), game_code.c_str(), revision); @@ -96,12 +115,15 @@ int main(int argc, char** argv) { valid = false; } if (!valid) { - std::fputs("refusing selection: this project is pinned to AMHE0\n", - stderr); + std::fprintf( + stderr, + "refusing selection: this build targets MPH profile %s only\n", + kBuildVersion); return 1; } std::puts("selection=metroid-prime-hunters"); + std::printf("profile=%s\n", kBuildVersion); std::printf("rom=%s\n", rom_path.c_str()); std::printf("title=%s game_code=%s revision=%u sha1=%s\n", title.c_str(), game_code.c_str(), revision, digest.c_str()); @@ -109,9 +131,6 @@ int main(int argc, char** argv) { read_u32(rom, 0x24u), read_u32(rom, 0x34u)); std::printf("arm9_size=0x%08x arm7_size=0x%08x\n", read_u32(rom, 0x2Cu), read_u32(rom, 0x3Cu)); - std::puts("reference=NoneGiven/MphRead (AMHE0-aware; non-matching recreation)"); std::puts("boot_status=authentic-firmware-and-cartridge"); - std::puts("attract_status=full-no-input-loop"); - std::puts("gameplay_status=celestial-archives-entry"); return 0; } diff --git a/tools/benchmark_mph_fmv.py b/tools/benchmark_mph_fmv.py index 5c36090..be5a1de 100644 --- a/tools/benchmark_mph_fmv.py +++ b/tools/benchmark_mph_fmv.py @@ -9,9 +9,17 @@ import os import subprocess import time +import tomllib from pathlib import Path import capture_mph_checkpoints as capture_lib +from mph_profile import ( + DEFAULT_PROFILE_FILE, + DEFAULT_VERSION, + load_profile, + resolve_repo_path, + verify_rom_identity, +) DEFAULT_TARGETS = [600, 1200, 1800, 2400, 3000, 3600, 4200] @@ -79,13 +87,43 @@ def milliseconds_per_frame(field: str) -> float: } +def verify_config(path: Path, profile: dict[str, object], version: str) -> None: + try: + with path.open("rb") as f: + document = tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise SystemExit(f"unable to read game config {path}: {exc}") from exc + game = document.get("game") + if not isinstance(game, dict): + raise SystemExit(f"game config has no [game] table: {path}") + expected = { + "id": profile["game_code"], + "revision": profile["revision"], + "rom_size": profile["rom_size"], + "sha1": profile["sha1"], + } + for key, value in expected.items(): + if game.get(key) != value: + raise SystemExit( + f"game config {path} game.{key}={game.get(key)!r}; " + f"expected {value!r} for {version}" + ) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--runner", type=Path, required=True) parser.add_argument("--bios", type=Path, required=True) parser.add_argument("--rom", type=Path, required=True) - parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--config", type=Path) parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--version", default=DEFAULT_VERSION) + parser.add_argument( + "--profiles", + type=Path, + default=DEFAULT_PROFILE_FILE, + help=f"ROM profile registry (default: {DEFAULT_PROFILE_FILE})", + ) parser.add_argument("--port", type=int, default=19873) parser.add_argument("--targets", type=int, nargs="+", default=DEFAULT_TARGETS) parser.add_argument( @@ -104,7 +142,12 @@ def main() -> int: parser.add_argument("--rip-from", type=int) parser.add_argument("--rip-to", type=int) parser.add_argument("--rip-interval-us", type=int, default=1000) - parser.add_argument("--adaptive", choices=("none", "top"), default="top") + parser.add_argument( + "--adaptive", + choices=("auto", "none", "top"), + default="auto", + help="auto follows the selected ROM profile's validated policy", + ) parser.add_argument("--supersampling", type=int, choices=(1, 2, 4), default=1) parser.add_argument("--antialiasing", type=int, choices=(0, 2, 4, 8), default=0) parser.add_argument( @@ -113,6 +156,26 @@ def main() -> int: ) args = parser.parse_args() + profile = load_profile(args.profiles.resolve(), args.version) + rom_path = args.rom.resolve() + rom_sha1 = verify_rom_identity(rom_path, profile, args.version) + config_path = ( + args.config.resolve() + if args.config is not None + else resolve_repo_path(str(profile["game_config"])).resolve() + ) + verify_config(config_path, profile, args.version) + + profile_adaptive = bool(profile["adaptive_widescreen"]) + if args.adaptive == "top" and not profile_adaptive: + raise SystemExit( + f"{args.version} does not have validated Adaptive Widescreen; " + "refusing an FMV/coverage capture with --adaptive top" + ) + adaptive = args.adaptive + if adaptive == "auto": + adaptive = "top" if profile_adaptive else "none" + targets = sorted(set(args.targets)) if not targets or targets[0] <= 0: parser.error("targets must contain positive VBlank counts") @@ -132,16 +195,16 @@ def main() -> int: "--port", str(args.port), "--rom", - str(args.rom.resolve()), + str(rom_path), "--config", - str(args.config.resolve()), + str(config_path), "--no-save", "--startup-mode", "automatic", "--screen-layout", "separate", "--adaptive-widescreen", - args.adaptive, + adaptive, "--supersampling", str(args.supersampling), "--antialiasing", @@ -160,6 +223,7 @@ def main() -> int: if args.instrument: environment["NDS_PROFILE_GPU"] = "1" environment["NDS_PROFILE_SCHED"] = "1" + stdout = (output / "runner.stdout.log").open("wb") stderr = (output / "runner.stderr.log").open("wb") process = subprocess.Popen( @@ -172,6 +236,11 @@ def main() -> int: ) report: dict[str, object] = { + "mph_profile": args.version, + "rom_sha1": rom_sha1, + "display_name": profile["display_name"], + "config": str(config_path), + "adaptive": adaptive, "command": command, "targets": targets, "samples": [], @@ -241,6 +310,7 @@ def main() -> int: encoding="utf-8", newline="\n", ) + if args.discover_static_misses: report["tier3_coverage"] = client.command( "tier3_coverage", max=262_144 diff --git a/tools/build-linux.sh b/tools/build-linux.sh index c2bd0bb..f6c8d74 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -1,169 +1,239 @@ #!/usr/bin/env bash -# Build a Metroid Prime Hunters Recomp Linux x86_64 AppImage. -# -# The current recomp-ui launcher is Windows-only, so this packages the title -# runner directly. Put a legally dumped Metroid Prime Hunters .nds and a bios/ -# folder beside the AppImage; AppRun auto-detects the ROM. set -euo pipefail -APP_NAME="MetroidPrimeHuntersRecomp" -TITLE_TARGET="metroidprimehuntersrecomp" -ROM_SHA1="90164d1ac127ee5f9815ea4ae7de798c7b5fc629" -RUNNER_NAME="nds_runner" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FRAMEWORK_ROOT="$(cd "$ROOT/../ndsrecomp" && pwd)" VERSION="0.1.0" -JOBS="$(nproc 2>/dev/null || echo 4)" -DO_PACKAGE=1 - -REPO="$(cd "$(dirname "$0")/.." && pwd)" -FRAMEWORK_ROOT="$(cd "$REPO/../ndsrecomp" && pwd)" -OUT="$REPO/release-linux" +MPH_VERSION="US1_0" +ROM_PATH="$ROOT/Metroid Prime Hunters.nds" +BUILD_DIR="" +RUNNER_BUILD_DIR="" +APPDIR="" +PACKAGE=1 +JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '4')" + +usage() { + cat <<'EOF' +Build Metroid Prime Hunters Recomp for Linux. + +Usage: + tools/build-linux.sh [options] + +Options: + --version VERSION Package version (default: 0.1.0) + --mph-version PROFILE MPH ROM profile (US1_0 or EU1_1; default: US1_0) + --rom PATH Path to the selected retail ROM + --build-dir PATH Game build directory + --runner-build PATH ndsrecomp runner build directory + --appdir PATH AppImage staging directory + --jobs N Parallel build jobs + --no-package Build runner only; do not create AppImage + -h, --help Show this help +EOF +} -while [ $# -gt 0 ]; do +while (($#)); do case "$1" in - --version) VERSION="$2"; shift 2;; - --jobs) JOBS="$2"; shift 2;; - --out) OUT="$2"; shift 2;; - --no-package) DO_PACKAGE=0; shift;; + --version) + VERSION="$2" + shift 2 + ;; + --mph-version) + MPH_VERSION="$2" + shift 2 + ;; + --rom) + ROM_PATH="$2" + shift 2 + ;; + --build-dir) + BUILD_DIR="$2" + shift 2 + ;; + --runner-build) + RUNNER_BUILD_DIR="$2" + shift 2 + ;; + --appdir) + APPDIR="$2" + shift 2 + ;; + --jobs) + JOBS="$2" + shift 2 + ;; + --no-package) + PACKAGE=0 + shift + ;; -h|--help) - sed -n '2,14p' "$0" + usage exit 0 ;; - *) echo "unknown arg: $1" >&2; exit 2;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; esac done -GAME_BUILD="$REPO/build-linux-release" -RUNNER_BUILD="$FRAMEWORK_ROOT/runner/build-mph-linux-release" -TITLE_BANK_DIR="$REPO/generated/recomp" +PROFILE_FILE="$ROOT/config/mph_rom_profiles.json" +readarray -t PROFILE_VALUES < <( + python3 - "$PROFILE_FILE" "$MPH_VERSION" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +key = sys.argv[2] +registry = json.loads(path.read_text(encoding="utf-8")) +profile = registry.get("profiles", {}).get(key) +if not isinstance(profile, dict): + choices = ", ".join(sorted(registry.get("profiles", {}))) + raise SystemExit(f"unknown MPH profile {key!r}; configured: {choices}") +for field in ( + "sha1", "game_config", "game_code", "revision", + "launcher_default_rom", "fmv_runtime_bank", +): + if field not in profile: + raise SystemExit(f"{key}: missing profile field {field}") +print(profile["sha1"]) +print(profile["game_config"]) +print(profile["game_code"]) +print(profile["revision"]) +print("1" if profile.get("fmv_runtime") else "0") +print(profile["launcher_default_rom"]) +print(profile["fmv_runtime_bank"]) +PY +) + +ROM_SHA1="${PROFILE_VALUES[0]}" +GAME_CONFIG_REL="${PROFILE_VALUES[1]}" +GAME_CODE="${PROFILE_VALUES[2]}" +REVISION="${PROFILE_VALUES[3]}" +FMV_RUNTIME="${PROFILE_VALUES[4]}" +DEFAULT_ROM_NAME="${PROFILE_VALUES[5]}" +FMV_RUNTIME_BANK="${PROFILE_VALUES[6]}" +GAME_CONFIG="$ROOT/$GAME_CONFIG_REL" + +if [[ "$ROM_PATH" == "$ROOT/Metroid Prime Hunters.nds" && "$MPH_VERSION" != "US1_0" ]]; then + ROM_PATH="$ROOT/$DEFAULT_ROM_NAME" +fi -cd "$REPO" -test -f "$FRAMEWORK_ROOT/recompiler/CMakeLists.txt" || { - echo "ERROR: sibling ndsrecomp checkout is missing." >&2 +if [[ ! -f "$ROM_PATH" ]]; then + printf 'ROM not found: %s\n' "$ROM_PATH" >&2 exit 1 -} -test -f "$REPO/Metroid Prime Hunters.nds" || { - echo "ERROR: verified Metroid Prime Hunters ROM is missing from the repo root." >&2 +fi +if [[ ! -f "$GAME_CONFIG" ]]; then + printf 'Game config not found: %s\n' "$GAME_CONFIG" >&2 exit 1 -} +fi + +if [[ -z "$BUILD_DIR" ]]; then + if [[ "$MPH_VERSION" == "US1_0" ]]; then + BUILD_DIR="$ROOT/build-linux-release" + else + BUILD_DIR="$ROOT/build-linux-release-$MPH_VERSION" + fi +fi +if [[ -z "$RUNNER_BUILD_DIR" ]]; then + if [[ "$MPH_VERSION" == "US1_0" ]]; then + RUNNER_BUILD_DIR="$FRAMEWORK_ROOT/runner/build-mph-linux-release" + else + RUNNER_BUILD_DIR="$FRAMEWORK_ROOT/runner/build-mph-linux-release-$MPH_VERSION" + fi +fi +if [[ -z "$APPDIR" ]]; then + if [[ "$MPH_VERSION" == "US1_0" ]]; then + APPDIR="$ROOT/release-stage/MetroidPrimeHuntersRecomp-linux-x86_64.AppDir" + else + APPDIR="$ROOT/release-stage/MetroidPrimeHuntersRecomp-$MPH_VERSION-linux-x86_64.AppDir" + fi +fi -echo "[1/4] configure title banks" -cmake -S "$REPO" -B "$GAME_BUILD" -G "Unix Makefiles" \ +if [[ "$MPH_VERSION" == "US1_0" ]]; then + TITLE_BANK_DIR="$ROOT/generated/recomp" +else + TITLE_BANK_DIR="$ROOT/generated/$MPH_VERSION/recomp" +fi + +printf 'Building MPH profile %s (%s rev %s)\n' "$MPH_VERSION" "$GAME_CODE" "$REVISION" + +cmake -S "$ROOT" -B "$BUILD_DIR" \ -DCMAKE_BUILD_TYPE=Release \ - -DNDSRECOMP_ROOT="$FRAMEWORK_ROOT" -echo "[2/4] build title banks" -cmake --build "$GAME_BUILD" --target "$TITLE_TARGET" -j"$JOBS" + -DNDSRECOMP_ROOT="$FRAMEWORK_ROOT" \ + -DMPH_VERSION="$MPH_VERSION" \ + -DMPH_ROM="$ROM_PATH" +cmake --build "$BUILD_DIR" --target metroidprimehuntersrecomp -j "$JOBS" + +python3 "$ROOT/tools/patch_ndsrecomp_mph_runtime.py" \ + --framework-root "$FRAMEWORK_ROOT" \ + --profiles "$PROFILE_FILE" -echo "[3/4] configure runner" -cmake -S "$FRAMEWORK_ROOT/runner" -B "$RUNNER_BUILD" -G "Unix Makefiles" \ +cmake -S "$FRAMEWORK_ROOT/runner" -B "$RUNNER_BUILD_DIR" \ -DCMAKE_BUILD_TYPE=Release \ -DNDS_BOOTSTRAP_FIRMWARE=ON \ -DNDS_TITLE_BANK_DIR="$TITLE_BANK_DIR" \ -DNDS_TITLE_ROM_SHA1="$ROM_SHA1" -echo " build runner" -cmake --build "$RUNNER_BUILD" -j"$JOBS" +cmake --build "$RUNNER_BUILD_DIR" -j "$JOBS" + +RUNNER="$RUNNER_BUILD_DIR/nds_runner" +if [[ ! -x "$RUNNER" ]]; then + printf 'Runner missing after build: %s\n' "$RUNNER" >&2 + exit 1 +fi -if [ "$DO_PACKAGE" = "0" ]; then - echo "done: $RUNNER_BUILD/$RUNNER_NAME" +if [[ "$FMV_RUNTIME" == "1" ]]; then + if ! grep -a -q "$FMV_RUNTIME_BANK" "$RUNNER"; then + printf 'Runner does not contain required FMV runtime bank %s.\n' \ + "$FMV_RUNTIME_BANK" >&2 + exit 1 + fi +fi + +if ((PACKAGE == 0)); then + printf 'Runner ready: %s\n' "$RUNNER" + printf 'Profile config: %s\n' "$GAME_CONFIG" exit 0 fi -BIN="$RUNNER_BUILD/$RUNNER_NAME" -test -f "$BIN" || { echo "ERROR: runner not built: $BIN" >&2; exit 1; } -strings "$BIN" | grep -q mph_arm9_fmv_runtime || { - echo "ERROR: runner does not contain the MPH FMV runtime bank." >&2 - exit 1 -} +rm -rf "$APPDIR" +mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/mph-recomp" "$APPDIR/bios" +cp "$RUNNER" "$APPDIR/usr/bin/nds_runner" +cp "$GAME_CONFIG" "$APPDIR/usr/share/mph-recomp/game.toml" +cp "$ROOT/packaging/BIOS_README.txt" "$APPDIR/bios/README.txt" +cp "$ROOT/README.md" "$APPDIR/README.md" +cp "$ROOT/LICENSE" "$APPDIR/LICENSE" -LINUXDEPLOY_URL=https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -LINUXDEPLOY_SHA=421ca71d5c69ea97c6309276232990d43df1dcece0edfaa26bbf926ff96ed12e -APPIMAGETOOL_URL=https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -APPIMAGETOOL_SHA=a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 - -TOOLS_DIR="$RUNNER_BUILD/appimage-tools" -mkdir -p "$TOOLS_DIR" "$OUT" -fetch_tool() { - local url="$1" sha="$2" dest="$3" - if [ ! -f "$dest" ] || [ "$(sha256sum "$dest" | awk '{print $1}')" != "$sha" ]; then - curl -fL --retry 3 "$url" -o "$dest.tmp" - printf '%s %s\n' "$sha" "$dest.tmp" | sha256sum -c - >/dev/null - mv "$dest.tmp" "$dest" - fi - chmod 0755 "$dest" -} -LINUXDEPLOY_BIN="$TOOLS_DIR/linuxdeploy-x86_64.AppImage" -APPIMAGETOOL_BIN="$TOOLS_DIR/appimagetool-x86_64.AppImage" -fetch_tool "$LINUXDEPLOY_URL" "$LINUXDEPLOY_SHA" "$LINUXDEPLOY_BIN" -fetch_tool "$APPIMAGETOOL_URL" "$APPIMAGETOOL_SHA" "$APPIMAGETOOL_BIN" - -WORK="$(mktemp -d)" -trap 'chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK"' EXIT -APPDIR="$WORK/AppDir" -mkdir -p "$APPDIR/usr/bin/bios" "$APPDIR/usr/share/applications" "$APPDIR/usr/share/icons/hicolor/256x256/apps" - -cp "$BIN" "$APPDIR/usr/bin/$RUNNER_NAME" -cp "$REPO/game.toml" "$APPDIR/usr/bin/game.toml" -cp "$REPO/README.md" "$APPDIR/usr/bin/README.md" -cp "$REPO/LICENSE" "$APPDIR/usr/bin/LICENSE" -cp "$REPO/packaging/BIOS_README.txt" "$APPDIR/usr/bin/bios/README.txt" - -python3 - "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_NAME.png" <<'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(t, d): - c = t + d - return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 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 -cat > "$APPDIR/usr/share/applications/$APP_NAME.desktop" < "$APPDIR/AppRun" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROM="${1:-}" +if [[ -z "$ROM" ]]; then + printf 'Usage: %s /path/to/MetroidPrimeHunters.nds\n' "$0" >&2 + exit 2 +fi +shift || true +exec "$HERE/usr/bin/nds_runner" "$HERE/bios" \ + --interactive \ + --rom "$ROM" \ + --config "$HERE/usr/share/mph-recomp/game.toml" \ + "$@" EOF +chmod +x "$APPDIR/AppRun" -cat > "$APPDIR/AppRun" <<'EOF' -#!/bin/sh -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 +APPIMAGE_TOOL="${APPIMAGE_TOOL:-appimagetool}" +if ! command -v "$APPIMAGE_TOOL" >/dev/null 2>&1; then + printf 'appimagetool not found; staged AppDir at %s\n' "$APPDIR" >&2 + exit 1 fi -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 - 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 + +if [[ "$MPH_VERSION" == "US1_0" ]]; then + OUTPUT="$ROOT/release-stage/MetroidPrimeHuntersRecomp-linux-v${VERSION}-x86_64.AppImage" +else + OUTPUT="$ROOT/release-stage/MetroidPrimeHuntersRecomp-${MPH_VERSION}-linux-v${VERSION}-x86_64.AppImage" fi -exec "$HERE/usr/bin/nds_runner" "$@" -EOF -chmod +x "$APPDIR/AppRun" "$APPDIR/usr/bin/$RUNNER_NAME" - -echo "[4/4] package AppImage" -"$LINUXDEPLOY_BIN" --appimage-extract-and-run --appdir "$APPDIR" --executable "$APPDIR/usr/bin/$RUNNER_NAME" \ - --desktop-file "$APPDIR/usr/share/applications/$APP_NAME.desktop" \ - --icon-file "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_NAME.png" >/dev/null - -APP="$OUT/$APP_NAME-linux-v$VERSION-x86_64.AppImage" -rm -f "$APP" -ARCH=x86_64 "$APPIMAGETOOL_BIN" --appimage-extract-and-run "$APPDIR" "$APP" >/dev/null -chmod +x "$APP" -bash "$REPO/tools/test_appimage_layout.sh" "$APPDIR" -sha256sum "$APP" +ARCH=x86_64 "$APPIMAGE_TOOL" "$APPDIR" "$OUTPUT" +printf 'Created %s\n' "$OUTPUT" \ No newline at end of file diff --git a/tools/build-windows.ps1 b/tools/build-windows.ps1 index 3f75d53..fe66d92 100644 --- a/tools/build-windows.ps1 +++ b/tools/build-windows.ps1 @@ -1,22 +1,26 @@ <# -Build and package the Metroid Prime Hunters Recomp Windows release. +Build Metroid Prime Hunters Recomp for one configured retail revision. -This script builds the title banks, the sibling ndsrecomp runner, and the -Windows recomp-ui launcher, then stages a portable ZIP with tools\make_release.ps1. -It does not publish a release. +US1_0 keeps the existing release paths. Non-US profiles use isolated generated +banks, a revision-specific game config, a profile-specific launcher +identity/policy, and the shared runtime base-profile / executable-compatibility +detector for Prime Controls/direct mouse aim. Usage: powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` - tools\build-windows.ps1 -Version 0.1.0 + tools\build-windows.ps1 -Version 0.3.0 -MphVersion EU1_1 ` + -RomPath 'D:\ROMs\Metroid Prime Hunters (Europe) (Rev 1).nds' #> param( [string]$Version = '0.1.0', + [string]$MphVersion = 'US1_0', + [string]$RomPath = '', [string]$CMake = 'C:\msys64\mingw64\bin\cmake.exe', [string]$Generator = 'Ninja', [int]$Jobs = 12, - [string]$GameBuildDir = 'build-release', - [string]$RunnerBuildDir = '..\ndsrecomp\runner\build-mph-release', - [string]$LauncherBuildDir = 'launcher\recomp-ui\build-release', + [string]$GameBuildDir = '', + [string]$RunnerBuildDir = '', + [string]$LauncherBuildDir = '', [string]$RuntimeBinDir = 'C:\msys64\mingw64\bin', [string]$RecompUiRoot = 'F:\Projects\recomp-ui' ) @@ -28,46 +32,131 @@ if (-not (Test-Path -LiteralPath $cmakePath)) { throw "CMake not found: $cmakePath" } +$profileFile = Join-Path $root 'config\mph_rom_profiles.json' +$registry = Get-Content -LiteralPath $profileFile -Raw | ConvertFrom-Json +$profileProperty = $registry.profiles.PSObject.Properties[$MphVersion] +if ($null -eq $profileProperty) { + $choices = @($registry.profiles.PSObject.Properties.Name) -join ', ' + throw "Unknown MPH profile '$MphVersion'. Configured profiles: $choices" +} +$profile = $profileProperty.Value +$romSha1 = [string]$profile.sha1 +$region = [string]$profile.region +$launcherDefaultRom = [string]$profile.launcher_default_rom +$launcherAdaptive = if ([bool]$profile.adaptive_widescreen) { 'ON' } else { 'OFF' } +$fmvRuntimeBank = [string]$profile.fmv_runtime_bank +if ([string]::IsNullOrWhiteSpace($fmvRuntimeBank)) { + throw "Profile $MphVersion has no FMV runtime bank identity." +} +$gameConfig = [IO.Path]::GetFullPath( + (Join-Path $root ([string]$profile.game_config))) + +if ([string]::IsNullOrWhiteSpace($RomPath)) { + $RomPath = Join-Path $root $launcherDefaultRom +} +$romFull = [IO.Path]::GetFullPath($RomPath) +if (-not (Test-Path -LiteralPath $romFull)) { + throw "ROM not found: $romFull" +} + +if ([string]::IsNullOrWhiteSpace($GameBuildDir)) { + if ($MphVersion -eq 'US1_0') { + $GameBuildDir = 'build-release' + } else { + $GameBuildDir = "build-release-$MphVersion" + } +} +if ([string]::IsNullOrWhiteSpace($RunnerBuildDir)) { + if ($MphVersion -eq 'US1_0') { + $RunnerBuildDir = '..\ndsrecomp\runner\build-mph-release' + } else { + $RunnerBuildDir = "..\ndsrecomp\runner\build-mph-release-$MphVersion" + } +} +if ([string]::IsNullOrWhiteSpace($LauncherBuildDir)) { + if ($MphVersion -eq 'US1_0') { + $LauncherBuildDir = 'launcher\recomp-ui\build-release' + } else { + $LauncherBuildDir = "launcher\recomp-ui\build-release-$MphVersion" + } +} + $frameworkRoot = [IO.Path]::GetFullPath((Join-Path $root '..\ndsrecomp')) $gameBuild = [IO.Path]::GetFullPath((Join-Path $root $GameBuildDir)) $runnerBuild = [IO.Path]::GetFullPath((Join-Path $root $RunnerBuildDir)) $launcherBuild = [IO.Path]::GetFullPath((Join-Path $root $LauncherBuildDir)) -$titleBankDir = [IO.Path]::GetFullPath((Join-Path $root 'generated\recomp')) -$romSha1 = '90164d1ac127ee5f9815ea4ae7de798c7b5fc629' +if ($MphVersion -eq 'US1_0') { + $titleBankDir = [IO.Path]::GetFullPath((Join-Path $root 'generated\recomp')) +} else { + $titleBankDir = [IO.Path]::GetFullPath( + (Join-Path $root "generated\$MphVersion\recomp")) +} + +$patchPython = Join-Path $root '.venv\Scripts\python.exe' +if (-not (Test-Path -LiteralPath $patchPython)) { + $patchPython = 'python' +} Push-Location $root try { + Write-Host "Building MPH profile $MphVersion ($($profile.game_code) rev $($profile.revision))" + Write-Host "Launcher adaptive widescreen: $launcherAdaptive" + Write-Host "FMV runtime bank identity: $fmvRuntimeBank" + & $cmakePath -G $Generator -S $root -B $gameBuild ` -DCMAKE_BUILD_TYPE=Release ` - -DNDSRECOMP_ROOT="$frameworkRoot" + -DNDSRECOMP_ROOT="$frameworkRoot" ` + -DMPH_VERSION="$MphVersion" ` + -DMPH_ROM="$romFull" if ($LASTEXITCODE -ne 0) { throw 'Game CMake configure failed.' } + & $cmakePath --build $gameBuild --target metroidprimehuntersrecomp -j $Jobs if ($LASTEXITCODE -ne 0) { throw 'Game bank build failed.' } + & $patchPython "$root\tools\patch_ndsrecomp_mph_runtime.py" ` + --framework-root "$frameworkRoot" --profiles "$profileFile" + if ($LASTEXITCODE -ne 0) { throw 'ndsrecomp MPH runtime-profile patch failed.' } + & $cmakePath -G $Generator -S "$frameworkRoot\runner" -B $runnerBuild ` -DCMAKE_BUILD_TYPE=Release ` -DNDS_BOOTSTRAP_FIRMWARE=ON ` "-DNDS_TITLE_BANK_DIR=$titleBankDir" ` "-DNDS_TITLE_ROM_SHA1=$romSha1" if ($LASTEXITCODE -ne 0) { throw 'Runner CMake configure failed.' } + & $cmakePath --build $runnerBuild -j $Jobs if ($LASTEXITCODE -ne 0) { throw 'Runner build failed.' } & $cmakePath -G $Generator -S "$root\launcher\recomp-ui" -B $launcherBuild ` -DCMAKE_BUILD_TYPE=Release ` + -DNDSRECOMP_ROOT="$frameworkRoot" ` -DRECOMP_UI_ROOT="$RecompUiRoot" ` - -DCMAKE_PREFIX_PATH="$RuntimeBinDir\..\lib\cmake" + -DCMAKE_PREFIX_PATH="$RuntimeBinDir\..\lib\cmake" ` + "-DMPH_LAUNCHER_ROM_SHA1=$romSha1" ` + "-DMPH_LAUNCHER_REGION=$region" ` + "-DMPH_LAUNCHER_DEFAULT_ROM=$launcherDefaultRom" ` + "-DMPH_LAUNCHER_ADAPTIVE_WIDESCREEN=$launcherAdaptive" if ($LASTEXITCODE -ne 0) { throw 'Launcher CMake configure failed.' } + & $cmakePath --build $launcherBuild -j $Jobs if ($LASTEXITCODE -ne 0) { throw 'Launcher build failed.' } - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` - "$root\tools\make_release.ps1" ` - -Version $Version ` - -RunnerBuildDir $RunnerBuildDir ` - -LauncherBuildDir $LauncherBuildDir ` - -RuntimeBinDir $RuntimeBinDir + $releaseArgs = @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + "$root\tools\make_release.ps1", + '-Version', $Version, + '-RunnerBuildDir', $RunnerBuildDir, + '-LauncherBuildDir', $LauncherBuildDir, + '-RuntimeBinDir', $RuntimeBinDir, + '-GameConfig', $gameConfig, + '-Profile', $MphVersion, + '-FmvRuntimeBank', $fmvRuntimeBank + ) + if (-not [bool]$profile.fmv_runtime) { + $releaseArgs += '-AllowNoFmvRuntime' + } + & powershell.exe @releaseArgs if ($LASTEXITCODE -ne 0) { throw 'Release packaging failed.' } } finally { Pop-Location -} +} \ No newline at end of file diff --git a/tools/capture_mph_checkpoints.py b/tools/capture_mph_checkpoints.py index 4730000..6855681 100644 --- a/tools/capture_mph_checkpoints.py +++ b/tools/capture_mph_checkpoints.py @@ -12,6 +12,15 @@ from PIL import Image +from mph_profile import ( + DEFAULT_PROFILE_FILE, + DEFAULT_VERSION, + load_profile, + resolve_repo_path, + verify_game_config_identity, + verify_rom_identity, +) + def firmware_crc16(data: bytes, start: int = 0xFFFF) -> int: polynomial = ( @@ -121,9 +130,6 @@ def capture( raise RuntimeError( f"no progress toward VBlank {count}: {json.dumps(hit)}" ) - # A long authentic-firmware run can exceed one server command's - # safety-round budget. Continue from the live machine rather than - # saving a misleading image under the requested checkpoint name. previous_vblank = current_vblank screens = [framebuffer(client, engine) for engine in ("A", "B")] image = Image.new( @@ -176,6 +182,13 @@ def main() -> int: parser.add_argument("--rom", type=Path, required=True) parser.add_argument("--config", type=Path) parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--version", default=DEFAULT_VERSION) + parser.add_argument( + "--profiles", + type=Path, + default=DEFAULT_PROFILE_FILE, + help=f"ROM profile registry (default: {DEFAULT_PROFILE_FILE})", + ) parser.add_argument("--port", type=int, default=19852) parser.add_argument( "--targets", type=int, nargs="+", default=[300, 600, 900, 1200] @@ -204,11 +217,36 @@ def main() -> int: parser.error("--generated-firmware requires --boot direct") if args.freebios and args.boot != "direct": parser.error("--freebios requires --boot direct") - if args.runner is not None and args.config is None: - parser.error("--config is required with --runner") + + profile = load_profile(args.profiles.resolve(), args.version) + args.rom = args.rom.resolve() + rom_sha1 = verify_rom_identity(args.rom, profile, args.version) + if args.runner is not None: + args.config = ( + args.config.resolve() + if args.config is not None + else resolve_repo_path(str(profile["game_config"])).resolve() + ) + verify_game_config_identity(args.config, profile, args.version) output = args.out.resolve() output.mkdir(parents=True, exist_ok=True) + targets = sorted(set(args.targets)) + metadata = { + "mph_profile": args.version, + "rom_sha1": rom_sha1, + "display_name": profile["display_name"], + "backend": "native" if args.runner is not None else "oracle", + "boot": args.boot, + "targets": targets, + "game_config": str(args.config) if args.runner is not None else None, + } + (output / "metadata.json").write_text( + json.dumps(metadata, indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + with (output / "runner.stdout.log").open("wb") as stdout, ( output / "runner.stderr.log" ).open("wb") as stderr: @@ -222,15 +260,12 @@ def main() -> int: "--port", str(args.port), "--rom", - str(args.rom.resolve()), + str(args.rom), "--config", - str(args.config.resolve()), + str(args.config), "--no-save", ] if not args.generated_firmware: - # Meaningful only when the real firmware runs (or seeds the - # user-settings mirror); a generated image must stay - # byte-identical to the oracle's, which never sets autoboot. command += ["--startup-mode", "automatic"] if args.boot == "direct": command += ["--boot", "direct"] @@ -260,11 +295,7 @@ def main() -> int: command += ["--firmware", str(firmware)] command += [ "--rom", - str(args.rom.resolve()), - # The runner's --startup-mode automatic patches its private - # in-memory firmware; the oracle reads the same patched copy - # from disk so a direct boot's user-settings mirror carries - # identical bytes on both sides. + str(args.rom), "--boot", args.boot, "--port", @@ -289,7 +320,7 @@ def main() -> int: count, include_native_stats=args.runner is not None, ) - for count in sorted(set(args.targets)) + for count in targets ] (output / "report.json").write_text( json.dumps(report, indent=2) + "\n", diff --git a/tools/check_melonprime_detector.py b/tools/check_melonprime_detector.py new file mode 100644 index 0000000..b8e5144 --- /dev/null +++ b/tools/check_melonprime_detector.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Verify the MPH executable-checksum registry against melonPrimeDS develop_hud. + +This deliberately parses only the authoritative CHECKSUM_TABLE from +MelonPrimeGameRomDetect.cpp. Runtime addresses are cross-checked separately +against MelonPrimeGameRomAddrTable.h. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +ENTRY_RE = re.compile( + r'\{\s*(0x[0-9A-Fa-f]{8})u\s*,\s*RomGroup::([A-Za-z0-9_]+)\s*,\s*"([^"]+)"\s*\}' +) + + +def fail(message: str) -> None: + raise SystemExit(f"ERROR: {message}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--profiles", type=Path, required=True) + parser.add_argument("--detector", type=Path, required=True) + args = parser.parse_args() + + registry = json.loads(args.profiles.read_text(encoding="utf-8")) + configured = registry.get("runtime_checksums") + if not isinstance(configured, list): + fail("profile registry has no runtime_checksums array") + + expected: list[tuple[int, str, str]] = [] + for index, entry in enumerate(configured): + if not isinstance(entry, dict): + fail(f"runtime_checksums[{index}] must be an object") + crc = entry.get("crc32") + profile = entry.get("profile") + name = entry.get("name") + if not isinstance(crc, str) or not isinstance(profile, str) or not isinstance(name, str): + fail(f"runtime_checksums[{index}] has invalid fields") + expected.append((int(crc, 16), profile, name)) + + source = args.detector.read_text(encoding="utf-8") + table_match = re.search( + r"constexpr\s+ChecksumEntry\s+CHECKSUM_TABLE\[\]\s*=\s*\{(.*?)\n\s*\};", + source, + re.S, + ) + if not table_match: + fail("could not locate CHECKSUM_TABLE in melonPrimeDS detector") + + actual = [ + (int(crc, 16), profile, name) + for crc, profile, name in ENTRY_RE.findall(table_match.group(1)) + ] + if not actual: + fail("melonPrimeDS CHECKSUM_TABLE contained no parseable entries") + + if actual != expected: + print("Configured runtime checksums:") + for crc, profile, name in expected: + print(f" 0x{crc:08X} {profile} {name}") + print("melonPrimeDS develop_hud checksums:") + for crc, profile, name in actual: + print(f" 0x{crc:08X} {profile} {name}") + fail("runtime checksum registry drifted from MelonPrimeGameRomDetect.cpp") + + # Keep the documented fallback contract visible to CI. If upstream moves + # either NDS header field, the imported detector source must be re-audited. + if "gameCode (@0x0C) + revision (@0x1E)" not in source: + fail("melonPrimeDS detector no longer documents gameCode@0x0C + revision@0x1E") + + print( + f"OK: {len(actual)} executable checksums and header fallback contract " + "match melonPrimeDS develop_hud" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py new file mode 100755 index 0000000..eee8dc5 --- /dev/null +++ b/tools/check_mph_multirom_profiles.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Validate the current multi-ROM schema, then run the legacy deep checks. + +The legacy checker predates the all-version Adaptive Widescreen address table +and intentionally asserted EU1_1=false plus three runtime fields. Preserve its +broad coverage on a compatibility view while validating the new scale fields +against melonPrimeDS on the real registry. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import re +import shutil +import tempfile +from pathlib import Path + +EXPECTED_SCALE = { + "JP1_0": (0x0211313C, 0x0211E7E8, 0x02112960), + "JP1_1": (0x021130FC, 0x0211E7A8, 0x02112920), + "US1_0": (0x02110FFC, 0x0211C638, 0x02110820), + "US1_1": (0x02111ABC, 0x0211D168, 0x021112E0), + "EU1_0": (0x02111ADC, 0x0211D114, 0x02111300), + "EU1_1": (0x02111B5C, 0x0211D208, 0x02111380), + "KR1_0": (0x02109B64, 0x02114838, 0x021091A4), +} +SCALE_FIELDS = ("scale_patch_addr1", "scale_patch_addr2", "scale_value_addr") + + +def die(message: str) -> None: + raise SystemExit(message) + + +def load_legacy(path: Path): + spec = importlib.util.spec_from_file_location("mph_multirom_legacy", path) + if spec is None or spec.loader is None: + die(f"unable to import legacy checker: {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def validate_scale_registry(repo: Path, table: Path | None) -> None: + registry_path = repo / "config" / "mph_rom_profiles.json" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + runtime = registry.get("runtime_profiles") + if not isinstance(runtime, dict) or set(runtime) != set(EXPECTED_SCALE): + die("runtime profile set no longer matches the seven MPH revisions") + + for key, expected in EXPECTED_SCALE.items(): + item = runtime[key] + fields = item.get("runtime") if isinstance(item, dict) else None + if not isinstance(fields, dict): + die(f"{key}: runtime table missing") + got = [] + for name in SCALE_FIELDS: + value = fields.get(name) + if not isinstance(value, str): + die(f"{key}.{name}: expected hex string") + got.append(int(value, 0)) + if tuple(got) != expected: + die(f"{key}: widescreen address triple changed: {tuple(hex(x) for x in got)}") + + profiles = registry.get("profiles") + if not isinstance(profiles, dict): + die("content profiles missing") + for key, profile in profiles.items(): + if not isinstance(profile, dict): + die(f"{key}: invalid content profile") + if profile.get("adaptive_widescreen") is not True: + die(f"{key}: Adaptive Widescreen must remain exposed for known content profiles") + config_path = repo / str(profile.get("game_config", "")) + text = config_path.read_text(encoding="utf-8") + if 'adaptive_widescreen = "top"' not in text or 'adaptive_capability = "top"' not in text: + die(f"{key}: game config does not expose top-screen Adaptive Widescreen") + + if table: + text = table.read_text(encoding="utf-8") + rows = { + "scale_patch_addr1": ("scalePatchAddr1", "ScalePatchAddr1"), + "scale_patch_addr2": ("scalePatchAddr2", "ScalePatchAddr2"), + "scale_value_addr": ("scaleValueAddr", "ScaleValueAddr"), + } + order = ("JP1_0", "JP1_1", "US1_0", "US1_1", "EU1_0", "EU1_1", "KR1_0") + for field, (member_name, list_name) in rows.items(): + match = re.search( + rf"X\(ADDR,\s*{member_name},\s*{list_name},\s*([^\n]+)\)", text + ) + if not match: + die(f"melonPrimeDS table missing {list_name}") + values = [int(x, 16) for x in re.findall(r"0x([0-9A-Fa-f]+)u?", match.group(1))] + expected = [EXPECTED_SCALE[key][SCALE_FIELDS.index(field)] for key in order] + if values[:7] != expected: + die(f"melonPrimeDS {list_name} drifted from registry") + + +def legacy_compat_view(repo: Path, destination: Path) -> Path: + target = destination / "repo" + shutil.copytree( + repo, target, + ignore=shutil.ignore_patterns(".git", "generated", "build", "build-*", "scratch"), + ) + registry_path = target / "config" / "mph_rom_profiles.json" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + for item in registry["runtime_profiles"].values(): + runtime = item["runtime"] + for field in SCALE_FIELDS: + runtime.pop(field, None) + registry["profiles"]["EU1_1"]["adaptive_widescreen"] = False + registry_path.write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8") + + eu_config = target / "config" / "game-eu11.toml" + lines = eu_config.read_text(encoding="utf-8").splitlines() + lines = [line for line in lines if not line.startswith("adaptive_widescreen =")] + eu_config.write_text("\n".join(lines) + "\n", encoding="utf-8") + return target + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--melonprime-table", type=Path) + args = parser.parse_args() + repo = args.repo.resolve() + table = args.melonprime_table.resolve() if args.melonprime_table else None + + validate_scale_registry(repo, table) + legacy = load_legacy(repo / "tools" / "check_mph_multirom_profiles_legacy.py") + with tempfile.TemporaryDirectory(prefix="mph-multirom-check-") as temp: + compat = legacy_compat_view(repo, Path(temp)) + legacy.validate_registry(compat, table) + print("OK: Adaptive Widescreen address/capability checks passed for all seven MPH revisions") + + +if __name__ == "__main__": + main() diff --git a/tools/check_mph_multirom_profiles_legacy.py b/tools/check_mph_multirom_profiles_legacy.py new file mode 100755 index 0000000..949eb14 --- /dev/null +++ b/tools/check_mph_multirom_profiles_legacy.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +"""Static consistency checks for Metroid Prime Hunters ROM profiles. + +The registry intentionally separates three identities: + +* runtime base profile: seven region/revision RAM layouts, +* executable checksum: melonPrimeDS header+ARM9+ARM7 CRC32 compatibility, +* whole-ROM SHA-1: exact build/capture/generated-content provenance. + +Build/capture content profiles reference a runtime base through ``base_profile``. +That lets a clean ROM and one or more exact mod ROM identities keep independent +coverage/banks/checkpoints while sharing a validated runtime address layout. + +Runtime checksum hits may authorize host Aim/Morph access. Header fallback alone +must never be promoted to that trust level by this checker or the runner patch. +""" + +from __future__ import annotations + +import argparse +import json +import re +import tomllib +from pathlib import Path +from typing import NoReturn + + +SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +CHECKSUM_RE = re.compile(r"^0x[0-9A-F]{8}$") +BANK_RE = re.compile(r"^[A-Za-z0-9_]+$") +PROFILE_KEY_RE = re.compile(r"^[A-Za-z0-9_]+$") +HEX_RE = re.compile(r"0x[0-9A-Fa-f]+u?") +REQUIRED_RUNTIME_FIELDS = { + "morph_state": "baseIsAltForm", + "aim_x": "baseAimX", + "aim_y": "baseAimY", +} +EXPECTED_RUNTIME_PROFILES = { + "US1_0": ("AMHE", 0, 0x218DA42C), + "US1_1": ("AMHE", 1, 0x91B46577), + "EU1_0": ("AMHP", 0, 0xA4A8FE5A), + "EU1_1": ("AMHP", 1, 0x910018A5), + "JP1_0": ("AMHJ", 0, 0xD75F539D), + "JP1_1": ("AMHJ", 1, 0x42EBF348), + "KR1_0": ("AMHK", 0, 0xE54682F3), +} +# Exact CHECKSUM_TABLE from develop_hud/MelonPrimeGameRomDetect.cpp at the +# source revision audited for this PR. Names are retained so drift is visible. +EXPECTED_RUNTIME_CHECKSUMS = { + 0x91B46577: ("US1_1", "US1.1"), + 0x01476E8F: ("US1_1", "US1.1 ENCRYPTED"), + 0x218DA42C: ("US1_0", "US1.0"), + 0xE048CD92: ("US1_0", "US1.0 ENCRYPTED"), + 0x910018A5: ("EU1_1", "EU1.1"), + 0x31703770: ("EU1_1", "EU1.1 ENCRYPTED"), + 0x948B1E48: ("EU1_1", "EU1.1 BALANCED"), + 0x2970A14F: ("EU1_1", "EU1.1 BALANCED V1.2.11"), + 0x9E20F3A8: ("EU1_1", "EU1.1 RUSSIANED"), + 0xA4A8FE5A: ("EU1_0", "EU1.0"), + 0x979BB267: ("EU1_0", "EU1.0 ENCRYPTED"), + 0xD75F539D: ("JP1_0", "JP1.0"), + 0xE795A10C: ("JP1_0", "JP1.0 ENCRYPTED"), + 0x42EBF348: ("JP1_1", "JP1.1"), + 0x0A1203A5: ("JP1_1", "JP1.1 ENCRYPTED"), + 0xE54682F3: ("KR1_0", "KR1.0"), + 0xC26916F3: ("KR1_0", "KR1.0 ENCRYPTED"), +} + + +def die(message: str) -> NoReturn: + raise SystemExit(f"ERROR: {message}") + + +def parse_hex(value: object, where: str) -> int: + if not isinstance(value, str): + die(f"{where} must be a hex string") + try: + return int(value, 0) + except ValueError: + die(f"{where} has invalid hex value {value!r}") + + +def parse_checksum(value: object, where: str) -> int: + if not isinstance(value, str) or not CHECKSUM_RE.fullmatch(value): + die(f"{where} must use uppercase 0xXXXXXXXX format") + return int(value, 16) + + +def load_json(path: Path) -> object: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def parse_melonprime_table(path: Path) -> dict[str, dict[str, int]]: + text = path.read_text(encoding="utf-8") + enum_match = re.search( + r"enum\s+class\s+RomGroup\s*:\s*int\s*\{([^}]*)\}", text, re.S + ) + if not enum_match: + die(f"could not parse RomGroup from {path}") + groups: list[str] = [] + for raw in enum_match.group(1).split(","): + token = raw.strip().split("=")[0].strip() + if token and token != "COUNT": + groups.append(token) + if not groups: + die(f"RomGroup has no revisions in {path}") + + wanted = set(REQUIRED_RUNTIME_FIELDS.values()) + fields: dict[str, list[int]] = {} + row_re = re.compile( + r"X\(ADDR,\s*([A-Za-z0-9_]+),\s*[A-Za-z0-9_]+,\s*([^)]*)\)" + ) + for match in row_re.finditer(text): + field = match.group(1) + if field not in wanted: + continue + values = [ + int(token.rstrip("uU"), 16) + for token in HEX_RE.findall(match.group(2)) + ] + if len(values) != len(groups): + die( + f"{field} in {path} has {len(values)} values; " + f"RomGroup has {len(groups)} revisions" + ) + fields[field] = values + + missing = wanted - fields.keys() + if missing: + die(f"missing melonPrimeDS fields: {', '.join(sorted(missing))}") + + result: dict[str, dict[str, int]] = {} + for index, group in enumerate(groups): + result[group] = {field: values[index] for field, values in fields.items()} + return result + + +def validate_runtime_profiles( + registry: dict[str, object], + melon: dict[str, dict[str, int]] | None, +) -> dict[str, dict[str, object]]: + runtime_profiles = registry.get("runtime_profiles") + if not isinstance(runtime_profiles, dict): + die("ROM profile registry has no object-valued runtime_profiles") + + actual_keys = set(runtime_profiles) + expected_keys = set(EXPECTED_RUNTIME_PROFILES) + if actual_keys != expected_keys: + missing = ", ".join(sorted(expected_keys - actual_keys)) or "none" + extra = ", ".join(sorted(actual_keys - expected_keys)) or "none" + die( + "runtime_profiles must contain exactly the seven supported retail " + f"profiles (missing: {missing}; extra: {extra})" + ) + + seen_identity: set[tuple[str, int]] = set() + seen_base_checksum: set[int] = set() + validated: dict[str, dict[str, object]] = {} + for key, expected in EXPECTED_RUNTIME_PROFILES.items(): + profile = runtime_profiles.get(key) + if not isinstance(profile, dict): + die(f"runtime profile {key} must be an object") + + game_code = profile.get("game_code") + revision = profile.get("revision") + base_checksum = parse_checksum( + profile.get("base_checksum"), f"{key}.base_checksum" + ) + if (game_code, revision, base_checksum) != expected: + die( + f"{key} runtime identity/checksum is " + f"{(game_code, revision, base_checksum)!r}; expected {expected!r}" + ) + if not isinstance(game_code, str) or len(game_code) != 4 or not game_code.isascii(): + die(f"{key}.game_code must be exactly four ASCII characters") + if not isinstance(revision, int) or revision not in (0, 1): + die(f"{key}.revision is not an explicitly supported revision") + + identity = (game_code, revision) + if identity in seen_identity: + die(f"duplicate runtime cartridge identity: {game_code} rev {revision}") + seen_identity.add(identity) + if base_checksum in seen_base_checksum: + die(f"duplicate canonical executable checksum: 0x{base_checksum:08X}") + seen_base_checksum.add(base_checksum) + + runtime = profile.get("runtime") + if not isinstance(runtime, dict): + die(f"{key}.runtime is required") + parsed_runtime: dict[str, int] = {} + for profile_field in REQUIRED_RUNTIME_FIELDS: + value = parse_hex(runtime.get(profile_field), f"{key}.runtime.{profile_field}") + if not 0x02000000 <= value <= 0x023FFFFF: + die(f"{key}.runtime.{profile_field} is outside DS main RAM") + parsed_runtime[profile_field] = value + + if melon is not None: + if key not in melon: + die(f"{key} is not present in melonPrimeDS RomGroup") + for profile_field, melon_field in REQUIRED_RUNTIME_FIELDS.items(): + actual = parsed_runtime[profile_field] + expected_addr = melon[key][melon_field] + if actual != expected_addr: + die( + f"{key}.{profile_field}=0x{actual:08X}, but " + f"melonPrimeDS {melon_field}=0x{expected_addr:08X}" + ) + + validated[key] = profile + return validated + + +def validate_runtime_checksums(registry: dict[str, object]) -> None: + entries = registry.get("runtime_checksums") + if not isinstance(entries, list): + die("runtime_checksums must be an array") + actual: dict[int, tuple[str, str]] = {} + for index, item in enumerate(entries): + if not isinstance(item, dict): + die(f"runtime_checksums[{index}] must be an object") + crc = parse_checksum(item.get("crc32"), f"runtime_checksums[{index}].crc32") + profile = item.get("profile") + name = item.get("name") + if profile not in EXPECTED_RUNTIME_PROFILES: + die(f"runtime_checksums[{index}] has unknown profile {profile!r}") + if not isinstance(name, str) or not name: + die(f"runtime_checksums[{index}] has no name") + if crc in actual: + die(f"duplicate runtime executable checksum 0x{crc:08X}") + actual[crc] = (str(profile), name) + if actual != EXPECTED_RUNTIME_CHECKSUMS: + missing = sorted(set(EXPECTED_RUNTIME_CHECKSUMS) - set(actual)) + extra = sorted(set(actual) - set(EXPECTED_RUNTIME_CHECKSUMS)) + changed = sorted( + crc for crc in set(actual) & set(EXPECTED_RUNTIME_CHECKSUMS) + if actual[crc] != EXPECTED_RUNTIME_CHECKSUMS[crc] + ) + die( + "runtime checksum registry drifted from audited develop_hud detector: " + f"missing={[f'0x{x:08X}' for x in missing]}, " + f"extra={[f'0x{x:08X}' for x in extra]}, " + f"changed={[f'0x{x:08X}' for x in changed]}" + ) + + +def validate_registry(repo: Path, table: Path | None) -> None: + registry_path = repo / "config" / "mph_rom_profiles.json" + registry_obj = load_json(registry_path) + if not isinstance(registry_obj, dict): + die("ROM profile registry must be a JSON object") + registry: dict[str, object] = registry_obj + + if registry.get("schema") != 5: + die("ROM profile registry schema must be 5") + + expected_address_source = ( + "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/" + "src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h" + ) + expected_detection_source = ( + "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/" + "src/frontend/qt_sdl/MelonPrimeGameRomDetect.cpp" + ) + expected_checksum_source = ( + "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/" + "src/NDSCart/CartCommon.cpp" + ) + if registry.get("runtime_address_source") != expected_address_source: + die("runtime_address_source is not the approved develop_hud address table") + if registry.get("runtime_detection_source") != expected_detection_source: + die("runtime_detection_source is not the approved develop_hud detector") + if registry.get("runtime_checksum_source") != expected_checksum_source: + die("runtime_checksum_source is not melonPrimeDS CartCommon::Checksum") + + melon = parse_melonprime_table(table) if table else None + runtime_profiles = validate_runtime_profiles(registry, melon) + validate_runtime_checksums(registry) + + profiles = registry.get("profiles") + if not isinstance(profiles, dict) or not profiles: + die("ROM profile registry has no build/capture content profiles") + + seen_sha1: set[str] = set() + seen_fmv_banks: set[str] = set() + seen_known_clean_base: set[str] = set() + for key, profile in profiles.items(): + if not isinstance(key, str) or not PROFILE_KEY_RE.fullmatch(key): + die(f"content profile key {key!r} must be a C/path-safe identifier") + if not isinstance(profile, dict): + die(f"profile {key} must be an object") + + base_profile = profile.get("base_profile") + known_clean = profile.get("known_clean") + if not isinstance(base_profile, str) or base_profile not in runtime_profiles: + die(f"{key}.base_profile must name one of the seven runtime profiles") + if not isinstance(known_clean, bool): + die(f"{key}.known_clean must be boolean") + if known_clean: + # Runtime code generation intentionally discovers a clean identity by + # looking up the content profile with the same canonical key. + if key != base_profile: + die( + f"{key}: known-clean content profile must use its canonical " + f"runtime key {base_profile!r}" + ) + if base_profile in seen_known_clean_base: + die(f"duplicate known-clean content identity for {base_profile}") + seen_known_clean_base.add(base_profile) + elif key in runtime_profiles: + die( + f"{key}: canonical runtime-profile key is reserved for its " + "known-clean content identity; use a distinct key for a mod" + ) + + sha1 = profile.get("sha1") + game_code = profile.get("game_code") + revision = profile.get("revision") + if not isinstance(sha1, str) or not SHA1_RE.fullmatch(sha1): + die(f"{key}.sha1 must be 40 lowercase hex digits") + if sha1 in seen_sha1: + die(f"duplicate SHA-1 in profile registry: {sha1}") + seen_sha1.add(sha1) + + runtime_profile = runtime_profiles[base_profile] + if game_code != runtime_profile.get("game_code") or revision != runtime_profile.get("revision"): + die( + f"{key}: content header identity disagrees with runtime base " + f"profile {base_profile}" + ) + + launcher_default_rom = profile.get("launcher_default_rom") + if ( + not isinstance(launcher_default_rom, str) + or not launcher_default_rom + or not launcher_default_rom.lower().endswith(".nds") + ): + die(f"{key}.launcher_default_rom must be a non-empty .nds filename") + if Path(launcher_default_rom).name != launcher_default_rom: + die(f"{key}.launcher_default_rom must be a filename, not a path") + adaptive_widescreen = profile.get("adaptive_widescreen") + if not isinstance(adaptive_widescreen, bool): + die(f"{key}.adaptive_widescreen must be boolean") + + fmv_runtime = profile.get("fmv_runtime") + if not isinstance(fmv_runtime, bool): + die(f"{key}.fmv_runtime must be boolean") + fmv_runtime_bank = profile.get("fmv_runtime_bank") + if ( + not isinstance(fmv_runtime_bank, str) + or not BANK_RE.fullmatch(fmv_runtime_bank) + or "_arm9_" not in fmv_runtime_bank + ): + die(f"{key}.fmv_runtime_bank must be a C identifier-style ARM9 bank name") + if fmv_runtime_bank in seen_fmv_banks: + die(f"duplicate FMV runtime bank identity: {fmv_runtime_bank}") + seen_fmv_banks.add(fmv_runtime_bank) + + if fmv_runtime: + runtime_config_path = repo / "config" / f"{fmv_runtime_bank}.toml" + if not runtime_config_path.is_file(): + die(f"{key} enables FMV runtime but config is missing: {runtime_config_path}") + with runtime_config_path.open("rb") as f: + runtime_config = tomllib.load(f) + runtime_program = runtime_config.get("program") + if not isinstance(runtime_program, dict): + die(f"{runtime_config_path} has no [program] table") + if runtime_program.get("id") != fmv_runtime_bank: + die( + f"{runtime_config_path} program.id={runtime_program.get('id')!r}; " + f"expected {fmv_runtime_bank!r}" + ) + + coverage_path = repo / str(profile.get("coverage", "")) + if not coverage_path.is_file(): + die(f"{key}.coverage does not exist: {coverage_path}") + coverage = load_json(coverage_path) + if not isinstance(coverage, dict) or coverage.get("game_sha1") != sha1: + die(f"{key}.coverage game_sha1 does not match profile SHA-1") + + game_config_path = repo / str(profile.get("game_config", "")) + if not game_config_path.is_file(): + die(f"{key}.game_config does not exist: {game_config_path}") + with game_config_path.open("rb") as f: + game_config = tomllib.load(f) + game = game_config.get("game") + if not isinstance(game, dict): + die(f"{key}.game_config has no [game] table") + expected_config = { + "id": game_code, + "revision": revision, + "rom_size": profile.get("rom_size"), + "sha1": sha1, + } + for field, expected in expected_config.items(): + if game.get(field) != expected: + die( + f"{key}.game_config game.{field}={game.get(field)!r}; " + f"expected {expected!r}" + ) + + display = game_config.get("display", {}) + if not isinstance(display, dict): + die(f"{key}.game_config [display] must be a table") + config_adaptive = display.get("adaptive_widescreen") + if adaptive_widescreen: + if config_adaptive != "top": + die( + f"{key} enables adaptive_widescreen in the profile but " + "game config does not enable top-screen adaptive widescreen" + ) + elif config_adaptive not in (None, "none"): + die( + f"{key} disables adaptive_widescreen in the profile but " + f"game config enables {config_adaptive!r}" + ) + + us = profiles.get("US1_0") + if not isinstance(us, dict): + die("US1_0 clean build profile is required") + if us.get("base_profile") != "US1_0" or us.get("known_clean") is not True: + die("US1_0 must remain the canonical known-clean US1_0 identity") + if us.get("fmv_runtime_bank") != "mph_arm9_fmv_runtime": + die("US1_0 historical FMV runtime bank identity changed unexpectedly") + + eu = profiles.get("EU1_1") + if not isinstance(eu, dict): + die("EU1_1 clean build profile is required") + if eu.get("base_profile") != "EU1_1" or eu.get("known_clean") is not True: + die("EU1_1 must remain the canonical known-clean EU1_1 identity") + if eu.get("game_code") != "AMHP" or eu.get("revision") != 1: + die("EU1_1 must remain AMHP revision 1") + if eu.get("sha1") != "bdcd1dea293e24c98d4c481430e90d21198985a5": + die("EU1_1 SHA-1 changed unexpectedly") + expected_eu_runtime = { + "morph_state": "0x020DB138", + "aim_x": "0x020DEE46", + "aim_y": "0x020DEE4E", + } + if runtime_profiles["EU1_1"].get("runtime") != expected_eu_runtime: + die("EU1_1 runtime profile changed unexpectedly") + if eu.get("fmv_runtime") is not False: + die("EU1_1 must not reuse the US1.0 FMV runtime capture") + if eu.get("fmv_runtime_bank") != "mph_amhp1_arm9_fmv_runtime": + die("EU1_1 FMV runtime bank identity changed unexpectedly") + if eu.get("adaptive_widescreen") is not False: + die("EU1_1 adaptive widescreen must remain disabled until validated") + if eu.get("launcher_default_rom") != "Metroid Prime Hunters (Europe Rev 1).nds": + die("EU1_1 launcher default ROM filename changed unexpectedly") + + print( + f"OK: validated {len(runtime_profiles)} runtime base profiles, " + f"{len(EXPECTED_RUNTIME_CHECKSUMS)} executable checksums, and " + f"{len(profiles)} build/capture content profiles" + ) + if melon is not None: + print(f"OK: all seven Aim/Morph profiles match melonPrimeDS table: {table}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--repo", type=Path, default=Path(__file__).resolve().parents[1] + ) + parser.add_argument( + "--melonprime-table", + type=Path, + help="Downloaded MelonPrimeGameRomAddrTable.h to cross-check", + ) + args = parser.parse_args() + validate_registry(args.repo.resolve(), args.melonprime_table) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/fuzz_mph_gameplay.py b/tools/fuzz_mph_gameplay.py index 74f329a..762ec3f 100644 --- a/tools/fuzz_mph_gameplay.py +++ b/tools/fuzz_mph_gameplay.py @@ -13,6 +13,14 @@ from PIL import Image import capture_mph_checkpoints as capture_lib +from mph_profile import ( + DEFAULT_PROFILE_FILE, + DEFAULT_VERSION, + load_profile, + resolve_repo_path, + verify_game_config_identity, + verify_rom_identity, +) KEY_BITS = { @@ -263,6 +271,13 @@ def main() -> int: parser.add_argument("--rom", type=Path, required=True) parser.add_argument("--config", type=Path) parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--version", default=DEFAULT_VERSION) + parser.add_argument( + "--profiles", + type=Path, + default=DEFAULT_PROFILE_FILE, + help=f"ROM profile registry (default: {DEFAULT_PROFILE_FILE})", + ) parser.add_argument("--port", type=int, default=19860) parser.add_argument("--seed", type=int, default=0x4D5048) parser.add_argument("--steps", type=int, default=100) @@ -274,8 +289,17 @@ def main() -> int: parser.add_argument("--skip-start-tap", action="store_true") parser.add_argument("--capture-static-coverage", action="store_true") args = parser.parse_args() - if args.runner is not None and args.config is None: - parser.error("--config is required with --runner") + + profile = load_profile(args.profiles.resolve(), args.version) + args.rom = args.rom.resolve() + rom_sha1 = verify_rom_identity(args.rom, profile, args.version) + if args.runner is not None: + args.config = ( + args.config.resolve() + if args.config is not None + else resolve_repo_path(str(profile["game_config"])).resolve() + ) + verify_game_config_identity(args.config, profile, args.version) output = args.out.resolve() output.mkdir(parents=True, exist_ok=True) @@ -287,6 +311,10 @@ def main() -> int: process = launch(args, output) trace: dict[str, object] = { + "mph_profile": args.version, + "rom_sha1": rom_sha1, + "display_name": profile["display_name"], + "scenario": args.actions.as_posix() if args.actions is not None else None, "seed": args.seed, "backend": "native" if args.runner is not None else "oracle", "start_vblank": args.start_vblank, diff --git a/tools/make_release.ps1 b/tools/make_release.ps1 index 84de180..ef31866 100644 --- a/tools/make_release.ps1 +++ b/tools/make_release.ps1 @@ -1,22 +1,24 @@ <# Package a completed Metroid Prime Hunters Recomp Windows release. -The ZIP contains the portable recomp-ui launcher, the title runner with the -content-validated FMV runtime bank compiled in, launcher assets, game config, -documentation, and MinGW/SDL dependencies. ROMs, BIOS/firmware, saves, raw -captures, and generated source are never staged. +The ZIP contains the portable recomp-ui launcher, the title runner, launcher +assets, the selected revision's game config, documentation, and MinGW/SDL +dependencies. ROMs, BIOS/firmware, saves, raw captures, and generated source +are never staged. -Build the runner and launcher first, then run: - - powershell -File tools\make_release.ps1 -Version 0.1.0 ` - -RunnerBuildDir ..\ndsrecomp\runner\build-mph-release ` - -LauncherBuildDir launcher\recomp-ui\build-release +Profiles with a validated FMV runtime bank require that exact bank identity to +be present in the runner. Profiles without one may opt out until their own +revision-specific capture has been produced. #> param( [Parameter(Mandatory = $true)][string]$Version, [string]$RunnerBuildDir = '..\ndsrecomp\runner\build-mph-release', [string]$LauncherBuildDir = 'launcher\recomp-ui\build-release', - [string]$RuntimeBinDir = 'C:\msys64\mingw64\bin' + [string]$RuntimeBinDir = 'C:\msys64\mingw64\bin', + [string]$GameConfig = 'game.toml', + [string]$Profile = 'US1_0', + [string]$FmvRuntimeBank = 'mph_arm9_fmv_runtime', + [switch]$AllowNoFmvRuntime ) $ErrorActionPreference = 'Stop' @@ -26,18 +28,26 @@ $launcherBuild = [IO.Path]::GetFullPath((Join-Path $root $LauncherBuildDir)) $runner = Join-Path $runnerBuild 'nds_runner.exe' $launcher = Join-Path $launcherBuild 'mph-recomp-ui.exe' $assets = Join-Path $launcherBuild 'assets' +$gameConfigPath = if ([IO.Path]::IsPathRooted($GameConfig)) { + [IO.Path]::GetFullPath($GameConfig) +} else { + [IO.Path]::GetFullPath((Join-Path $root $GameConfig)) +} -foreach ($required in @($runner, $launcher, $assets)) { +foreach ($required in @($runner, $launcher, $assets, $gameConfigPath)) { if (-not (Test-Path -LiteralPath $required)) { throw "Release input missing: $required" } } -# A static-only runner is functional but drops the opening movies to roughly -# half speed. Refuse to package one by checking the compiled bank identity. -$runnerText = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($runner)) -if (-not $runnerText.Contains('mph_arm9_fmv_runtime')) { - throw 'Runner does not contain the MPH FMV runtime bank.' +if (-not $AllowNoFmvRuntime) { + if ([string]::IsNullOrWhiteSpace($FmvRuntimeBank)) { + throw 'FMV runtime bank identity is empty.' + } + $runnerText = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($runner)) + if (-not $runnerText.Contains($FmvRuntimeBank)) { + throw "Runner does not contain required FMV runtime bank '$FmvRuntimeBank'." + } } $projectText = Get-Content (Join-Path $root 'CMakeLists.txt') -Raw @@ -47,7 +57,11 @@ if ($projectText -notmatch } $out = Join-Path $root 'release-stage' -$stageName = "MetroidPrimeHuntersRecomp-windows-x64-v$Version" +if ($Profile -eq 'US1_0') { + $stageName = "MetroidPrimeHuntersRecomp-windows-x64-v$Version" +} else { + $stageName = "MetroidPrimeHuntersRecomp-$Profile-windows-x64-v$Version" +} $stage = Join-Path $out $stageName $zip = Join-Path $out "$stageName.zip" $outFull = [IO.Path]::GetFullPath($out).TrimEnd('\') + '\' @@ -74,7 +88,8 @@ 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 $gameConfigPath ` + -Destination (Join-Path $stage 'game.toml') 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') ` @@ -153,4 +168,4 @@ try { Write-Host "--- $stageName ---" Get-ChildItem -LiteralPath $stage | Select-Object Name, Length | Out-Host -Get-FileHash -LiteralPath $zip -Algorithm SHA256 | Out-Host +Get-FileHash -LiteralPath $zip -Algorithm SHA256 | Out-Host \ No newline at end of file diff --git a/tools/mph_profile.py b/tools/mph_profile.py new file mode 100755 index 0000000..6305a09 --- /dev/null +++ b/tools/mph_profile.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Shared Metroid Prime Hunters ROM-profile helpers for build/capture tools.""" + +from __future__ import annotations + +import hashlib +import json +import tomllib +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_PROFILE_FILE = REPO_ROOT / "config" / "mph_rom_profiles.json" +DEFAULT_VERSION = "US1_0" + + +def load_profile(path: Path, version: str) -> dict[str, object]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"unable to read ROM profile registry {path}: {exc}") from exc + + profiles = document.get("profiles") + if not isinstance(profiles, dict): + raise SystemExit(f"ROM profile registry has no object-valued 'profiles': {path}") + profile = profiles.get(version) + if not isinstance(profile, dict): + choices = ", ".join(sorted(str(key) for key in profiles)) + raise SystemExit( + f"unknown MPH version {version!r}; configured versions: {choices}" + ) + + required = { + "display_name": str, + "game_code": str, + "revision": int, + "rom_size": int, + "sha1": str, + "program_id": str, + "coverage": str, + "game_config": str, + "fmv_runtime": bool, + "fmv_runtime_bank": str, + "launcher_default_rom": str, + "adaptive_widescreen": bool, + } + for key, expected_type in required.items(): + value = profile.get(key) + if not isinstance(value, expected_type): + raise SystemExit( + f"ROM profile {version!r} field {key!r} must be " + f"{expected_type.__name__}" + ) + + game_code = str(profile["game_code"]) + if len(game_code) != 4 or not game_code.isascii(): + raise SystemExit( + f"ROM profile {version!r} game_code must be exactly four ASCII bytes" + ) + digest = str(profile["sha1"]) + if len(digest) != 40 or any(c not in "0123456789abcdef" for c in digest): + raise SystemExit( + f"ROM profile {version!r} sha1 must be 40 lowercase hex digits" + ) + revision = int(profile["revision"]) + if revision < 0 or revision > 255: + raise SystemExit(f"ROM profile {version!r} revision must fit one byte") + if int(profile["rom_size"]) <= 0: + raise SystemExit(f"ROM profile {version!r} rom_size must be positive") + + bank = str(profile["fmv_runtime_bank"]) + if not bank or "_arm9_" not in bank or not bank.replace("_", "a").isalnum(): + raise SystemExit( + f"ROM profile {version!r} fmv_runtime_bank must be an ARM9 bank identifier" + ) + + return profile + + +def default_generated_inputs_dir(version: str) -> Path: + if version == "US1_0": + return REPO_ROOT / "generated" / "inputs" + return REPO_ROOT / "generated" / version / "inputs" + + +def resolve_repo_path(value: str) -> Path: + path = Path(value) + return path if path.is_absolute() else REPO_ROOT / path + + +def verify_game_config_identity( + config_path: Path, + profile: dict[str, object], + version: str, +) -> None: + try: + with config_path.open("rb") as f: + document = tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise SystemExit(f"unable to read game config {config_path}: {exc}") from exc + + game = document.get("game") + if not isinstance(game, dict): + raise SystemExit(f"game config has no [game] table: {config_path}") + expected = { + "id": profile["game_code"], + "revision": profile["revision"], + "rom_size": profile["rom_size"], + "sha1": profile["sha1"], + } + for key, value in expected.items(): + if game.get(key) != value: + raise SystemExit( + f"game config {config_path} game.{key}={game.get(key)!r}; " + f"expected {value!r} for {version}" + ) + + +def verify_rom_identity( + rom_path: Path, + profile: dict[str, object], + version: str, +) -> str: + expected_size = int(profile["rom_size"]) + try: + size = rom_path.stat().st_size + except OSError as exc: + raise SystemExit(f"unable to stat ROM {rom_path}: {exc}") from exc + if size != expected_size: + raise SystemExit( + f"ROM size mismatch for {version}: got {size}, expected {expected_size}" + ) + + digest = hashlib.sha1() + try: + with rom_path.open("rb") as f: + header = f.read(0x200) + digest.update(header) + while True: + chunk = f.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + except OSError as exc: + raise SystemExit(f"unable to read ROM {rom_path}: {exc}") from exc + + actual_sha1 = digest.hexdigest() + expected_sha1 = str(profile["sha1"]) + if actual_sha1 != expected_sha1: + raise SystemExit( + f"ROM SHA-1 mismatch for {version}: got {actual_sha1}, " + f"expected {expected_sha1}" + ) + + expected_code = str(profile["game_code"]).encode("ascii") + if len(header) <= 0x1E: + raise SystemExit(f"ROM header is truncated: {rom_path}") + if header[0x0C:0x10] != expected_code: + raise SystemExit( + f"game code mismatch for {version}: got {header[0x0C:0x10]!r}, " + f"expected {expected_code!r}" + ) + revision = int(profile["revision"]) + if header[0x1E] != revision: + raise SystemExit( + f"ROM revision mismatch for {version}: got {header[0x1E]}, " + f"expected {revision}" + ) + return actual_sha1 diff --git a/tools/mph_screens.py b/tools/mph_screens.py index f1f9eb5..df0e01f 100644 --- a/tools/mph_screens.py +++ b/tools/mph_screens.py @@ -34,6 +34,8 @@ # red cross on the right, both ringed in orange. "dialog_yes": (98, 302, 120, 324), "dialog_no": (138, 302, 160, 324), + # Single green check used by pairing/update notices and acknowledgements. + "dialog_ok": (116, 300, 140, 326), # Friends/Rivals lobby, top screen: the ARENA thumbnail. Flat dark green # while no game row is selected, a lit photo of the arena once a row is # selected and the host's settings have been pulled down (measured: 0 diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py new file mode 100755 index 0000000..2bc0249 --- /dev/null +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Apply the MPH runtime-profile patch stack to the pinned ndsrecomp runner. + +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. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def main() -> None: + here = Path(__file__).resolve().parent + args = sys.argv[1:] + for script in ( + here / "patch_ndsrecomp_mph_runtime_core.py", + here / "patch_ndsrecomp_mph_widescreen.py", + here / "patch_ndsrecomp_mph_widescreen_reset.py", + ): + subprocess.run([sys.executable, str(script), *args], check=True) + + +if __name__ == "__main__": + main() diff --git a/tools/patch_ndsrecomp_mph_runtime_core.py b/tools/patch_ndsrecomp_mph_runtime_core.py new file mode 100755 index 0000000..6597a56 --- /dev/null +++ b/tools/patch_ndsrecomp_mph_runtime_core.py @@ -0,0 +1,535 @@ +#!/usr/bin/env python3 +"""Apply the MPH multi-ROM runtime-profile shim to the pinned ndsrecomp runner. + +Runtime address selection follows melonPrimeDS's two-stage detector: + +1. authoritative executable checksum (CRC32 of header[0:0x40], ARM9, ARM7), +2. exact NDS gameCode @0x0C + supported revision @0x1E as a fallback. + +The fallback identifies a base profile but is *not* sufficient evidence for +host-side Aim/Morph RAM accesses. Those writes are enabled only for a checksum +explicitly known by the melonPrimeDS detector. This keeps unknown mods +fail-closed instead of guessing that a matching header implies compatible RAM. + +Whole-ROM SHA-1 has a separate role. It remains the actual-content identity +used by generated banks/captures. A clean build may accept a different whole- +ROM SHA only when the actual ROM has the canonical executable checksum of that +same clean base profile (for example, a data-only mod outside header/ARM9/ARM7). +Code-modified variants still require their own exact build/capture identity. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +CHECKSUM_RE = re.compile(r"^0x[0-9A-F]{8}$") +MAIN_RAM_MIN = 0x02000000 +MAIN_RAM_MAX = 0x023FFFFF +EXPECTED_RUNTIME_KEYS = { + "US1_0", "US1_1", "EU1_0", "EU1_1", "JP1_0", "JP1_1", "KR1_0" +} + + +def parse_address(value: object, *, profile: str, field: str) -> int: + if not isinstance(value, str): + raise SystemExit(f"{profile}.{field}: expected a hex string") + try: + address = int(value, 0) + except ValueError as exc: + raise SystemExit(f"{profile}.{field}: invalid address {value!r}") from exc + if not MAIN_RAM_MIN <= address <= MAIN_RAM_MAX: + raise SystemExit( + f"{profile}.{field}: 0x{address:08X} is outside DS main RAM" + ) + return address + + +def parse_checksum(value: object, *, where: str) -> int: + if not isinstance(value, str) or not CHECKSUM_RE.fullmatch(value): + raise SystemExit(f"{where}: expected uppercase 0xXXXXXXXX checksum") + return int(value, 16) + + +def load_runtime_registry( + registry_path: Path, +) -> tuple[list[dict[str, object]], list[dict[str, object]]]: + registry = json.loads(registry_path.read_text(encoding="utf-8")) + runtime_profiles = registry.get("runtime_profiles") + if not isinstance(runtime_profiles, dict) or not runtime_profiles: + raise SystemExit("ROM profile registry has no runtime_profiles") + + actual_keys = set(runtime_profiles) + if actual_keys != EXPECTED_RUNTIME_KEYS: + missing = ", ".join(sorted(EXPECTED_RUNTIME_KEYS - actual_keys)) or "none" + extra = ", ".join(sorted(actual_keys - EXPECTED_RUNTIME_KEYS)) or "none" + raise SystemExit( + "runtime profile set must be exactly the seven supported retail " + f"profiles (missing: {missing}; extra: {extra})" + ) + + build_profiles = registry.get("profiles") + if not isinstance(build_profiles, dict): + raise SystemExit("ROM profile registry has no build profiles") + + result: list[dict[str, object]] = [] + seen_identity: set[tuple[str, int]] = set() + seen_base_checksum: set[int] = set() + seen_clean_sha1: set[str] = set() + + for key, profile in runtime_profiles.items(): + if not isinstance(profile, dict): + raise SystemExit(f"{key}: runtime profile must be an object") + game_code = profile.get("game_code") + revision = profile.get("revision") + runtime = profile.get("runtime") + if ( + not isinstance(game_code, str) + or len(game_code) != 4 + or not game_code.isascii() + ): + raise SystemExit(f"{key}.game_code must be exactly four ASCII bytes") + if not isinstance(revision, int) or revision not in (0, 1): + raise SystemExit(f"{key}.revision must be an explicitly supported 0/1") + if not isinstance(runtime, dict): + raise SystemExit(f"{key}.runtime must be an object") + + identity = (game_code, revision) + if identity in seen_identity: + raise SystemExit( + f"duplicate runtime cartridge identity: {game_code} rev {revision}" + ) + seen_identity.add(identity) + + base_checksum = parse_checksum( + profile.get("base_checksum"), where=f"{key}.base_checksum" + ) + if base_checksum in seen_base_checksum: + raise SystemExit(f"duplicate canonical executable checksum for {key}") + seen_base_checksum.add(base_checksum) + + known_clean_sha1 = "" + clean = build_profiles.get(key) + if clean is not None: + if not isinstance(clean, dict): + raise SystemExit(f"{key}: build profile must be an object") + if clean.get("game_code") != game_code or clean.get("revision") != revision: + raise SystemExit( + f"{key}: clean build identity disagrees with runtime profile" + ) + sha1 = clean.get("sha1") + if not isinstance(sha1, str) or not SHA1_RE.fullmatch(sha1): + raise SystemExit(f"{key}.sha1 must be 40 lowercase hex digits") + if sha1 in seen_clean_sha1: + raise SystemExit(f"duplicate known-clean SHA-1: {sha1}") + seen_clean_sha1.add(sha1) + known_clean_sha1 = sha1 + + result.append( + { + "key": key, + "game_code": game_code, + "revision": revision, + "base_checksum": base_checksum, + "known_clean_sha1": known_clean_sha1, + "morph_state": parse_address( + runtime.get("morph_state"), profile=key, field="morph_state" + ), + "aim_x": parse_address( + runtime.get("aim_x"), profile=key, field="aim_x" + ), + "aim_y": parse_address( + runtime.get("aim_y"), profile=key, field="aim_y" + ), + } + ) + + checksums_obj = registry.get("runtime_checksums") + if not isinstance(checksums_obj, list) or not checksums_obj: + raise SystemExit("ROM profile registry has no runtime_checksums") + checksums: list[dict[str, object]] = [] + seen_checksums: set[int] = set() + canonical_seen: set[str] = set() + for index, item in enumerate(checksums_obj): + if not isinstance(item, dict): + raise SystemExit(f"runtime_checksums[{index}] must be an object") + checksum = parse_checksum( + item.get("crc32"), where=f"runtime_checksums[{index}].crc32" + ) + profile_key = item.get("profile") + name = item.get("name") + if profile_key not in EXPECTED_RUNTIME_KEYS: + raise SystemExit( + f"runtime_checksums[{index}].profile is unknown: {profile_key!r}" + ) + if not isinstance(name, str) or not name: + raise SystemExit(f"runtime_checksums[{index}].name must be non-empty") + if checksum in seen_checksums: + raise SystemExit(f"duplicate runtime checksum 0x{checksum:08X}") + seen_checksums.add(checksum) + profile = next(p for p in result if p["key"] == profile_key) + if checksum == profile["base_checksum"]: + canonical_seen.add(str(profile_key)) + checksums.append( + {"crc32": checksum, "profile": profile_key, "name": name} + ) + + if canonical_seen != EXPECTED_RUNTIME_KEYS: + missing = ", ".join(sorted(EXPECTED_RUNTIME_KEYS - canonical_seen)) + raise SystemExit( + f"runtime_checksums is missing canonical entries for: {missing}" + ) + return result, checksums + + +def generated_header( + profiles: list[dict[str, object]], checksums: list[dict[str, object]] +) -> str: + profile_rows: list[str] = [] + for profile in profiles: + profile_rows.append( + ' {"%s", "%s", %du, "%s", 0x%08Xu, 0x%08Xu, 0x%08Xu, 0x%08Xu}, // %s' + % ( + profile["key"], + profile["game_code"], + profile["revision"], + profile["known_clean_sha1"], + profile["base_checksum"], + profile["morph_state"], + profile["aim_x"], + profile["aim_y"], + profile["key"], + ) + ) + checksum_rows = [ + ' {0x%08Xu, "%s", "%s"},' + % (item["crc32"], item["profile"], item["name"]) + for item in checksums + ] + return """#pragma once + +#include +#include + +// Generated by MetroidPrimeHuntersRecomp/tools/patch_ndsrecomp_mph_runtime.py. +// Do not edit in the ndsrecomp checkout; edit config/mph_rom_profiles.json. +// +// The executable checksum mirrors melonPrimeDS CartCommon::Checksum(): CRC32 of +// header[0:0x40], then ARM9, then ARM7. A checksum hit is authoritative for the +// runtime layout. Header gameCode+revision is only a fail-closed fallback hint. +struct NdsMphRuntimeProfile { + const char* key; + const char* game_code; + uint8_t revision; + const char* known_clean_sha1; + uint32_t base_checksum; + uint32_t morph_state; + uint32_t aim_x; + uint32_t aim_y; +}; + +struct NdsMphRuntimeChecksum { + uint32_t checksum; + const char* profile_key; + const char* name; +}; + +inline constexpr std::array kNdsMphRuntimeProfiles{{ +%s +}}; + +inline constexpr std::array kNdsMphRuntimeChecksums{{ +%s +}}; +""" % ( + len(profile_rows), + "\n".join(profile_rows), + len(checksum_rows), + "\n".join(checksum_rows), + ) + + +def patch_once(path: Path, old: str, new: str, marker: str) -> None: + text = path.read_text(encoding="utf-8") + if marker in text: + return + if old not in text: + raise SystemExit( + f"Refusing to patch {path}: expected pinned ndsrecomp preimage " + f"for marker {marker!r} was not found" + ) + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def patch_runner(framework_root: Path, registry_path: Path) -> None: + runner_src = framework_root / "runner" / "src" + title_h = runner_src / "title_patches.h" + title_cpp = runner_src / "title_patches.cpp" + frontend_cpp = runner_src / "frontend.cpp" + main_cpp = runner_src / "main.cpp" + for path in (title_h, title_cpp, frontend_cpp, main_cpp): + if not path.is_file(): + raise SystemExit(f"Pinned ndsrecomp runner file not found: {path}") + + profiles, checksums = load_runtime_registry(registry_path) + generated = runner_src / "mph_runtime_profiles.generated.h" + generated.write_text(generated_header(profiles, checksums), encoding="utf-8") + + patch_once( + title_h, + "void nds_title_patches_set_mph_mouse_aim(bool enabled);\n" + "bool nds_title_patches_apply_mph_mouse_delta(int32_t dx, int32_t dy);\n", + "// MPH_MULTIROM_RUNTIME_PROFILE: melonPrimeDS-compatible base detector.\n" + "bool nds_title_patches_select_mph_runtime_profile(\n" + " const uint8_t* rom_data, uint64_t rom_size, const char* rom_sha1,\n" + " const char* expected_rom_sha1);\n" + "bool nds_title_patches_mph_host_writes_compatible();\n" + "bool nds_title_patches_mph_allows_rom_sha1_mismatch();\n" + "bool nds_title_patches_mph_in_ball();\n" + "void nds_title_patches_set_mph_mouse_aim(bool enabled);\n" + "bool nds_title_patches_apply_mph_mouse_delta(int32_t dx, int32_t dy);\n", + "melonPrimeDS-compatible base detector", + ) + + patch_once( + title_cpp, + '#include "title_patches.h"\n', + '#include "title_patches.h"\n' + '#include "mph_runtime_profiles.generated.h" // MPH_MULTIROM_PROFILE_HEADER\n', + "MPH_MULTIROM_PROFILE_HEADER", + ) + patch_once( + title_cpp, + "// AMHE0's native touch-look routine consumes these signed, per-frame fields.\n" + "// Feeding deltas here while holding the stylus at center preserves the game\n" + "// path but removes the finite physical touchscreen edge.\n" + "constexpr uint32_t kMphUs10AimX = 0x020DE526u;\n" + "constexpr uint32_t kMphUs10AimY = 0x020DE52Eu;\n", + "// MPH_MULTIROM_RUNTIME_PROFILE: runtime identity and safety state.\n" + "const NdsMphRuntimeProfile* g_mph_runtime_profile = nullptr;\n" + "bool g_mph_host_writes_compatible = false;\n" + "bool g_mph_allow_rom_sha1_mismatch = false;\n\n" + "uint32_t mph_read_le32(const uint8_t* p) {\n" + " return static_cast(p[0]) |\n" + " (static_cast(p[1]) << 8) |\n" + " (static_cast(p[2]) << 16) |\n" + " (static_cast(p[3]) << 24);\n" + "}\n\n" + "uint32_t mph_crc32(const uint8_t* data, uint32_t len, uint32_t start) {\n" + " uint32_t crc = start ^ 0xFFFFFFFFu;\n" + " for (uint32_t i = 0; i < len; ++i) {\n" + " crc ^= data[i];\n" + " for (int bit = 0; bit < 8; ++bit)\n" + " crc = (crc >> 1) ^\n" + " (0xEDB88320u & (0u - (crc & 1u)));\n" + " }\n" + " return crc ^ 0xFFFFFFFFu;\n" + "}\n\n" + "bool mph_compute_executable_checksum(\n" + " const uint8_t* rom, uint64_t rom_size, uint32_t* out) {\n" + " if (!rom || !out || rom_size < 0x40u) return false;\n" + " const uint32_t arm9_offset = mph_read_le32(rom + 0x20u);\n" + " const uint32_t arm9_size = mph_read_le32(rom + 0x2Cu);\n" + " const uint32_t arm7_offset = mph_read_le32(rom + 0x30u);\n" + " const uint32_t arm7_size = mph_read_le32(rom + 0x3Cu);\n" + " if (static_cast(arm9_offset) + arm9_size > rom_size ||\n" + " static_cast(arm7_offset) + arm7_size > rom_size)\n" + " return false;\n" + " uint32_t crc = mph_crc32(rom, 0x40u, 0u);\n" + " crc = mph_crc32(rom + arm9_offset, arm9_size, crc);\n" + " crc = mph_crc32(rom + arm7_offset, arm7_size, crc);\n" + " *out = crc;\n" + " return true;\n" + "}\n\n" + "const NdsMphRuntimeProfile* mph_find_profile_by_key(const char* key) {\n" + " for (const auto& profile : kNdsMphRuntimeProfiles)\n" + " if (std::strcmp(profile.key, key) == 0) return &profile;\n" + " return nullptr;\n" + "}\n\n" + "const NdsMphRuntimeProfile* mph_find_clean_sha1(const char* sha1) {\n" + " if (!sha1 || sha1[0] == '\\0') return nullptr;\n" + " for (const auto& profile : kNdsMphRuntimeProfiles) {\n" + " if (profile.known_clean_sha1[0] != '\\0' &&\n" + " std::strcmp(profile.known_clean_sha1, sha1) == 0)\n" + " return &profile;\n" + " }\n" + " return nullptr;\n" + "}\n", + "runtime identity and safety state", + ) + patch_once( + title_cpp, + "void nds_title_patches_set_mph_mouse_aim(bool enabled) {\n" + " g_mph_mouse_aim = enabled;\n" + "}\n\n" + "bool nds_title_patches_apply_mph_mouse_delta(int32_t dx, int32_t dy) {\n" + " if (!g_mph_mouse_aim || (dx == 0 && dy == 0)) return false;\n" + " if (dx != 0)\n" + " bus_write_u32_slow(kMphUs10AimX, static_cast(dx));\n" + " if (dy != 0)\n" + " bus_write_u32_slow(kMphUs10AimY, static_cast(dy));\n" + " return true;\n" + "}\n", + "bool nds_title_patches_select_mph_runtime_profile(\n" + " const uint8_t* rom_data, uint64_t rom_size, const char* rom_sha1,\n" + " const char* expected_rom_sha1) {\n" + " g_mph_mouse_aim = false;\n" + " g_mph_runtime_profile = nullptr;\n" + " g_mph_host_writes_compatible = false;\n" + " g_mph_allow_rom_sha1_mismatch = false;\n" + " if (!rom_data || !rom_sha1 || !expected_rom_sha1 || rom_size <= 0x1Eu)\n" + " return false;\n\n" + " uint32_t checksum = 0;\n" + " if (!mph_compute_executable_checksum(rom_data, rom_size, &checksum))\n" + " return false;\n\n" + " const NdsMphRuntimeChecksum* checksum_hit = nullptr;\n" + " const NdsMphRuntimeProfile* profile = nullptr;\n" + " for (const auto& entry : kNdsMphRuntimeChecksums) {\n" + " if (entry.checksum == checksum) {\n" + " checksum_hit = &entry;\n" + " profile = mph_find_profile_by_key(entry.profile_key);\n" + " break;\n" + " }\n" + " }\n\n" + " if (!profile) {\n" + " // melonPrimeDS fallback, tightened to exact supported revisions.\n" + " for (const auto& candidate : kNdsMphRuntimeProfiles) {\n" + " if (std::memcmp(candidate.game_code, rom_data + 0x0Cu, 4) == 0 &&\n" + " candidate.revision == rom_data[0x1Eu]) {\n" + " if (profile) return false; // ambiguous registry: fail closed\n" + " profile = &candidate;\n" + " }\n" + " }\n" + " }\n" + " if (!profile) return false;\n\n" + " // A known clean whole-ROM hash can only describe its own base profile.\n" + " const NdsMphRuntimeProfile* actual_clean = mph_find_clean_sha1(rom_sha1);\n" + " if (actual_clean && actual_clean != profile) return false;\n\n" + " g_mph_runtime_profile = profile;\n" + " // Unknown checksum + matching header is only a base-profile hint.\n" + " // Do not perform host RAM reads/writes until the executable checksum\n" + " // is explicitly represented by melonPrimeDS's authoritative table.\n" + " g_mph_host_writes_compatible = checksum_hit != nullptr;\n\n" + " // Whole-ROM mismatch may be relaxed only for a clean build whose\n" + " // executable identity is byte-for-byte equivalent to the canonical\n" + " // header+ARM9+ARM7 checksum. Code-modified known variants therefore\n" + " // still require an exact mod-specific build/capture SHA.\n" + " const NdsMphRuntimeProfile* expected_clean =\n" + " mph_find_clean_sha1(expected_rom_sha1);\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" + " return true;\n" + "}\n\n" + "bool nds_title_patches_mph_host_writes_compatible() {\n" + " return g_mph_runtime_profile && g_mph_host_writes_compatible;\n" + "}\n\n" + "bool nds_title_patches_mph_allows_rom_sha1_mismatch() {\n" + " return g_mph_runtime_profile && g_mph_allow_rom_sha1_mismatch;\n" + "}\n\n" + "bool nds_title_patches_mph_in_ball() {\n" + " return nds_title_patches_mph_host_writes_compatible() &&\n" + " bus_read_u8_slow(g_mph_runtime_profile->morph_state) == 0x02u;\n" + "}\n\n" + "void nds_title_patches_set_mph_mouse_aim(bool enabled) {\n" + " g_mph_mouse_aim =\n" + " enabled && nds_title_patches_mph_host_writes_compatible();\n" + "}\n\n" + "bool nds_title_patches_apply_mph_mouse_delta(int32_t dx, int32_t dy) {\n" + " if (!g_mph_mouse_aim || !nds_title_patches_mph_host_writes_compatible() ||\n" + " (dx == 0 && dy == 0)) return false;\n" + " if (dx != 0)\n" + " bus_write_u32_slow(g_mph_runtime_profile->aim_x,\n" + " static_cast(dx));\n" + " if (dy != 0)\n" + " bus_write_u32_slow(g_mph_runtime_profile->aim_y,\n" + " static_cast(dy));\n" + " return true;\n" + "}\n", + "nds_title_patches_select_mph_runtime_profile", + ) + + patch_once( + frontend_cpp, + "constexpr uint32_t kMphUs10MorphState = 0x020DA818u;\n", + "// MPH_MULTIROM_RUNTIME_PROFILE: morph address comes from the base ROM profile.\n", + "morph address comes from the base ROM profile", + ) + patch_once( + frontend_cpp, + " const bool in_ball =\n" + " bus_read_u8_slow(kMphUs10MorphState) == 0x02u;\n", + " const bool in_ball = nds_title_patches_mph_in_ball();\n", + "nds_title_patches_mph_in_ball()", + ) + + patch_once( + main_cpp, + " rom_sha1 = gba::sha1(rom.data(), rom.size()).hex();\n" + " std::fprintf(stderr, \"[load] cartridge: %zu bytes, SHA-1 %s\\n\",\n" + " rom.size(), rom_sha1.c_str());\n" + " if (!frontend_options.expected_rom_sha1.empty() &&\n" + " rom_sha1 != frontend_options.expected_rom_sha1) {\n" + " std::fprintf(stderr,\n" + " \"refusing to start: game config expects ROM SHA-1 \"\n" + " \"%s, got %s\\n\",\n" + " frontend_options.expected_rom_sha1.c_str(),\n" + " rom_sha1.c_str());\n" + " return 1;\n" + " }\n", + " rom_sha1 = gba::sha1(rom.data(), rom.size()).hex();\n" + " std::fprintf(stderr, \"[load] cartridge: %zu bytes, SHA-1 %s\\n\",\n" + " rom.size(), rom_sha1.c_str());\n" + " // MPH_MULTIROM_CONTENT_GATE: choose a base layout before the\n" + " // generic exact-SHA gate. The whole-ROM hash still identifies\n" + " // generated content; only canonical executable-equivalent data\n" + " // variants may reuse a clean build.\n" + " 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" + " 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" + " std::fprintf(stderr,\n" + " \"refusing to start: game config expects ROM SHA-1 \"\n" + " \"%s, got %s\\n\",\n" + " frontend_options.expected_rom_sha1.c_str(),\n" + " rom_sha1.c_str());\n" + " return 1;\n" + " }\n", + "MPH_MULTIROM_CONTENT_GATE", + ) + patch_once( + main_cpp, + " mph_mouse_aim_policy =\n" + " rom_sha1 == \"90164d1ac127ee5f9815ea4ae7de798c7b5fc629\" &&\n" + " frontend_options.relative_mouse_touch;\n", + " // MPH_MULTIROM_HOST_WRITE_GATE: header fallback alone never enables\n" + " // direct host RAM writes; an authoritative executable checksum is required.\n" + " mph_mouse_aim_policy =\n" + " nds_title_patches_mph_host_writes_compatible() &&\n" + " frontend_options.relative_mouse_touch;\n", + "MPH_MULTIROM_HOST_WRITE_GATE", + ) + + print( + f"Patched ndsrecomp MPH runtime base profiles: " + + ", ".join(str(profile["key"]) for profile in profiles) + + f" ({len(checksums)} authoritative executable checksums)" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + parser.add_argument("--profiles", type=Path, required=True) + args = parser.parse_args() + patch_runner(args.framework_root.resolve(), args.profiles.resolve()) + + +if __name__ == "__main__": + main() diff --git a/tools/patch_ndsrecomp_mph_widescreen.py b/tools/patch_ndsrecomp_mph_widescreen.py new file mode 100755 index 0000000..3f93c11 --- /dev/null +++ b/tools/patch_ndsrecomp_mph_widescreen.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Layer profile-aware MPH 21:9 projection/culling patches onto ndsrecomp. + +Addresses and guards mirror melonPrimeDS MelonPrimePatchAspectRatio.cpp and +MelonPrimeGameRomAddrTable.h, cross-checked against mphCodex Widescreen.md. +Only an authoritative executable checksum may enable these host code/data +writes. Header-only fallback detection remains fail-closed. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +EXPECTED_KEYS = {"US1_0", "US1_1", "EU1_0", "EU1_1", "JP1_0", "JP1_1", "KR1_0"} +FIELDS = ("scale_patch_addr1", "scale_patch_addr2", "scale_value_addr") +RAM_MIN = 0x02000000 +RAM_MAX = 0x023FFFFF + + +def parse_addr(value: object, where: str) -> int: + if not isinstance(value, str): + raise SystemExit(f"{where}: expected hex string") + try: + address = int(value, 0) + except ValueError as exc: + raise SystemExit(f"{where}: invalid address {value!r}") from exc + if not RAM_MIN <= address <= RAM_MAX: + raise SystemExit(f"{where}: 0x{address:08X} outside DS main RAM") + return address + + +def load_profiles(path: Path) -> list[dict[str, object]]: + registry = json.loads(path.read_text(encoding="utf-8")) + profiles = registry.get("runtime_profiles") + if not isinstance(profiles, dict) or set(profiles) != EXPECTED_KEYS: + raise SystemExit("runtime_profiles must contain exactly the seven MPH base revisions") + result: list[dict[str, object]] = [] + for key, item in profiles.items(): + if not isinstance(item, dict) or not isinstance(item.get("runtime"), dict): + raise SystemExit(f"{key}: missing runtime object") + runtime = item["runtime"] + row: dict[str, object] = {"key": key} + for field in FIELDS: + row[field] = parse_addr(runtime.get(field), f"{key}.runtime.{field}") + if int(row["scale_value_addr"]) & 3: + raise SystemExit(f"{key}.runtime.scale_value_addr must be word-aligned") + result.append(row) + return result + + +def header_text(profiles: list[dict[str, object]]) -> str: + rows = "\n".join( + ' {"%s", 0x%08Xu, 0x%08Xu, 0x%08Xu},' + % ( + p["key"], p["scale_patch_addr1"], p["scale_patch_addr2"], + p["scale_value_addr"], + ) + for p in profiles + ) + return f'''#pragma once + +#include +#include + +// Generated from config/mph_rom_profiles.json. +// Source of truth: melonPrimeDS MelonPrimeGameRomAddrTable.h. +struct NdsMphWidescreenProfile {{ + const char* key; + uint32_t scale_patch_addr1; + uint32_t scale_patch_addr2; + uint32_t scale_value_addr; +}}; + +inline constexpr std::array kNdsMphWidescreenProfiles{{{{ +{rows} +}}}}; +''' + + +def patch_once(path: Path, old: str, new: str, marker: str) -> None: + text = path.read_text(encoding="utf-8") + if marker in text: + return + if old not in text: + raise SystemExit( + f"Refusing to patch {path}: expected pinned ndsrecomp preimage " + f"for {marker!r} was not found" + ) + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def patch(framework: Path, registry: Path) -> None: + src = framework / "runner" / "src" + title_h = src / "title_patches.h" + title_cpp = src / "title_patches.cpp" + main_cpp = src / "main.cpp" + for path in (title_h, title_cpp, main_cpp): + if not path.is_file(): + raise SystemExit(f"runner source missing: {path}") + + profiles = load_profiles(registry) + (src / "mph_widescreen_profiles.generated.h").write_text( + header_text(profiles), encoding="utf-8" + ) + + patch_once( + title_h, + "bool nds_title_patches_mph_in_ball();\n", + "bool nds_title_patches_mph_in_ball();\n" + "// MPH_MULTIROM_WIDESCREEN: profile-aware 21:9 projection/culling patch.\n" + "bool nds_title_patches_mph_detected();\n" + "void nds_title_patches_set_mph_adaptive(bool enabled);\n", + "MPH_MULTIROM_WIDESCREEN", + ) + patch_once( + title_cpp, + '#include "mph_runtime_profiles.generated.h" // MPH_MULTIROM_PROFILE_HEADER\n', + '#include "mph_runtime_profiles.generated.h" // MPH_MULTIROM_PROFILE_HEADER\n' + '#include "mph_widescreen_profiles.generated.h" // MPH_MULTIROM_WIDESCREEN_HEADER\n', + "MPH_MULTIROM_WIDESCREEN_HEADER", + ) + patch_once( + title_cpp, + "bool g_mph_allow_rom_sha1_mismatch = false;\n", + "bool g_mph_allow_rom_sha1_mismatch = false;\n" + "// MPH_MULTIROM_WIDESCREEN_STATE\n" + "bool g_mph_adaptive = false;\n" + "bool g_mph_aspect_ratio_applied = false;\n" + "constexpr uint32_t kMphScaleOrig1 = 0xE5991664u;\n" + "constexpr uint32_t kMphScaleOrig2 = 0xE59A1664u;\n" + "constexpr uint32_t kMphScale21x9Instr = 0xE3A0106Du;\n" + "constexpr uint16_t kMphScaleOrigValue = 0x1555u;\n" + "constexpr uint16_t kMphScale21x9Value = 0x2555u;\n", + "MPH_MULTIROM_WIDESCREEN_STATE", + ) + patch_once( + title_cpp, + "\n} // namespace\n\nvoid nds_title_patches_set_sm64ds_adaptive(bool enabled) {\n", + "\nconst NdsMphWidescreenProfile* mph_widescreen_profile() {\n" + " if (!g_mph_runtime_profile) return nullptr;\n" + " for (const auto& profile : kNdsMphWidescreenProfiles)\n" + " if (std::strcmp(profile.key, g_mph_runtime_profile->key) == 0)\n" + " return &profile;\n" + " return nullptr;\n" + "}\n\n" + "void patch_mph_aspect_ratio() {\n" + " if (!g_mph_adaptive || g_mph_aspect_ratio_applied ||\n" + " !g_mph_runtime_profile || !g_mph_host_writes_compatible)\n" + " return;\n" + " const auto* wide = mph_widescreen_profile();\n" + " if (!wide) return;\n" + " int32_t first = 0, second = 0, scale_word = 0;\n" + " if (!read_main_ram32(wide->scale_patch_addr1, &first) ||\n" + " !read_main_ram32(wide->scale_patch_addr2, &second) ||\n" + " !read_main_ram32(wide->scale_value_addr, &scale_word))\n" + " return;\n" + " // Fail closed before doing any partial write. These are the exact\n" + " // guards used by melonPrimeDS for the three MPH widescreen patches.\n" + " if (static_cast(first) != kMphScaleOrig1 ||\n" + " static_cast(second) != kMphScaleOrig2 ||\n" + " (static_cast(scale_word) & 0xFFFFu) != kMphScaleOrigValue)\n" + " return;\n" + " bus_write_u32_slow(wide->scale_patch_addr1, kMphScale21x9Instr);\n" + " bus_write_u32_slow(wide->scale_patch_addr2, kMphScale21x9Instr);\n" + " const uint32_t patched_scale =\n" + " (static_cast(scale_word) & 0xFFFF0000u) | kMphScale21x9Value;\n" + " bus_write_u32_slow(wide->scale_value_addr, patched_scale);\n" + " g_mph_aspect_ratio_applied = true;\n" + " std::fprintf(stderr,\n" + " \"[mph] adaptive 21:9 projection/culling enabled for %s\\n\",\n" + " g_mph_runtime_profile->key);\n" + "}\n\n" + "} // namespace\n\nvoid nds_title_patches_set_sm64ds_adaptive(bool enabled) {\n", + "patch_mph_aspect_ratio()", + ) + patch_once( + title_cpp, + " g_mph_allow_rom_sha1_mismatch = false;\n" + " if (!rom_data || !rom_sha1 || !expected_rom_sha1 || rom_size <= 0x1Eu)\n", + " g_mph_allow_rom_sha1_mismatch = false;\n" + " g_mph_adaptive = false;\n" + " g_mph_aspect_ratio_applied = false;\n" + " if (!rom_data || !rom_sha1 || !expected_rom_sha1 || rom_size <= 0x1Eu)\n", + "g_mph_aspect_ratio_applied = false", + ) + patch_once( + title_cpp, + "bool nds_title_patches_mph_host_writes_compatible() {\n", + "bool nds_title_patches_mph_detected() {\n" + " return g_mph_runtime_profile != nullptr;\n" + "}\n\n" + "void nds_title_patches_set_mph_adaptive(bool enabled) {\n" + " g_mph_adaptive = enabled && g_mph_runtime_profile &&\n" + " g_mph_host_writes_compatible;\n" + " if (!g_mph_adaptive) g_mph_aspect_ratio_applied = false;\n" + "}\n\n" + "bool nds_title_patches_mph_host_writes_compatible() {\n", + "nds_title_patches_mph_detected()", + ) + patch_once( + title_cpp, + "void nds_title_patches_start_frame() {\n" + " if (g_sm64ds_adaptive) patch_sm64ds_clipper();\n" + "}\n", + "void nds_title_patches_start_frame() {\n" + " if (g_sm64ds_adaptive) patch_sm64ds_clipper();\n" + " if (g_mph_adaptive) patch_mph_aspect_ratio();\n" + "}\n", + "if (g_mph_adaptive) patch_mph_aspect_ratio();", + ) + + patch_once( + main_cpp, + " sm64ds_wide_policy = true;\n" + " }\n" + "#endif\n" + " if (interactive &&\n", + " sm64ds_wide_policy = true;\n" + " }\n" + "#endif\n" + " // MPH_MULTIROM_WIDESCREEN_GATE: a header-only base-profile hint is\n" + " // insufficient for host code/data writes. Keep the UI option visible,\n" + " // but force native presentation when executable compatibility is unknown.\n" + " if (nds_title_patches_mph_detected() &&\n" + " !nds_title_patches_mph_host_writes_compatible()) {\n" + " if ((frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u)\n" + " std::fprintf(stderr,\n" + " \"[mph] adaptive widescreen disabled: unknown executable checksum\\n\");\n" + " frontend_options.adaptive_screens &= ~NDS_ADAPTIVE_TOP;\n" + " frontend_options.adaptive_hud_anchor = false;\n" + " }\n" + " if (interactive &&\n", + "MPH_MULTIROM_WIDESCREEN_GATE", + ) + patch_once( + main_cpp, + " nds_title_patches_set_sm64ds_adaptive(\n" + " sm64ds_wide_policy &&\n" + " (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u);\n" + " nds_title_patches_set_mph_mouse_aim(mph_mouse_aim_policy);\n", + " nds_title_patches_set_sm64ds_adaptive(\n" + " sm64ds_wide_policy &&\n" + " (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u);\n" + " nds_title_patches_set_mph_adaptive(\n" + " nds_title_patches_mph_host_writes_compatible() &&\n" + " (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u);\n" + " nds_title_patches_set_mph_mouse_aim(mph_mouse_aim_policy);\n", + "nds_title_patches_set_mph_adaptive(", + ) + + print("Patched MPH adaptive 21:9 runtime profiles: " + + ", ".join(str(p["key"]) for p in profiles)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + parser.add_argument("--profiles", type=Path, required=True) + args = parser.parse_args() + patch(args.framework_root.resolve(), args.profiles.resolve()) + + +if __name__ == "__main__": + main() diff --git a/tools/patch_ndsrecomp_mph_widescreen_reset.py b/tools/patch_ndsrecomp_mph_widescreen_reset.py new file mode 100755 index 0000000..c3a5bec --- /dev/null +++ b/tools/patch_ndsrecomp_mph_widescreen_reset.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Make the MPH widescreen patch survive guest resets without re-identifying ROM.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + parser.add_argument("--profiles", type=Path, required=True) + args = parser.parse_args() + path = args.framework_root.resolve() / "runner" / "src" / "title_patches.cpp" + text = path.read_text(encoding="utf-8") + marker = "MPH_MULTIROM_WIDESCREEN_RESET_SAFE" + if marker in text: + return + old = ( + " if (!g_mph_adaptive || g_mph_aspect_ratio_applied ||\n" + " !g_mph_runtime_profile || !g_mph_host_writes_compatible)\n" + " return;\n" + ) + new = ( + " // MPH_MULTIROM_WIDESCREEN_RESET_SAFE: inspect the guarded words each\n" + " // frame. Once patched they no longer match the stock preimage, while a\n" + " // guest reset restores the stock words and makes the patch eligible again.\n" + " if (!g_mph_adaptive || !g_mph_runtime_profile ||\n" + " !g_mph_host_writes_compatible)\n" + " return;\n" + ) + if old not in text: + raise SystemExit("widescreen reset-safe preimage not found") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/prepare_mph.py b/tools/prepare_mph.py index af509d7..e16f1da 100644 --- a/tools/prepare_mph.py +++ b/tools/prepare_mph.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Verify AMHE0 and prepare ignored ndsrecomp main/overlay inputs.""" +"""Verify a configured MPH retail revision and prepare ndsrecomp inputs.""" from __future__ import annotations @@ -17,10 +17,9 @@ ) from exc -EXPECTED_ROM_SHA1 = "90164d1ac127ee5f9815ea4ae7de798c7b5fc629" -EXPECTED_ROM_SIZE = 64 * 1024 * 1024 -EXPECTED_GAME_CODE = b"AMHE" -EXPECTED_REVISION = 0 +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_PROFILE_FILE = REPO_ROOT / "config" / "mph_rom_profiles.json" +DEFAULT_VERSION = "US1_0" def sha1(data: bytes) -> str: @@ -31,6 +30,58 @@ def toml_string(value: str) -> str: return json.dumps(value, ensure_ascii=True) +def load_profile(path: Path, version: str) -> dict[str, object]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"unable to read ROM profile registry {path}: {exc}") from exc + + profiles = document.get("profiles") + if not isinstance(profiles, dict): + raise SystemExit(f"ROM profile registry has no object-valued 'profiles': {path}") + profile = profiles.get(version) + if not isinstance(profile, dict): + choices = ", ".join(sorted(str(key) for key in profiles)) + raise SystemExit( + f"unknown MPH version {version!r}; configured versions: {choices}" + ) + + required = { + "display_name": str, + "game_code": str, + "revision": int, + "rom_size": int, + "sha1": str, + "program_id": str, + } + for key, expected_type in required.items(): + value = profile.get(key) + if not isinstance(value, expected_type): + raise SystemExit( + f"ROM profile {version!r} field {key!r} must be " + f"{expected_type.__name__}" + ) + + game_code = str(profile["game_code"]) + if len(game_code) != 4 or not game_code.isascii(): + raise SystemExit( + f"ROM profile {version!r} game_code must be exactly four ASCII bytes" + ) + digest = str(profile["sha1"]) + if len(digest) != 40 or any(c not in "0123456789abcdef" for c in digest): + raise SystemExit( + f"ROM profile {version!r} sha1 must be 40 lowercase hex digits" + ) + revision = int(profile["revision"]) + if revision < 0 or revision > 255: + raise SystemExit(f"ROM profile {version!r} revision must fit one byte") + rom_size = int(profile["rom_size"]) + if rom_size <= 0: + raise SystemExit(f"ROM profile {version!r} rom_size must be positive") + + return profile + + def write_seed_config( path: Path, *, @@ -42,25 +93,25 @@ def write_seed_config( coverage_entries: list[dict[str, object]], ) -> None: lines = [ - "# AUTO-GENERATED by tools/prepare_mph.py; do not commit.", - "[program]", - f"name = {toml_string(name)}", - f"id = {toml_string(program_id)}", - f"load_address = 0x{load_address:08x}", - f"size = 0x{len(binary):08x}", - f"entry_pc = 0x{entry_pc:08x}", - "authoritative_entry_points = false", - "", - "[identity]", - f"sha1 = {toml_string(sha1(binary))}", - "", - "[[entry_point]]", - f"addr = 0x{entry_pc:08x}", - 'mode = "arm"', - 'name = "entry"', - 'kind = "rom_header"', - "", - ] + "# AUTO-GENERATED by tools/prepare_mph.py; do not commit.", + "[program]", + f"name = {toml_string(name)}", + f"id = {toml_string(program_id)}", + f"load_address = 0x{load_address:08x}", + f"size = 0x{len(binary):08x}", + f"entry_pc = 0x{entry_pc:08x}", + "authoritative_entry_points = false", + "", + "[identity]", + f"sha1 = {toml_string(sha1(binary))}", + "", + "[[entry_point]]", + f"addr = 0x{entry_pc:08x}", + 'mode = "arm"', + 'name = "entry"', + 'kind = "rom_header"', + "", + ] for entry in coverage_entries: addr = int(str(entry["addr"]), 0) mode = str(entry["mode"]) @@ -97,12 +148,11 @@ def write_overlay_config( An overlay has no ROM-header entry point: the guest's own loader copies the image to `load_address` and enters it through pointers the main image - holds, so no root is seeded here — real entry points arrive from Tier-3 - coverage recorded while this exact body was resident (the - PokemonBlackWhiteRecomp probe/build tools are the reference consumers). - The [overlay] table carries the facts a content-validated dispatch row - needs: the exact image identity and the static-init span, the one region - the guest legitimately mutates after load. + holds, so no root is seeded here. Real entry points arrive from Tier-3 + coverage recorded while this exact body was resident. The [overlay] table + carries the facts a content-validated dispatch row needs: exact image + identity and the static-init span, the one region the guest legitimately + mutates after load. """ lines = [ "# AUTO-GENERATED by tools/prepare_mph.py; do not commit.", @@ -137,34 +187,59 @@ def main() -> int: parser.add_argument("--rom", type=Path, required=True) parser.add_argument("--out", type=Path, required=True) parser.add_argument("--coverage", type=Path, required=True) + parser.add_argument( + "--version", + default=DEFAULT_VERSION, + help=f"ROM profile key (default: {DEFAULT_VERSION})", + ) + parser.add_argument( + "--profiles", + type=Path, + default=DEFAULT_PROFILE_FILE, + help=f"ROM profile registry (default: {DEFAULT_PROFILE_FILE})", + ) args = parser.parse_args() + profile = load_profile(args.profiles, args.version) + expected_sha1 = str(profile["sha1"]) + expected_size = int(profile["rom_size"]) + expected_game_code = str(profile["game_code"]).encode("ascii") + expected_revision = int(profile["revision"]) + display_name = str(profile["display_name"]) + program_id = str(profile["program_id"]) + rom_bytes = args.rom.read_bytes() digest = sha1(rom_bytes) - if len(rom_bytes) != EXPECTED_ROM_SIZE: + if len(rom_bytes) != expected_size: raise SystemExit( - f"ROM size mismatch: got {len(rom_bytes)}, expected {EXPECTED_ROM_SIZE}" + f"ROM size mismatch for {args.version}: got {len(rom_bytes)}, " + f"expected {expected_size}" ) - if digest != EXPECTED_ROM_SHA1: + if digest != expected_sha1: raise SystemExit( - f"ROM SHA-1 mismatch: got {digest}, expected {EXPECTED_ROM_SHA1}" + f"ROM SHA-1 mismatch for {args.version}: got {digest}, " + f"expected {expected_sha1}" ) - if rom_bytes[0x0C:0x10] != EXPECTED_GAME_CODE: + if rom_bytes[0x0C:0x10] != expected_game_code: raise SystemExit( - f"game code mismatch: got {rom_bytes[0x0C:0x10]!r}, " - f"expected {EXPECTED_GAME_CODE!r}" + f"game code mismatch for {args.version}: got " + f"{rom_bytes[0x0C:0x10]!r}, expected {expected_game_code!r}" ) - if rom_bytes[0x1C] != EXPECTED_REVISION: + if rom_bytes[0x1E] != expected_revision: raise SystemExit( - f"ROM revision mismatch: got {rom_bytes[0x1C]}, " - f"expected {EXPECTED_REVISION}" + f"ROM revision mismatch for {args.version}: got {rom_bytes[0x1E]}, " + f"expected {expected_revision}" ) rom = ndspy.rom.NintendoDSRom(rom_bytes) coverage = json.loads(args.coverage.read_text(encoding="utf-8")) - if coverage.get("game_sha1") != EXPECTED_ROM_SHA1: - raise SystemExit("static coverage seed identity does not match AMHE0") + if coverage.get("game_sha1") != expected_sha1: + raise SystemExit( + f"static coverage seed identity does not match {args.version}" + ) coverage_entries = coverage.get("entry_points", {}) + if not isinstance(coverage_entries, dict): + raise SystemExit("coverage entry_points must be an object") arm9_coverage = list(coverage_entries.get("arm9", [])) arm7_coverage = list(coverage_entries.get("arm7", [])) arm9_compressed = bytes(rom.arm9) @@ -181,8 +256,8 @@ def main() -> int: write_seed_config( out / "arm9.toml", - name="Metroid Prime Hunters (USA rev 0) ARM9 main", - program_id="mph_amhe0_arm9", + name=f"{display_name} ARM9 main", + program_id=f"{program_id}_arm9", load_address=int(rom.arm9RamAddress), entry_pc=int(rom.arm9EntryAddress), binary=arm9, @@ -190,8 +265,8 @@ def main() -> int: ) write_seed_config( out / "arm7.toml", - name="Metroid Prime Hunters (USA rev 0) ARM7", - program_id="mph_amhe0_arm7", + name=f"{display_name} ARM7", + program_id=f"{program_id}_arm7", load_address=int(rom.arm7RamAddress), entry_pc=int(rom.arm7EntryAddress), binary=arm7, @@ -211,8 +286,8 @@ def main() -> int: if int(overlay.ramAddress) < DS_MAIN_RAM_LIMIT: write_overlay_config( overlays_dir / f"arm9_overlay_{overlay_id:03d}.toml", - title="Metroid Prime Hunters (USA rev 0)", - program_id="mph_amhe0_arm9", + title=display_name, + program_id=f"{program_id}_arm9", overlay_id=overlay_id, load_address=int(overlay.ramAddress), binary=data, @@ -246,7 +321,11 @@ def main() -> int: newline="\n", ) - print(f"ROM verified: AMHE revision 0, SHA-1 {digest}") + print( + f"ROM verified: profile={args.version} code=" + f"{expected_game_code.decode('ascii')} revision={expected_revision} " + f"SHA-1 {digest}" + ) print( f"ARM9: {len(arm9_compressed):,} compressed bytes -> " f"{len(arm9):,} bytes at 0x{int(rom.arm9RamAddress):08X}" @@ -261,4 +340,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file diff --git a/tools/probe_mph_online_first_run.py b/tools/probe_mph_online_first_run.py new file mode 100644 index 0000000..dab370a --- /dev/null +++ b/tools/probe_mph_online_first_run.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""Validate MPH Wiimmfi setup and persistence across real process restarts. + +The runner stays in interactive mode. Navigation, screenshots, and lifecycle +control all use its TCP debug surface; input/checkpoint primitives come from +fuzz_mph_gameplay. The source ROM, save, and identity are copied/read-only. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import random +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +from PIL import Image + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import capture_mph_checkpoints as capture_lib # noqa: E402 +import fuzz_mph_gameplay as input_lib # noqa: E402 +import mph_screens # noqa: E402 +from add_mph_friend import MENU_PATH # noqa: E402 +from run_mph_friend_match import DIALOG_YES, FRIENDS_AND_RIVALS # noqa: E402 +from run_mph_wfc_instances import FILTERS # noqa: E402 + +INPUT_FUZZ = random.Random() + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def wait_frames( + client: capture_lib.DebugClient, + process: subprocess.Popen[bytes], + count: int, + timeout: float = 300.0, +) -> None: + start = input_lib.event_counts(client)["vblank9"] + target = start + count + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(f"runner exited with code {process.returncode}") + if input_lib.event_counts(client)["vblank9"] >= target: + return + time.sleep(0.025) + raise TimeoutError(f"runner did not advance {count} frames") + + +def tap( + client: capture_lib.DebugClient, + process: subprocess.Popen[bytes], + x: int, + y: int, + wait: int, +) -> None: + fuzzed_x = max(0, min(255, x + INPUT_FUZZ.randint(-2, 2))) + fuzzed_y = max(0, min(191, y + INPUT_FUZZ.randint(-2, 2))) + client.command("touch", x=fuzzed_x, y=fuzzed_y, down=True) + wait_frames(client, process, INPUT_FUZZ.randint(8, 14)) + client.command("touch", x=fuzzed_x, y=fuzzed_y, down=False) + wait_frames(client, process, wait) + + +def press( + client: capture_lib.DebugClient, + process: subprocess.Popen[bytes], + key: str, + wait: int, +) -> None: + bit = input_lib.KEY_BITS[key] + client.command("keys", mask=input_lib.RELEASED_KEYS & ~(1 << bit)) + wait_frames(client, process, INPUT_FUZZ.randint(8, 14)) + client.command("keys", mask=input_lib.RELEASED_KEYS) + wait_frames(client, process, wait) + + +class InteractiveSession: + def __init__( + self, + args: argparse.Namespace, + name: str, + port: int, + profile: Path, + ) -> None: + self.output = args.out / name + self.output.mkdir(parents=True, exist_ok=True) + self.report: list[dict[str, Any]] = [] + self.stdout = (self.output / "runner.stdout.log").open("wb") + self.stderr = (self.output / "runner.stderr.log").open("wb") + command = [ + str(args.runner), str(profile / "bios"), "--interactive", + "--port", str(port), "--rom", str(args.rom), + "--config", str(args.config), "--save-path", + str(profile / "Metroid Prime Hunters.sav"), + "--firmware-state-path", str(profile / "firmware-generated.bin"), + "--startup-mode", "automatic", "--network", "on", + "--network-backend", "slirp", "--wfc", "on", + "--wfc-provider", args.wfc_provider, "--freebios", + "--generated-firmware", "--boot", "direct", + ] + self.process = subprocess.Popen( + command, cwd=args.runner.parent, stdout=self.stdout, + stderr=self.stderr, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + capture_lib.wait_for_server(port, self.process) + self.client = capture_lib.DebugClient(port, timeout=600.0) + + def save(self, label: str) -> dict[str, Any]: + item = input_lib.save_checkpoint( + self.client, self.output, len(self.report), label + ) + self.report.append(item) + return item + + def close_handles(self) -> None: + try: + self.client.close() + except OSError: + pass + self.stdout.close() + self.stderr.close() + (self.output / "report.json").write_text( + json.dumps(self.report, indent=2) + "\n", encoding="utf-8" + ) + + def force_cleanup(self) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.close_handles() + + +def reach_wfc_menu(session: InteractiveSession) -> None: + wait_frames(session.client, session.process, 7800) + session.save("title") + for label, x, y, wait in MENU_PATH[:4]: + tap(session.client, session.process, x, y, wait) + session.save(label) + + +def setup_and_power_off(session: InteractiveSession) -> dict[str, Any]: + reach_wfc_menu(session) + route = ( + ("wfc-setup-root", 128, 36, 600), + ("settings-tile", 85, 100, 220), + ("slot1", 43, 35, 220), + ("search-for-ap", 128, 37, 1600), + ("test-connection", 192, 36, 2400), + ("save-settings", 190, 177, 600), + ) + for label, x, y, wait in route: + tap(session.client, session.process, x, y, wait) + session.save(label) + press(session.client, session.process, "b", 600) + session.save("setup-root-after-back") + press(session.client, session.process, "b", 600) + prompt = session.save("system-will-shut-down") + + rings = { + name: session.client.command("net_ring_dump", max=256, filter=name) + for name in FILTERS + } + counts = { + name: len(value.get("events", [])) if isinstance(value, dict) else 0 + for name, value in rings.items() + } + if counts.get("dhcp", 0) == 0 or counts.get("backend_error", 0) != 0: + raise RuntimeError(f"connection test did not succeed cleanly: {counts}") + + # The green check confirms the firmware's terminal shutdown prompt. + session.client.command("touch", x=128, y=128, down=True) + try: + session.process.wait(timeout=30) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("guest power-off left the application open") from exc + if session.process.returncode != 0: + raise RuntimeError( + f"guest power-off returned {session.process.returncode}" + ) + session.close_handles() + stderr_text = (session.output / "runner.stderr.log").read_text( + encoding="utf-8", errors="replace" + ) + if "[sdl] guest requested power-off; closing" not in stderr_text: + raise RuntimeError("runner did not report the guest power-off exit") + return {"prompt": prompt, "net_counts": counts, "returncode": 0} + + +def direct_online( + session: InteractiveSession, + require_no_notice_pages: bool, +) -> dict[str, Any]: + reach_wfc_menu(session) + tap(session.client, session.process, *FRIENDS_AND_RIVALS, 240) + session.save("friends-rivals") + + arrow_pages = 0 + yes_pages = 0 + ok_pages = 0 + for index in range(8): + item = session.save(f"dialog-{index}") + image = Image.open(session.output / str(item["image"])).convert("RGB") + ok_bright = mph_screens.bright_count( + image, mph_screens.BOXES["dialog_ok"] + ) + if mph_screens.bright_count( + image, mph_screens.BOXES["dialog_arrow"]) > 40: + arrow_pages += 1 + tap(session.client, session.process, 190, 120, 240) + elif mph_screens.is_connect_dialog(image): + yes_pages += 1 + tap(session.client, session.process, *DIALOG_YES, 240) + elif 40 < ok_bright < 200: + ok_pages += 1 + tap(session.client, session.process, 128, 128, 240) + elif ok_bright >= 200: + # The animated connection badge shares the acknowledgement's + # screen region. Leave it alone and wait for authentication. + break + else: + break + wait_frames(session.client, session.process, 2400) + final = session.save("post-connect") + rings = { + name: session.client.command("net_ring_dump", max=256, filter=name) + for name in FILTERS + } + counts = { + name: len(value.get("events", [])) if isinstance(value, dict) else 0 + for name, value in rings.items() + } + if counts.get("tls_record", 0) == 0 or counts.get("backend_error", 0) != 0: + raise RuntimeError(f"Wiimmfi authentication failed: {counts}") + if require_no_notice_pages and (arrow_pages != 0 or ok_pages != 0): + raise RuntimeError( + "returning profile repeated pairing/update pages: " + f"arrow={arrow_pages}, ok={ok_pages}" + ) + + response = session.client.command("frontend_exit") + if not isinstance(response, dict) or not response.get("requested"): + raise RuntimeError(f"frontend_exit was not accepted: {response!r}") + session.process.wait(timeout=30) + if session.process.returncode != 0: + raise RuntimeError( + f"normal window close returned {session.process.returncode}" + ) + session.close_handles() + return { + "final": final, + "arrow_pages": arrow_pages, + "yes_pages": yes_pages, + "ok_pages": ok_pages, + "net_counts": counts, + "returncode": 0, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runner", type=Path, required=True) + parser.add_argument("--rom", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--source-save", type=Path, required=True) + parser.add_argument("--source-identity", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--port", type=int, default=21020) + parser.add_argument("--wfc-provider", default="wiimmfi") + parser.add_argument("--input-fuzz-seed", type=int, default=0x4D5048) + parser.add_argument( + "--resume-after-setup", action="store_true", + help="reuse an existing profile after a successful setup/power-off run", + ) + args = parser.parse_args() + INPUT_FUZZ.seed(args.input_fuzz_seed) + for key in ("runner", "rom", "config", "source_save", "source_identity"): + setattr(args, key, getattr(args, key).resolve()) + args.out = args.out.resolve() + args.out.mkdir(parents=True, exist_ok=True) + profile = args.out / "profile" + (profile / "bios").mkdir(parents=True, exist_ok=True) + + source_hashes = { + "save": sha256(args.source_save), + "identity": sha256(args.source_identity), + } + if not args.resume_after_setup: + shutil.copy2(args.source_save, profile / "Metroid Prime Hunters.sav") + shutil.copy2( + args.source_identity, profile / "bios" / "generated-identity.bin" + ) + elif not (profile / "firmware-generated.bin").is_file(): + parser.error("--resume-after-setup requires an existing profile state") + + summary: dict[str, Any] = { + "input_fuzz_seed": args.input_fuzz_seed, + "source_hashes": source_hashes, + } + sessions: list[InteractiveSession] = [] + try: + state = profile / "firmware-generated.bin" + cartridge = profile / "Metroid Prime Hunters.sav" + if not args.resume_after_setup: + first = InteractiveSession( + args, "01-setup-poweroff", args.port, profile + ) + sessions.append(first) + summary["setup"] = setup_and_power_off(first) + summary["state_after_setup"] = { + "size": state.stat().st_size, "sha256": sha256(state) + } + summary["save_after_setup"] = sha256(cartridge) + else: + summary["setup"] = "resumed" + + online_port = args.port + (0 if args.resume_after_setup else 1) + second = InteractiveSession(args, "02-first-online", online_port, profile) + sessions.append(second) + summary["first_online"] = direct_online(second, False) + summary["state_after_first_online"] = sha256(state) + summary["save_after_first_online"] = sha256(cartridge) + + third = InteractiveSession(args, "03-returning-online", online_port + 1, profile) + sessions.append(third) + summary["returning_online"] = direct_online(third, True) + summary["state_after_returning_online"] = sha256(state) + summary["save_after_returning_online"] = sha256(cartridge) + finally: + for session in sessions: + if session.process.poll() is None: + session.force_cleanup() + + if sha256(args.source_save) != source_hashes["save"] or \ + sha256(args.source_identity) != source_hashes["identity"]: + raise RuntimeError("source save/identity changed during QA") + summary["sources_unchanged"] = True + summary["success"] = True + (args.out / "summary.json").write_text( + json.dumps(summary, indent=2) + "\n", encoding="utf-8" + ) + print(json.dumps(summary, indent=2), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/probe_mph_wfc.py b/tools/probe_mph_wfc.py index 52c9c81..16619ba 100644 --- a/tools/probe_mph_wfc.py +++ b/tools/probe_mph_wfc.py @@ -148,6 +148,12 @@ def run(args: argparse.Namespace) -> dict[str, Any]: command.append("--no-save") if args.firmware_path: command.extend(["--firmware-path", str(args.firmware_path.resolve())]) + if args.firmware_state_path: + command.extend([ + "--firmware-state-path", str(args.firmware_state_path.resolve()) + ]) + if args.no_dumps: + command.extend(["--freebios", "--generated-firmware", "--boot", "direct"]) if args.discover_static_misses: command.append("--discover-static-misses") @@ -195,7 +201,12 @@ def run(args: argparse.Namespace) -> dict[str, Any]: ("settings-tile", 85, 100, 220), ("slot1", 43, 35, 220), ("search-for-ap", 128, 37, 1600), - ("ap-row", 50, 65, 220), + # Generated firmware already advertises the runner's + # ndsrecomp AP. Discovery selects it and returns to + # Connection 1 Settings; the old probe tapped (50,65) + # here, which is the SSID row and opened the keyboard. + ("test-connection", 192, 36, 2400), + ("save-settings", 190, 177, 600), ) else: if args.flow == "friends-rivals": @@ -215,9 +226,12 @@ def run(args: argparse.Namespace) -> dict[str, Any]: save(label) if args.flow == "setup": - input_lib.press_key(client, "a", 12) - input_lib.advance_frames(client, 300) - save("after-a-start-test") + input_lib.press_key(client, "b", 12) + input_lib.advance_frames(client, 600) + save("setup-root-after-back") + input_lib.press_key(client, "b", 12) + input_lib.advance_frames(client, 600) + save("exit-prompt") if args.flow == "search-game": for target in args.targets: @@ -272,7 +286,7 @@ def run(args: argparse.Namespace) -> dict[str, Any]: firmware = client.command("firmware_dump") if not isinstance(firmware, dict): raise RuntimeError("firmware_dump returned a non-object response") - if int(firmware.get("size", 0)) != 262144: + if int(firmware.get("size", 0)) not in (131072, 262144): raise RuntimeError( f"firmware_dump returned {firmware.get('size')} bytes" ) @@ -313,6 +327,8 @@ def main() -> int: parser.add_argument("--config", type=Path, default=Path("game.toml")) parser.add_argument("--save-path", type=Path) parser.add_argument("--firmware-path", type=Path) + parser.add_argument("--firmware-state-path", type=Path) + parser.add_argument("--no-dumps", action="store_true") parser.add_argument("--firmware-out", type=Path) parser.add_argument("--discover-static-misses", action="store_true") parser.add_argument( @@ -357,6 +373,8 @@ def main() -> int: parser.error("--port must be in 1..65535") if args.instance_index < 0 or args.instance_index > 255: parser.error("--instance-index must be in 0..255") + if args.no_dumps and args.firmware_path: + parser.error("--no-dumps and --firmware-path are mutually exclusive") summary = run(args) print(json.dumps(summary, indent=2), flush=True) diff --git a/tools/promote_mph_runtime_coverage.py b/tools/promote_mph_runtime_coverage.py index 1acfd25..74433de 100644 --- a/tools/promote_mph_runtime_coverage.py +++ b/tools/promote_mph_runtime_coverage.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Create a content-validated MPH ARM9 runtime-bank config from a capture.""" +"""Create a profile-specific content-validated MPH ARM9 runtime-bank config.""" from __future__ import annotations @@ -8,62 +8,148 @@ import json from pathlib import Path +from mph_profile import DEFAULT_PROFILE_FILE, DEFAULT_VERSION, load_profile + ITCM_BASE = 0x01FF8000 MAIN_RAM_END = 0x02400000 -IMAGE_SIZE = 0x00408000 +IMAGE_SIZE = MAIN_RAM_END - ITCM_BASE def runtime_address(address: int) -> bool: return ITCM_BASE <= address < MAIN_RAM_END +def coverage_key(item: dict[str, object]) -> tuple[int, ...]: + return tuple( + int(item[field]) + for field in ("cpu", "pc", "thumb", "kind", "caller") + ) + + +def verify_report_identity( + report: dict[str, object], + *, + version: str, + expected_sha1: str, + label: str, + require_identity: bool, +) -> None: + observed_profile = report.get("mph_profile") + observed_sha1 = report.get("rom_sha1") + if require_identity and (observed_profile is None or observed_sha1 is None): + raise SystemExit( + f"{label} for {version} is missing profile/ROM identity; " + "recapture with the profile-aware FMV benchmark tool" + ) + if observed_profile is not None and str(observed_profile) != version: + raise SystemExit( + f"{label} profile {observed_profile!r} does not match {version!r}" + ) + if observed_sha1 is not None and str(observed_sha1) != expected_sha1: + raise SystemExit( + f"{label} ROM SHA-1 {observed_sha1!r} does not match " + f"{version} ({expected_sha1})" + ) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--benchmark", type=Path, required=True) parser.add_argument( - "--before-benchmark", type=Path, + "--before-benchmark", + type=Path, help="subtract cumulative coverage captured before the target phase", ) parser.add_argument("--image", type=Path, required=True) parser.add_argument("--out", type=Path, required=True) - parser.add_argument("--bank", default="mph_arm9_fmv_runtime") + parser.add_argument("--bank") + parser.add_argument("--version", default=DEFAULT_VERSION) + parser.add_argument( + "--profiles", + type=Path, + default=DEFAULT_PROFILE_FILE, + help=f"ROM profile registry (default: {DEFAULT_PROFILE_FILE})", + ) parser.add_argument( - "--include-slice-resumes", action="store_true", + "--include-slice-resumes", + action="store_true", help="also seed kind-1 scheduler resume PCs (usually fragments code)", ) args = parser.parse_args() + profile = load_profile(args.profiles.resolve(), args.version) + expected_sha1 = str(profile["sha1"]) + bank = args.bank or str(profile["fmv_runtime_bank"]) + require_identity = args.version != DEFAULT_VERSION + image = args.image.read_bytes() if len(image) != IMAGE_SIZE: raise SystemExit( f"runtime image is 0x{len(image):X} bytes; expected 0x{IMAGE_SIZE:X}" ) identity = hashlib.sha1(image).hexdigest() + report = json.loads(args.benchmark.read_text(encoding="utf-8")) + verify_report_identity( + report, + version=args.version, + expected_sha1=expected_sha1, + label="benchmark", + require_identity=require_identity, + ) + if require_identity: + runtime_capture = report.get("runtime_capture") + if not isinstance(runtime_capture, dict): + raise SystemExit( + f"benchmark for {args.version} has no runtime_capture metadata" + ) + capture_sha1 = runtime_capture.get("sha1") + capture_bytes_raw = runtime_capture.get("bytes") + if capture_sha1 != identity: + raise SystemExit( + f"runtime image SHA-1 {identity} does not match benchmark " + f"capture SHA-1 {capture_sha1!r}" + ) + try: + capture_bytes = int(capture_bytes_raw) + except (TypeError, ValueError) as exc: + raise SystemExit( + f"benchmark runtime capture has invalid byte count " + f"{capture_bytes_raw!r}" + ) from exc + if capture_bytes != IMAGE_SIZE: + raise SystemExit( + f"benchmark runtime capture size {capture_bytes} does not " + f"match 0x{IMAGE_SIZE:X}" + ) + coverage = report.get("tier3_coverage", {}).get("entries", []) + if args.before_benchmark is not None: before_report = json.loads( args.before_benchmark.read_text(encoding="utf-8") ) + verify_report_identity( + before_report, + version=args.version, + expected_sha1=expected_sha1, + label="before-benchmark", + require_identity=require_identity, + ) before_entries = before_report.get( "tier3_coverage", {} ).get("entries", []) - - def coverage_key(item: dict[str, object]) -> tuple[int, ...]: - return tuple( - int(item[field]) - for field in ("cpu", "pc", "thumb", "kind", "caller") - ) - before_hits = { coverage_key(item): int(item["hits"]) for item in before_entries } coverage = [ - item for item in coverage + item + for item in coverage if int(item["hits"]) - before_hits.get(coverage_key(item), 0) > 0 ] + entries = sorted({ (int(item["pc"]), "thumb" if int(item["thumb"]) else "arm") for item in coverage @@ -74,14 +160,16 @@ def coverage_key(item: dict[str, object]) -> tuple[int, ...]: if not entries: raise SystemExit("benchmark contains no ARM9 runtime Tier-3 coverage") + display_name = str(profile["display_name"]) lines = [ "# AUTO-GENERATED by tools/promote_mph_runtime_coverage.py; committed.", - "# Source is generated/capture/mph_arm9_fmv_runtime.bin (git-ignored).", + f"# Profile: {args.version}; retail ROM SHA-1: {expected_sha1}.", + f"# Source image: {args.image.name} (git-ignored capture artifact).", "# Every function is validated against live guest bytes before dispatch.", "", "[program]", - 'name = "Metroid Prime Hunters (USA) ARM9 FMV runtime"', - f'id = "{args.bank}"', + f'name = "{display_name} ARM9 FMV runtime"', + f'id = "{bank}"', 'cpu = "arm9"', 'isa = "armv5te"', f"load_address = 0x{ITCM_BASE:08X}", @@ -104,7 +192,10 @@ def coverage_key(item: dict[str, object]) -> tuple[int, ...]: args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text("\n".join(lines), encoding="utf-8", newline="\n") print(json.dumps({ + "profile": args.version, + "game_sha1": expected_sha1, "config": str(args.out), + "bank": bank, "image_sha1": identity, "entry_points": len(entries), }, indent=2)) diff --git a/tools/promote_mph_static_coverage.py b/tools/promote_mph_static_coverage.py index d2b1173..864f965 100644 --- a/tools/promote_mph_static_coverage.py +++ b/tools/promote_mph_static_coverage.py @@ -1,37 +1,130 @@ #!/usr/bin/env python3 -"""Promote deterministic Tier-3 call targets into reproducible main-bank seeds.""" +"""Promote deterministic Tier-3 call targets into profile-specific main-bank seeds.""" from __future__ import annotations import argparse import json +import tomllib from pathlib import Path +from mph_profile import ( + DEFAULT_PROFILE_FILE, + DEFAULT_VERSION, + default_generated_inputs_dir, + load_profile, +) + -GAME_SHA1 = "90164d1ac127ee5f9815ea4ae7de798c7b5fc629" -MAIN_RANGES = { - 9: (0x02004000, 0x020E19D8), - 7: (0x02380000, 0x023A8464), -} PROMOTABLE_KINDS = {2: "call", 3: "indirect"} -EXISTING_SEEDS = {(9, 0x02004800, 0), (7, 0x02380000, 0)} + + +def load_program(path: Path, expected_id: str) -> tuple[int, int, int]: + try: + with path.open("rb") as f: + document = tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise SystemExit(f"unable to read generated program config {path}: {exc}") from exc + + program = document.get("program") + if not isinstance(program, dict): + raise SystemExit(f"{path} has no [program] table") + if str(program.get("id", "")) != expected_id: + raise SystemExit( + f"{path} program.id={program.get('id')!r}; expected {expected_id!r}" + ) + + try: + load_address = int(program["load_address"]) + size = int(program["size"]) + entry_pc = int(program["entry_pc"]) + except (KeyError, TypeError, ValueError) as exc: + raise SystemExit(f"{path} has invalid program geometry: {exc}") from exc + + if size <= 0: + raise SystemExit(f"{path} program.size must be positive") + if not load_address <= entry_pc < load_address + size: + raise SystemExit( + f"{path} entry_pc 0x{entry_pc:08X} is outside " + f"0x{load_address:08X}..0x{load_address + size:08X}" + ) + return load_address, load_address + size, entry_pc def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--trace", type=Path, required=True) parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--scenario") + parser.add_argument("--runner-commit", required=True) + parser.add_argument("--version", default=DEFAULT_VERSION) parser.add_argument( - "--scenario", default="scenarios/adventure_start.json" + "--profiles", + type=Path, + default=DEFAULT_PROFILE_FILE, + help=f"ROM profile registry (default: {DEFAULT_PROFILE_FILE})", + ) + parser.add_argument( + "--inputs", + type=Path, + help=( + "prepared input directory containing arm9.toml/arm7.toml; " + "defaults to the selected profile's generated inputs directory" + ), ) - parser.add_argument("--runner-commit", required=True) args = parser.parse_args() + profile = load_profile(args.profiles.resolve(), args.version) + inputs = ( + args.inputs.resolve() + if args.inputs is not None + else default_generated_inputs_dir(args.version).resolve() + ) + program_id = str(profile["program_id"]) + + main_ranges: dict[int, tuple[int, int]] = {} + existing_seeds: set[tuple[int, int, int]] = set() + geometry: dict[str, dict[str, str]] = {} + for cpu, filename, suffix in ( + (9, "arm9.toml", "arm9"), + (7, "arm7.toml", "arm7"), + ): + start, end, entry_pc = load_program( + inputs / filename, f"{program_id}_{suffix}" + ) + main_ranges[cpu] = (start, end) + existing_seeds.add((cpu, entry_pc, 0)) + geometry[suffix] = { + "start": f"0x{start:08x}", + "end": f"0x{end:08x}", + "entry_pc": f"0x{entry_pc:08x}", + } + trace = json.loads(args.trace.read_text(encoding="utf-8")) + observed_profile = trace.get("mph_profile") + observed_sha1 = trace.get("rom_sha1") + expected_sha1 = str(profile["sha1"]) + if args.version != DEFAULT_VERSION and ( + observed_profile is None or observed_sha1 is None + ): + raise SystemExit( + f"{args.version} coverage promotion requires a profile-tagged trace; " + "recapture with the profile-aware fuzz/capture tooling" + ) + if observed_profile is not None and str(observed_profile) != args.version: + raise SystemExit( + f"trace profile {observed_profile!r} does not match {args.version!r}" + ) + if observed_sha1 is not None and str(observed_sha1) != expected_sha1: + raise SystemExit( + f"trace ROM SHA-1 {observed_sha1!r} does not match " + f"{args.version} ({expected_sha1})" + ) + coverage = trace.get("tier3_coverage", {}).get("entries", []) if not coverage: raise SystemExit( - "trace has no Tier-3 addresses; run with --discover-static-misses" + "trace has no Tier-3 addresses; capture with --discover-static-misses" ) grouped: dict[tuple[int, int, int], dict[str, object]] = {} @@ -40,13 +133,13 @@ def main() -> int: pc = int(observed["pc"]) thumb = int(observed["thumb"]) kind = int(observed["kind"]) - if cpu not in MAIN_RANGES or kind not in PROMOTABLE_KINDS: + if cpu not in main_ranges or kind not in PROMOTABLE_KINDS: continue - start, end = MAIN_RANGES[cpu] + start, end = main_ranges[cpu] if not start <= pc < end: continue key = (cpu, pc, thumb) - if key in EXISTING_SEEDS: + if key in existing_seeds: continue entry = grouped.setdefault( key, @@ -65,20 +158,23 @@ def main() -> int: kinds.append(label) by_cpu: dict[str, list[dict[str, object]]] = {"arm9": [], "arm7": []} - for (cpu, pc, thumb), entry in sorted(grouped.items()): - del pc, thumb + for (cpu, _pc, _thumb), entry in sorted(grouped.items()): entry["kinds"] = sorted(entry["kinds"]) by_cpu["arm7" if cpu == 7 else "arm9"].append(entry) + scenario = args.scenario if args.scenario is not None else trace.get("scenario") payload = { "schema": 1, - "game_sha1": GAME_SHA1, - "scenario": args.scenario, + "profile": args.version, + "game_sha1": expected_sha1, + "scenario": scenario, "runner_commit": args.runner_commit, "selection": ( - "Tier-3 call and indirect targets inside immutable ARM9/ARM7 " - "main-image ranges; slice-resume roots and runtime overlays omitted" + "Tier-3 call and indirect targets inside the selected ROM's " + "prepared immutable ARM9/ARM7 main-image ranges; slice-resume " + "roots and runtime overlays omitted" ), + "main_image": geometry, "static_coverage": trace.get("static_coverage", {}), "entry_points": by_cpu, } @@ -87,7 +183,7 @@ def main() -> int: json.dumps(payload, indent=2) + "\n", encoding="utf-8", newline="\n" ) print( - f"wrote {len(by_cpu['arm9'])} ARM9 and " + f"{args.version}: wrote {len(by_cpu['arm9'])} ARM9 and " f"{len(by_cpu['arm7'])} ARM7 static coverage seeds to {args.out}" ) return 0 diff --git a/tools/tests/mph_runtime_profile_test.cpp b/tools/tests/mph_runtime_profile_test.cpp new file mode 100644 index 0000000..21b99f6 --- /dev/null +++ b/tools/tests/mph_runtime_profile_test.cpp @@ -0,0 +1,226 @@ +#include +#include +#include +#include +#include + +#include "state.h" +#include "title_patches.h" + +namespace { + +struct Write32 { + uint32_t addr; + uint32_t value; +}; + +struct RuntimeCase { + const char* name; + const char* game_code; + uint8_t revision; + std::array crc_suffix; + uint32_t morph; + uint32_t aim_x; + uint32_t aim_y; +}; + +// Each synthetic ROM uses a 0-byte ARM9 and a 4-byte ARM7. The four suffix +// bytes were solved so melonPrimeDS's CRC32(header[0:0x40], ARM9, ARM7) +// equals the canonical checksum for the corresponding retail profile. This +// tests the real detector without shipping or reading copyrighted ROM data. +constexpr RuntimeCase kCases[] = { + {"US1_0", "AMHE", 0, {0x45, 0x3D, 0xE6, 0x16}, 0x020DA818u, 0x020DE526u, 0x020DE52Eu}, + {"US1_1", "AMHE", 1, {0xE0, 0xF3, 0x6E, 0x2A}, 0x020DB098u, 0x020DEDA6u, 0x020DEDAEu}, + {"EU1_0", "AMHP", 0, {0x66, 0x59, 0x8A, 0xC0}, 0x020DB0B8u, 0x020DEDC6u, 0x020DEDCEu}, + {"EU1_1", "AMHP", 1, {0x7B, 0xD4, 0x8E, 0xCB}, 0x020DB138u, 0x020DEE46u, 0x020DEE4Eu}, + {"JP1_0", "AMHJ", 0, {0xF0, 0x91, 0x6F, 0xD1}, 0x020DC6D8u, 0x020E03E6u, 0x020E03EEu}, + {"JP1_1", "AMHJ", 1, {0x89, 0x6F, 0x91, 0xF1}, 0x020DC698u, 0x020E03A6u, 0x020E03AEu}, + {"KR1_0", "AMHK", 0, {0x4C, 0xE9, 0x78, 0xBD}, 0x020D3EE4u, 0x020D7C0Eu, 0x020D7C16u}, +}; + +constexpr const char* kUs10Sha1 = + "90164d1ac127ee5f9815ea4ae7de798c7b5fc629"; +constexpr const char* kEu11Sha1 = + "bdcd1dea293e24c98d4c481430e90d21198985a5"; +constexpr const char* kUnknownSha1 = + "0000000000000000000000000000000000000000"; + +std::vector g_writes; +uint32_t g_morph_addr = 0; +uint8_t g_morph_value = 0; +int g_failures = 0; + +void expect(bool condition, const char* message) { + if (condition) return; + std::fprintf(stderr, "FAIL: %s\n", message); + ++g_failures; +} + +void expect_write(std::size_t index, uint32_t addr, uint32_t value, + const char* message) { + const bool ok = index < g_writes.size() && + g_writes[index].addr == addr && + g_writes[index].value == value; + expect(ok, message); +} + +void write_le32(std::vector& rom, std::size_t offset, uint32_t value) { + rom[offset + 0] = static_cast(value >> 0); + rom[offset + 1] = static_cast(value >> 8); + rom[offset + 2] = static_cast(value >> 16); + rom[offset + 3] = static_cast(value >> 24); +} + +std::vector make_rom( + const char* game_code, uint8_t revision, std::array suffix) { + std::vector rom(0x44u, 0u); + std::memcpy(rom.data() + 0x0Cu, game_code, 4u); + rom[0x1Eu] = revision; + write_le32(rom, 0x20u, 0x40u); // ARM9 ROM offset + write_le32(rom, 0x2Cu, 0u); // ARM9 size + write_le32(rom, 0x30u, 0x40u); // ARM7 ROM offset + write_le32(rom, 0x3Cu, 4u); // ARM7 size + std::memcpy(rom.data() + 0x40u, suffix.data(), suffix.size()); + return rom; +} + +bool select_rom( + const std::vector& rom, + const char* actual_sha1 = kUnknownSha1, + const char* expected_sha1 = "") { + return nds_title_patches_select_mph_runtime_profile( + rom.data(), static_cast(rom.size()), + actual_sha1, expected_sha1); +} + +} // namespace + +bool bus_get_region(const char*, BusRegion*) { + return false; +} + +extern "C" uint8_t bus_read_u8_slow(uint32_t addr) { + return addr == g_morph_addr ? g_morph_value : 0u; +} + +extern "C" void bus_write_u32_slow(uint32_t addr, uint32_t value) { + g_writes.push_back({addr, value}); +} + +int main() { + for (const RuntimeCase& c : kCases) { + const auto rom = make_rom(c.game_code, c.revision, c.crc_suffix); + expect(select_rom(rom), c.name); + expect(nds_title_patches_mph_host_writes_compatible(), + "canonical executable checksum must authorize host writes"); + + g_writes.clear(); + expect(!nds_title_patches_apply_mph_mouse_delta(7, -5), + "profile switch must clear direct-aim enable state"); + nds_title_patches_set_mph_mouse_aim(true); + expect(nds_title_patches_apply_mph_mouse_delta(7, -5), + "authoritative checksum must accept direct mouse aim"); + expect(g_writes.size() == 2, + "recognized profile must perform two aim writes"); + expect_write(0, c.aim_x, 7u, + "X delta must target profile-specific baseAimX"); + expect_write(1, c.aim_y, static_cast(-5), + "Y delta must target profile-specific baseAimY"); + + g_morph_addr = c.morph; + g_morph_value = 0x02u; + expect(nds_title_patches_mph_in_ball(), + "baseIsAltForm=2 must report Morph Ball"); + g_morph_value = 0x00u; + expect(!nds_title_patches_mph_in_ball(), + "non-alt form must not report Morph Ball"); + } + + // Whole-ROM SHA mismatch is allowed only when a clean build sees the same + // canonical executable checksum. This supports data-only variants without + // reusing a clean build for code-modified content. + const auto us10 = make_rom("AMHE", 0, {0x45, 0x3D, 0xE6, 0x16}); + expect(select_rom(us10, kUnknownSha1, kUs10Sha1), + "US1.0 canonical executable variant must select"); + expect(nds_title_patches_mph_allows_rom_sha1_mismatch(), + "canonical US1.0 executable may reuse US1.0 clean build"); + + const auto eu11 = make_rom("AMHP", 1, {0x7B, 0xD4, 0x8E, 0xCB}); + expect(select_rom(eu11, kUnknownSha1, kUs10Sha1), + "EU1.1 canonical executable must still identify EU1.1 base"); + expect(!nds_title_patches_mph_allows_rom_sha1_mismatch(), + "EU1.1 executable must never bypass a US1.0 clean SHA gate"); + + // melonPrimeDS explicitly recognizes this EU1.1 Russian variant checksum, + // so its RAM layout is authoritative and host writes are safe. However its + // executable checksum is not the canonical clean EU1.1 checksum, therefore + // it still needs its own exact mod-specific recomp build/capture identity. + const auto eu11_russian = + make_rom("AMHP", 1, {0xCE, 0x58, 0x0D, 0xA4}); // 0x9E20F3A8 + expect(select_rom(eu11_russian, kUnknownSha1, kEu11Sha1), + "known EU1.1 Russian executable must identify EU1.1 base"); + expect(nds_title_patches_mph_host_writes_compatible(), + "known EU1.1 Russian checksum must authorize its address table"); + expect(!nds_title_patches_mph_allows_rom_sha1_mismatch(), + "code-modified known variant must not reuse clean EU1.1 build"); + + // Unknown checksum with a valid header is only a candidate base profile. + // It may be reported/routed as that base, but dangerous host RAM accesses + // remain disabled until its executable checksum is explicitly validated. + const auto unknown_us10 = make_rom("AMHE", 0, {0, 0, 0, 0}); + expect(select_rom(unknown_us10), + "unknown checksum with supported header should identify a candidate base"); + expect(!nds_title_patches_mph_host_writes_compatible(), + "header fallback alone must not authorize host writes"); + g_writes.clear(); + nds_title_patches_set_mph_mouse_aim(true); + expect(!nds_title_patches_apply_mph_mouse_delta(1, 1), + "unknown executable checksum must reject direct mouse writes"); + expect(g_writes.empty(), + "unknown executable checksum must not touch profile RAM"); + g_morph_addr = 0x020DA818u; + g_morph_value = 0x02u; + expect(!nds_title_patches_mph_in_ball(), + "unknown executable checksum must reject morph-state host read"); + expect(!nds_title_patches_mph_allows_rom_sha1_mismatch(), + "unknown executable checksum must not bypass clean SHA gate"); + + // Exact clean whole-ROM SHA cannot contradict the selected executable base. + expect(!select_rom(eu11, kUs10Sha1, ""), + "known-clean US1.0 SHA with EU1.1 executable must fail closed"); + expect(!select_rom(us10, kEu11Sha1, ""), + "known-clean EU1.1 SHA with US1.0 executable must fail closed"); + + // Unsupported headers/revisions and malformed binary ranges fail closed. + const auto unknown_code = make_rom("ZZZZ", 0, {0, 0, 0, 0}); + expect(!select_rom(unknown_code), "unknown game code must fail closed"); + const auto rev2 = make_rom("AMHE", 2, {0, 0, 0, 0}); + expect(!select_rom(rev2), "unknown USA revision must fail closed"); + const auto kr_rev1 = make_rom("AMHK", 1, {0, 0, 0, 0}); + expect(!select_rom(kr_rev1), "unknown Korea revision must fail closed"); + + auto bad_range = make_rom("AMHE", 0, {0, 0, 0, 0}); + write_le32(bad_range, 0x20u, 0xFFFFFFF0u); + write_le32(bad_range, 0x2Cu, 0x100u); + expect(!select_rom(bad_range), "out-of-range ARM9 image must fail closed"); + + std::vector tiny(0x1Eu, 0u); + expect(!nds_title_patches_select_mph_runtime_profile( + tiny.data(), static_cast(tiny.size()), + kUnknownSha1, ""), + "truncated NDS header must fail closed"); + expect(!nds_title_patches_select_mph_runtime_profile( + us10.data(), static_cast(us10.size()), + nullptr, ""), + "missing actual-content identity must fail closed"); + + if (g_failures != 0) { + std::fprintf(stderr, "%d runtime-profile assertion(s) failed\n", + g_failures); + return 1; + } + std::puts( + "OK: seven MPH base profiles use melonPrimeDS executable CRC; " + "header fallback and whole-ROM provenance gates fail closed"); + return 0; +}