From bc34bd95f475e25e6d69371cdcb9201521e22262 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:15:33 +0900 Subject: [PATCH 01/97] Add MPH ROM profile registry --- config/mph_rom_profiles.json | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 config/mph_rom_profiles.json diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json new file mode 100644 index 0000000..442af6a --- /dev/null +++ b/config/mph_rom_profiles.json @@ -0,0 +1,29 @@ +{ + "schema": 1, + "profiles": { + "US1_0": { + "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 + }, + "EU1_1": { + "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 + } + } +} From fb5abc9b332e7512ddccf22b4cb277c3ad8c6edb Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:15:47 +0900 Subject: [PATCH 02/97] Add EU1.1 bootstrap coverage identity --- coverage/eu11-bootstrap-entry-points.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 coverage/eu11-bootstrap-entry-points.json 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": [] + } +} From 3b9254eb50ca18dfb69723826440e7cc2be5b072 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:16:01 +0900 Subject: [PATCH 03/97] Add safe EU1.1 runtime config --- config/game-eu11.toml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 config/game-eu11.toml diff --git a/config/game-eu11.toml b/config/game-eu11.toml new file mode 100644 index 0000000..1778a05 --- /dev/null +++ b/config/game-eu11.toml @@ -0,0 +1,26 @@ +[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] +# Keep EU1.1 bootstrap on native presentation until title-specific projection, +# culling and HUD-address assumptions are validated for this revision. +screen_layout = "separate" +supersampling = 1 +antialiasing = 0 + +[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 From 8acf2819f4910f1e9e3ad245224e749eaf247136 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:16:39 +0900 Subject: [PATCH 04/97] Generalize MPH input preparation by ROM profile --- tools/prepare_mph.py | 177 +++++++++++++++++++++++++++++++------------ 1 file changed, 128 insertions(+), 49 deletions(-) diff --git a/tools/prepare_mph.py b/tools/prepare_mph.py index af509d7..193cc70 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[0x1C] != 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[0x1C]}, " + 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}" From 9d777a86cd781fbf29b79ab914627ecf7556c6f6 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:17:08 +0900 Subject: [PATCH 05/97] Make MPH banks version-profile driven --- CMakeLists.txt | 144 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 100 insertions(+), 44 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f7ea40..4a9c964 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.20) +cmake_minimum_required(VERSION 3.20) project(MetroidPrimeHuntersRecomp VERSION 0.3.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) @@ -18,13 +18,55 @@ add_subdirectory( "${NDSRECOMP_ROOT}/recompiler" "${CMAKE_BINARY_DIR}/ndsrecomp-recompiler") +set(MPH_VERSION "US1_0" CACHE STRING + "Metroid Prime Hunters retail revision profile") +set_property(CACHE MPH_VERSION PROPERTY STRINGS US1_0 EU1_1) +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_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) + 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 +90,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 +135,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 +153,55 @@ 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_arm9_fmv_runtime.toml") + set(MPH_FMV_RUNTIME_IMAGE + "${MPH_GENERATED_ROOT}/capture/mph_arm9_fmv_runtime.bin" + CACHE FILEPATH "Deterministic MPH 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_arm9_fmv_runtime_${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_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 ${MPH_VERSION} ARM9 FMV runtime code") + else() + message(STATUS + "MPH FMV runtime bank disabled: capture image is not present") + 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 +230,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() From 2ecf3999e2d54d8b47fbacd6bb62332d8c7aef36 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:17:30 +0900 Subject: [PATCH 06/97] Make ROM checker follow selected MPH profile --- src/main.cpp | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) 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; } From 99edb5122c367cd57436d46b3db005ab7c6ce9c3 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:17:52 +0900 Subject: [PATCH 07/97] Add EU1.1 Windows bring-up build path --- tools/build-windows.ps1 | 67 +++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/tools/build-windows.ps1 b/tools/build-windows.ps1 index 3f75d53..731d788 100644 --- a/tools/build-windows.ps1 +++ b/tools/build-windows.ps1 @@ -1,21 +1,23 @@ <# -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 path, including the recomp-ui launcher and +portable ZIP. Other profiles currently build the title banks and runner only; +they are bring-up builds until their title-specific runtime hooks are validated. 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 #> param( [string]$Version = '0.1.0', + [ValidateSet('US1_0', 'EU1_1')] + [string]$MphVersion = 'US1_0', [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]$GameBuildDir = '', + [string]$RunnerBuildDir = '', [string]$LauncherBuildDir = 'launcher\recomp-ui\build-release', [string]$RuntimeBinDir = 'C:\msys64\mingw64\bin', [string]$RecompUiRoot = 'F:\Projects\recomp-ui' @@ -28,19 +30,51 @@ 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) { + throw "Unknown MPH profile: $MphVersion" +} +$profile = $profileProperty.Value +$romSha1 = [string]$profile.sha1 + +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" + } +} + $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")) +} Push-Location $root try { + Write-Host "Building MPH profile $MphVersion ($($profile.game_code) rev $($profile.revision))" + & $cmakePath -G $Generator -S $root -B $gameBuild ` -DCMAKE_BUILD_TYPE=Release ` - -DNDSRECOMP_ROOT="$frameworkRoot" + -DNDSRECOMP_ROOT="$frameworkRoot" ` + -DMPH_VERSION="$MphVersion" 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.' } @@ -50,14 +84,27 @@ try { "-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.' } + if ($MphVersion -ne 'US1_0') { + $gameConfig = [IO.Path]::GetFullPath( + (Join-Path $root ([string]$profile.game_config))) + Write-Host '' + Write-Host "Bring-up runner built: $runnerBuild\nds_runner.exe" + Write-Host "Use the revision-specific config: $gameConfig" + Write-Host 'Launcher/release packaging intentionally skipped for this profile.' + Write-Host 'Prime Controls and direct mouse aim remain USA-only until revision addresses are validated.' + return + } + & $cmakePath -G $Generator -S "$root\launcher\recomp-ui" -B $launcherBuild ` -DCMAKE_BUILD_TYPE=Release ` -DRECOMP_UI_ROOT="$RecompUiRoot" ` -DCMAKE_PREFIX_PATH="$RuntimeBinDir\..\lib\cmake" if ($LASTEXITCODE -ne 0) { throw 'Launcher CMake configure failed.' } + & $cmakePath --build $launcherBuild -j $Jobs if ($LASTEXITCODE -ne 0) { throw 'Launcher build failed.' } From f613b6a43565687da3ffe6a20c61b0855471cfda Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:18:18 +0900 Subject: [PATCH 08/97] Add EU1.1 Linux bring-up build path --- tools/build-linux.sh | 76 +++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/tools/build-linux.sh b/tools/build-linux.sh index c2bd0bb..6fc7302 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -1,40 +1,70 @@ #!/usr/bin/env bash -# Build a Metroid Prime Hunters Recomp Linux x86_64 AppImage. +# Build Metroid Prime Hunters Recomp for a configured retail revision. # -# 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. +# US1_0 keeps the existing AppImage behavior. EU1_1 uses its own generated +# bank/config paths and deliberately omits the USA-only FMV-bank assertion. set -euo pipefail APP_NAME="MetroidPrimeHuntersRecomp" TITLE_TARGET="metroidprimehuntersrecomp" -ROM_SHA1="90164d1ac127ee5f9815ea4ae7de798c7b5fc629" RUNNER_NAME="nds_runner" VERSION="0.1.0" +MPH_VERSION="US1_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" +PROFILE_FILE="$REPO/config/mph_rom_profiles.json" while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2;; + --mph-version) MPH_VERSION="$2"; shift 2;; --jobs) JOBS="$2"; shift 2;; --out) OUT="$2"; shift 2;; --no-package) DO_PACKAGE=0; shift;; -h|--help) - sed -n '2,14p' "$0" + sed -n '2,16p' "$0" exit 0 ;; *) echo "unknown arg: $1" >&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_value() { + python3 - "$PROFILE_FILE" "$MPH_VERSION" "$1" <<'PY' +import json +import sys +path, version, key = sys.argv[1:] +with open(path, encoding="utf-8") as f: + registry = json.load(f) +try: + value = registry["profiles"][version][key] +except KeyError as exc: + raise SystemExit(f"unknown profile/field: {version}.{key}") from exc +if isinstance(value, bool): + print("1" if value else "0") +else: + print(value) +PY +} + +ROM_SHA1="$(profile_value sha1)" +GAME_CONFIG_REL="$(profile_value game_config)" +FMV_RUNTIME="$(profile_value fmv_runtime)" +GAME_CONFIG="$REPO/$GAME_CONFIG_REL" + +if [ "$MPH_VERSION" = "US1_0" ]; then + GAME_BUILD="$REPO/build-linux-release" + RUNNER_BUILD="$FRAMEWORK_ROOT/runner/build-mph-linux-release" + TITLE_BANK_DIR="$REPO/generated/recomp" +else + GAME_BUILD="$REPO/build-linux-release-$MPH_VERSION" + RUNNER_BUILD="$FRAMEWORK_ROOT/runner/build-mph-linux-release-$MPH_VERSION" + TITLE_BANK_DIR="$REPO/generated/$MPH_VERSION/recomp" +fi cd "$REPO" test -f "$FRAMEWORK_ROOT/recompiler/CMakeLists.txt" || { @@ -45,11 +75,16 @@ test -f "$REPO/Metroid Prime Hunters.nds" || { echo "ERROR: verified Metroid Prime Hunters ROM is missing from the repo root." >&2 exit 1 } +test -f "$GAME_CONFIG" || { + echo "ERROR: game config for $MPH_VERSION is missing: $GAME_CONFIG" >&2 + exit 1 +} -echo "[1/4] configure title banks" +echo "[1/4] configure title banks ($MPH_VERSION)" cmake -S "$REPO" -B "$GAME_BUILD" -G "Unix Makefiles" \ -DCMAKE_BUILD_TYPE=Release \ - -DNDSRECOMP_ROOT="$FRAMEWORK_ROOT" + -DNDSRECOMP_ROOT="$FRAMEWORK_ROOT" \ + -DMPH_VERSION="$MPH_VERSION" echo "[2/4] build title banks" cmake --build "$GAME_BUILD" --target "$TITLE_TARGET" -j"$JOBS" @@ -64,15 +99,18 @@ cmake --build "$RUNNER_BUILD" -j"$JOBS" if [ "$DO_PACKAGE" = "0" ]; then echo "done: $RUNNER_BUILD/$RUNNER_NAME" + echo "config: $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 -} +if [ "$FMV_RUNTIME" = "1" ]; then + strings "$BIN" | grep -q mph_arm9_fmv_runtime || { + echo "ERROR: runner does not contain the MPH FMV runtime bank." >&2 + exit 1 + } +fi LINUXDEPLOY_URL=https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage LINUXDEPLOY_SHA=421ca71d5c69ea97c6309276232990d43df1dcece0edfaa26bbf926ff96ed12e @@ -101,7 +139,7 @@ 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 "$GAME_CONFIG" "$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" @@ -148,9 +186,9 @@ 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 + exec "$HERE/usr/bin/nds_runner" "$RUNDIR/bios" --interactive --rom "$ROM" --config "$HERE/usr/bin/game.toml" 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 + exec "$HERE/usr/bin/nds_runner" "$RUNDIR/bios" --interactive --config "$HERE/usr/bin/game.toml" fi exec "$HERE/usr/bin/nds_runner" "$@" EOF @@ -161,7 +199,7 @@ echo "[4/4] package AppImage" --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" +APP="$OUT/$APP_NAME-$MPH_VERSION-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" From 37924ba6cb83c48c1b5abf253954ca851a3c6a91 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:18:46 +0900 Subject: [PATCH 09/97] Document EU1.1 bring-up gates --- docs/EU1_1_BRINGUP.md | 307 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 docs/EU1_1_BRINGUP.md diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md new file mode 100644 index 0000000..817c7c1 --- /dev/null +++ b/docs/EU1_1_BRINGUP.md @@ -0,0 +1,307 @@ +# Metroid Prime Hunters Recomp - EU1.1 Bring-up + +作成日: 2026-08-17 + +## 1. 目的 + +`MetroidPrimeHuntersRecomp` を USA revision 0 (`AMHE`, revision 0) 固定から段階的に +multi-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revision 1) +を安全にbring-upする。 + +この段階は「EU1.1を正式対応済みにする」ものではない。 +まず以下を成立させる。 + +1. ROM identityを版別profileとして管理する。 +2. EU1.1 ROMからARM9 / ARM7 / ARM9 overlayを直接抽出できる。 +3. EU1.1専用bankをUS1.0生成物から分離して生成できる。 +4. US1.0のcoverage seedやFMV runtime captureをEU1.1へ流用しない。 +5. exact ROM SHA-1でrunner側のbank登録をgateする。 +6. EU1.1固有の未解析箇所はInterpreter fallbackへ安全に落とす。 + +## 2. EU1.1 identity + +| Field | EU1.1 | +|---|---| +| Profile key | `EU1_1` | +| Game Code | `AMHP` | +| Revision | `1` | +| ROM size | `0x04000000` / 64 MiB | +| SHA-1 | `bdcd1dea293e24c98d4c481430e90d21198985a5` | +| Program ID prefix | `mph_amhp1` | + +identityは `config/mph_rom_profiles.json` に集約する。 + +## 3. 今回追加するbootstrap構造 + +### 3.1 Profile registry + +`config/mph_rom_profiles.json` + +US1.0とEU1.1を同一schemaで管理する。 + +Profileには最低限、 + +- `game_code` +- `revision` +- `rom_size` +- `sha1` +- `program_id` +- `coverage` +- `game_config` +- `fmv_runtime` + +を持たせる。 + +### 3.2 EU1.1 coverage seed + +`coverage/eu11-bootstrap-entry-points.json` + +初期状態ではARM9 / ARM7の追加coverage rootを空にする。 + +これは意図的である。 + +`prepare_mph.py` はROM headerのARM9/ARM7 entry PCを必ずseedするため、 +EU1.1はまずそのrootからstatic discoveryを行い、未コンパイル領域は +runtime Interpreterへfallbackする。 + +US1.0の `coverage/adventure-main-entry-points.json` に入っているアドレスを +EU1.1へそのままコピーしてはいけない。 + +### 3.3 Generated tree separation + +US1.0は後方互換性のため従来通り: + +```text +generated/ + inputs/ + recomp/ + capture/ +``` + +EU1.1は: + +```text +generated/ + EU1_1/ + inputs/ + recomp/ +``` + +とする。 + +これにより異なるROM由来のbankやbinaryが同じパスへ混ざらない。 + +## 4. mphCodexで確認済みのEU1.1差分例 + +AllVersions調査ではEU1.1に次の対応が確認されている。 + +| Semantic | US1.0 | EU1.1 | +|---|---:|---:| +| Current Camera Sequence | `0x020D9CB0` | `0x020DA5D0` | +| Game Mode | `0x020E78FC` | `0x020E845C` | +| Upper HUD function | `0x0202F600` | `0x0202F5E0` | +| Crosshair callsite | `0x0202F934` | `0x0202F904` | +| Crosshair renderer | `0x020393D4` | `0x02039338` | +| Local Player Pointer | `0x020BCA70` | `0x020BD370` | +| HUD suppression storage | `0x020DE748` | `0x020DF068` | + +重要なのは、差分が単一の固定deltaではないことである。 + +したがって今後のtitle patch / enhancementは、 + +```text +US1.0 address + region offset +``` + +ではなく、 + +```text +ROM identity -> semantic runtime profile -> exact address +``` + +で管理する。 + +## 5. 現時点でEU1.1へ有効化しない機能 + +### 5.1 Prime Controls / Morph state + +pinned `ndsrecomp` runnerのPrime ControlsにはUS1.0専用の + +```text +0x020DA818 +``` + +が直接使われている。 + +EU1.1側の同semanticアドレスを確認するまでは無効のままにする。 + +### 5.2 Direct Mouse Aim + +`runner/src/title_patches.cpp` のDirect Mouse AimにはUS1.0専用の + +```text +Aim X: 0x020DE526 +Aim Y: 0x020DE52E +``` + +が使われている。 + +これもEU1.1 mapping完了前には有効化しない。 + +### 5.3 USA FMV runtime bank + +US1.0の + +```text +config/mph_arm9_fmv_runtime.toml +generated/capture/mph_arm9_fmv_runtime.bin +``` + +はUS1.0のruntime bytesとobserved PCsに対するbankである。 + +EU1.1では `fmv_runtime=false` とし、絶対に登録しない。 + +EU1.1のFMV高速化はEU1.1実行から独立captureを作成した後に行う。 + +### 5.4 Launcherのsupported-ROM list + +recomp-ui launcherのsupported SHA-1配列にはまだEU1.1を追加しない。 + +理由は、launcherがPrime Controls等を通常のplayer-facing featureとして有効化する +経路を持つためである。 + +EU1.1の基本bootとruntime profileが検証されるまで「正式対応」と表示しない。 + +## 6. Build + +### Windows bring-up + +EU1.1 ROMをrepo rootの + +```text +Metroid Prime Hunters.nds +``` + +として配置した上で: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + tools\build-windows.ps1 ` + -MphVersion EU1_1 +``` + +EU1.1ではtitle bankとrunnerまでをbuildし、launcher/release packagingは +意図的にskipする。 + +revision-specific runtime config: + +```text +config/game-eu11.toml +``` + +### Linux bring-up + +```bash +tools/build-linux.sh --mph-version EU1_1 --no-package +``` + +AppImage生成まで行う場合: + +```bash +tools/build-linux.sh --mph-version EU1_1 +``` + +package内の `game.toml` は `config/game-eu11.toml` から生成する。 + +## 7. ROMが手元にある段階で行う次工程 + +### Gate A - extraction / static bank + +1. `prepare_mph.py` がEU1.1 identityをacceptする。 +2. ARM9を正しくdecompressする。 +3. ARM7を抽出する。 +4. overlay tableを列挙する。 +5. `generated/EU1_1/recomp/` にEU1.1専用bankを生成する。 +6. US1.0 artifactを一切参照していないことを確認する。 + +### Gate B - interpreter bootstrap + +EU1.1専用bankをexact SHA-1 gateでrunnerへ登録し、 + +- firmware boot +- cartridge handoff +- opening logos +- FMV +- title screen + +まで進むか確認する。 + +最初から高static coverageを要求しない。 +missはInterpreterへ落として正しさを優先する。 + +### Gate C - deterministic coverage + +EU1.1で実行したtraceから、 + +- immutable ARM9 main-image call target +- immutable ARM9 main-image indirect target +- ARM7 main-image target + +のみを抽出し、 + +```text +coverage/eu11-main-entry-points.json +``` + +へ昇格する。 + +US1.0のPCをアドレス変換して生成してはいけない。 + +### Gate D - FMV runtime bank + +EU1.1自身からITCM + main RAM captureを作り、 + +- capture SHA-1 +- live-byte validation +- observed call / indirect roots + +をEU1.1専用configへ固定する。 + +### Gate E - runtime semantic profile + +mphCodexを使い、 + +- morph state +- aim X/Y +- local player +- game mode +- HUD / camera state +- Scan Visor state +- Adventure camera state + +をEU1.1へ対応させる。 + +ここまで完了して初めてPrime Controls / Direct Mouse Aim / adaptive HUD等を +EU1.1に段階的に解禁する。 + +## 8. 完了条件 + +EU1.1を「supported」とする条件は最低でも以下。 + +- exact EU1.1 ROM identity gate +- EU1.1 main ARM9 bank +- EU1.1 ARM7 bank +- interpreter fallbackでterminal dispatch missなし +- title到達 +- Adventure gameplay到達 +- save/load確認 +- pause/reload確認 +- title patchがUS1.0 addressを参照しない +- Prime Controls動作確認 +- Direct Mouse Aim動作確認 +- native/reference checkpoint比較 +- US1.0 regressionなし + +現時点の実装は、このうちROM実体なしで先に安全に整備できる +**profile / extraction / bank isolation / bootstrap build infrastructure** +までを対象とする。 From 829de4d513cf191d5debea41713386c0b179bbaf Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:19:14 +0900 Subject: [PATCH 10/97] Preserve US1.0 AppImage naming --- tools/build-linux.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/build-linux.sh b/tools/build-linux.sh index 6fc7302..9b21fd2 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -199,7 +199,11 @@ echo "[4/4] package AppImage" --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-$MPH_VERSION-linux-v$VERSION-x86_64.AppImage" +if [ "$MPH_VERSION" = "US1_0" ]; then + APP="$OUT/$APP_NAME-linux-v$VERSION-x86_64.AppImage" +else + APP="$OUT/$APP_NAME-$MPH_VERSION-linux-v$VERSION-x86_64.AppImage" +fi rm -f "$APP" ARCH=x86_64 "$APPIMAGETOOL_BIN" --appimage-extract-and-run "$APPDIR" "$APP" >/dev/null chmod +x "$APP" From 099fbbcd85c2925529ac8ef88921f59375abfd4a Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:20:04 +0900 Subject: [PATCH 11/97] Fix Linux profile build script syntax From e0a0a8c2799886ab3d281105fc1d4a3571ba1463 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:44:30 +0900 Subject: [PATCH 12/97] Add revision-specific MPH runtime addresses --- config/mph_rom_profiles.json | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index 442af6a..2f0c680 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -1,5 +1,6 @@ { - "schema": 1, + "schema": 2, + "runtime_address_source": "https://github.com/ag-advania/melonPrimeDS/blob/main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h", "profiles": { "US1_0": { "display_name": "Metroid Prime Hunters (USA rev 0)", @@ -11,7 +12,12 @@ "program_id": "mph_amhe0", "coverage": "coverage/adventure-main-entry-points.json", "game_config": "game.toml", - "fmv_runtime": true + "fmv_runtime": true, + "runtime": { + "morph_state": "0x020DA818", + "aim_x": "0x020DE526", + "aim_y": "0x020DE52E" + } }, "EU1_1": { "display_name": "Metroid Prime Hunters (Europe rev 1)", @@ -23,7 +29,12 @@ "program_id": "mph_amhp1", "coverage": "coverage/eu11-bootstrap-entry-points.json", "game_config": "config/game-eu11.toml", - "fmv_runtime": false + "fmv_runtime": false, + "runtime": { + "morph_state": "0x020DB138", + "aim_x": "0x020DEE46", + "aim_y": "0x020DEE4E" + } } } } From 772721f0379e701ad36f7a2ca8465169907d464b Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:45:19 +0900 Subject: [PATCH 13/97] Patch ndsrecomp runner for MPH multi-ROM runtime profiles --- tools/patch_ndsrecomp_mph_runtime.py | 262 +++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tools/patch_ndsrecomp_mph_runtime.py diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py new file mode 100644 index 0000000..7659f01 --- /dev/null +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Apply the MPH multi-ROM runtime-profile shim to the pinned ndsrecomp runner. + +The upstream runner currently hard-codes Metroid Prime Hunters USA rev-0 RAM +addresses for Prime Controls and direct mouse aim. This project supports more +than one retail revision, so those host-side hooks must be selected by exact +ROM SHA-1 instead of by title/address assumptions. + +The address values are generated from config/mph_rom_profiles.json. The +registry records melonPrimeDS's MelonPrimeGameRomAddrTable.h as the source of +truth for the MPH runtime addresses. + +The source patch is intentionally small, idempotent, and fail-closed. If the +pinned ndsrecomp source changes enough that the expected preimages are absent, +this script stops instead of guessing a patch against unknown code. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +MAIN_RAM_MIN = 0x02000000 +MAIN_RAM_MAX = 0x023FFFFF + + +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 load_runtime_profiles(registry_path: Path) -> list[dict[str, object]]: + registry = json.loads(registry_path.read_text(encoding="utf-8")) + profiles = registry.get("profiles") + if not isinstance(profiles, dict) or not profiles: + raise SystemExit("ROM profile registry has no profiles") + + result: list[dict[str, object]] = [] + for key, profile in profiles.items(): + if not isinstance(profile, dict): + continue + runtime = profile.get("runtime") + if runtime is None: + continue + if not isinstance(runtime, dict): + raise SystemExit(f"{key}.runtime must be an object") + sha1 = profile.get("sha1") + if not isinstance(sha1, str) or not SHA1_RE.fullmatch(sha1): + raise SystemExit(f"{key}.sha1 must be 40 lowercase hex digits") + result.append( + { + "key": key, + "sha1": 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" + ), + } + ) + + if not result: + raise SystemExit("ROM profile registry contains no MPH runtime profiles") + return result + + +def generated_header(profiles: list[dict[str, object]]) -> str: + rows = [] + for profile in profiles: + rows.append( + " {\"%s\", 0x%08Xu, 0x%08Xu, 0x%08Xu}, // %s" + % ( + profile["sha1"], + profile["morph_state"], + profile["aim_x"], + profile["aim_y"], + profile["key"], + ) + ) + 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. +struct NdsMphRuntimeProfile { + const char* sha1; + uint32_t morph_state; + uint32_t aim_x; + uint32_t aim_y; +}; + +inline constexpr std::array kNdsMphRuntimeProfiles{{ +%s +}}; +""" % (len(rows), "\n".join(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 = load_runtime_profiles(registry_path) + generated = runner_src / "mph_runtime_profiles.generated.h" + generated.write_text(generated_header(profiles), 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: exact-ROM runtime profile selection.\n" + "bool nds_title_patches_select_mph_runtime_profile(const char* rom_sha1);\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", + "MPH_MULTIROM_RUNTIME_PROFILE", + ) + + 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: selected only by exact cartridge SHA-1.\n" + "const NdsMphRuntimeProfile* g_mph_runtime_profile = nullptr;\n", + "selected only by exact cartridge SHA-1", + ) + 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(const char* rom_sha1) {\n" + " g_mph_mouse_aim = false;\n" + " g_mph_runtime_profile = nullptr;\n" + " if (!rom_sha1) return false;\n" + " for (const NdsMphRuntimeProfile& profile : kNdsMphRuntimeProfiles) {\n" + " if (std::strcmp(profile.sha1, rom_sha1) == 0) {\n" + " g_mph_runtime_profile = &profile;\n" + " return true;\n" + " }\n" + " }\n" + " return false;\n" + "}\n\n" + "bool nds_title_patches_mph_in_ball() {\n" + " return g_mph_runtime_profile &&\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 = enabled && g_mph_runtime_profile;\n" + "}\n\n" + "bool nds_title_patches_apply_mph_mouse_delta(int32_t dx, int32_t dy) {\n" + " if (!g_mph_mouse_aim || !g_mph_runtime_profile ||\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 is selected by ROM SHA-1.\n", + "morph address is selected by ROM SHA-1", + ) + 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, + " mph_mouse_aim_policy =\n" + " rom_sha1 == \"90164d1ac127ee5f9815ea4ae7de798c7b5fc629\" &&\n" + " frontend_options.relative_mouse_touch;\n", + " // MPH_MULTIROM_RUNTIME_PROFILE: host hooks are enabled only when the\n" + " // exact ROM SHA-1 has a validated revision-specific address profile.\n" + " const bool mph_runtime_profile =\n" + " nds_title_patches_select_mph_runtime_profile(rom_sha1.c_str());\n" + " mph_mouse_aim_policy =\n" + " mph_runtime_profile && frontend_options.relative_mouse_touch;\n", + "host hooks are enabled only when the", + ) + + print( + f"Patched ndsrecomp MPH runtime profiles: " + + ", ".join(str(profile["key"]) for profile 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_runner(args.framework_root.resolve(), args.profiles.resolve()) + + +if __name__ == "__main__": + main() From 140243a76cd81ddea27e9847f757d9db5c331d7f Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:46:27 +0900 Subject: [PATCH 14/97] Make MPH launcher ROM identity profile-driven --- launcher/recomp-ui/CMakeLists.txt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index f4fe4a7..ac1227a 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -9,11 +9,19 @@ 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 + "Exact retail ROM SHA-1 accepted by this profile-specific launcher") +set(MPH_LAUNCHER_REGION "USA" CACHE STRING + "Region label shown by the profile-specific launcher") add_executable(mph-recomp-ui launcher_main.cpp "${NDSRECOMP_ROOT}/recompiler/support/sha1.cpp") target_include_directories(mph-recomp-ui PRIVATE "${NDSRECOMP_ROOT}/recompiler/support") +target_compile_definitions(mph-recomp-ui PRIVATE + MPH_LAUNCHER_ROM_SHA1="${MPH_LAUNCHER_ROM_SHA1}" + MPH_LAUNCHER_REGION="${MPH_LAUNCHER_REGION}") target_link_libraries(mph-recomp-ui PRIVATE SDL2::SDL2) set(RECOMP_UI_ROOT "F:/Projects/recomp-ui" CACHE PATH @@ -32,7 +40,9 @@ target_include_directories(mph-mod-provider-test PRIVATE "${RECOMP_UI_ROOT}/src" "${RECOMP_UI_ROOT}/src/common") target_compile_definitions(mph-mod-provider-test PRIVATE - MPH_RECOMP_UI_NO_MAIN) + MPH_RECOMP_UI_NO_MAIN + MPH_LAUNCHER_ROM_SHA1="${MPH_LAUNCHER_ROM_SHA1}" + MPH_LAUNCHER_REGION="${MPH_LAUNCHER_REGION}") set(RECOMP_UI_SDL3 OFF) set(RECOMP_UI_ENABLE_MODS ON CACHE BOOL "" FORCE) From 1015ef86b72f0013de2899f80fc13f9e2fc36f8a Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:47:04 +0900 Subject: [PATCH 15/97] Generate profile-specific launcher source at configure time --- launcher/recomp-ui/CMakeLists.txt | 35 ++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index ac1227a..8d53608 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -15,13 +15,34 @@ set(MPH_LAUNCHER_ROM_SHA1 set(MPH_LAUNCHER_REGION "USA" CACHE STRING "Region label shown by the profile-specific launcher") -add_executable(mph-recomp-ui launcher_main.cpp +# Keep launcher_main.cpp as the readable US1.0 baseline, but compile a +# profile-specific generated TU. This avoids duplicating the 40+ KiB launcher +# just to change cartridge identity metadata for another retail revision. +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/launcher_main.cpp" MPH_LAUNCHER_SOURCE) +set(_mph_us10_sha1 "90164d1ac127ee5f9815ea4ae7de798c7b5fc629") +string(FIND "${MPH_LAUNCHER_SOURCE}" "${_mph_us10_sha1}" _mph_sha_pos) +if(_mph_sha_pos EQUAL -1) + message(FATAL_ERROR "launcher_main.cpp no longer contains the US1.0 SHA-1 baseline") +endif() +string(REPLACE "${_mph_us10_sha1}" "${MPH_LAUNCHER_ROM_SHA1}" + MPH_LAUNCHER_SOURCE "${MPH_LAUNCHER_SOURCE}") +set(_mph_region_line "game.region = \"USA\";") +string(FIND "${MPH_LAUNCHER_SOURCE}" "${_mph_region_line}" _mph_region_pos) +if(_mph_region_pos EQUAL -1) + message(FATAL_ERROR "launcher_main.cpp no longer contains the USA region baseline") +endif() +string(REPLACE "${_mph_region_line}" + "game.region = \"${MPH_LAUNCHER_REGION}\";" + MPH_LAUNCHER_SOURCE "${MPH_LAUNCHER_SOURCE}") +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") -target_compile_definitions(mph-recomp-ui PRIVATE - MPH_LAUNCHER_ROM_SHA1="${MPH_LAUNCHER_ROM_SHA1}" - MPH_LAUNCHER_REGION="${MPH_LAUNCHER_REGION}") + "${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 @@ -40,9 +61,7 @@ target_include_directories(mph-mod-provider-test PRIVATE "${RECOMP_UI_ROOT}/src" "${RECOMP_UI_ROOT}/src/common") target_compile_definitions(mph-mod-provider-test PRIVATE - MPH_RECOMP_UI_NO_MAIN - MPH_LAUNCHER_ROM_SHA1="${MPH_LAUNCHER_ROM_SHA1}" - MPH_LAUNCHER_REGION="${MPH_LAUNCHER_REGION}") + MPH_RECOMP_UI_NO_MAIN) set(RECOMP_UI_SDL3 OFF) set(RECOMP_UI_ENABLE_MODS ON CACHE BOOL "" FORCE) From 7eb27d35e837ab7ba7aece7f918b65ad911fcde6 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:47:29 +0900 Subject: [PATCH 16/97] Package profile-specific MPH releases --- tools/make_release.ps1 | 49 ++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/tools/make_release.ps1 b/tools/make_release.ps1 index 84de180..3eb0ea8 100644 --- a/tools/make_release.ps1 +++ b/tools/make_release.ps1 @@ -1,22 +1,23 @@ <# 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 +US1_0 keeps the historical release name and requires the validated FMV runtime +bank. Other revision profiles may opt out of that bank until a revision- +specific runtime 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', + [switch]$AllowNoFmvRuntime ) $ErrorActionPreference = 'Stop' @@ -26,18 +27,25 @@ $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) { + # A US1.0 static-only runner is functional but drops the opening movies to + # roughly half speed. Keep the established release gate for that profile. + $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.' + } } $projectText = Get-Content (Join-Path $root 'CMakeLists.txt') -Raw @@ -47,7 +55,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 +86,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') ` From be879f46a2b2ba20e4be0fda595de71d458d6bd3 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:47:55 +0900 Subject: [PATCH 17/97] Build and package complete EU1.1 profile on Windows --- tools/build-windows.ps1 | 78 ++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/tools/build-windows.ps1 b/tools/build-windows.ps1 index 731d788..0de94a2 100644 --- a/tools/build-windows.ps1 +++ b/tools/build-windows.ps1 @@ -1,24 +1,26 @@ <# Build Metroid Prime Hunters Recomp for one configured retail revision. -US1_0 keeps the existing release path, including the recomp-ui launcher and -portable ZIP. Other profiles currently build the title banks and runner only; -they are bring-up builds until their title-specific runtime hooks are validated. +US1_0 keeps the existing release paths. EU1_1 uses isolated generated banks, +a revision-specific game config, a profile-specific launcher identity, and the +shared exact-ROM runtime-address shim for Prime Controls/direct mouse aim. Usage: powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` - tools\build-windows.ps1 -Version 0.3.0 -MphVersion EU1_1 + 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', [ValidateSet('US1_0', 'EU1_1')] [string]$MphVersion = 'US1_0', + [string]$RomPath = '', [string]$CMake = 'C:\msys64\mingw64\bin\cmake.exe', [string]$Generator = 'Ninja', [int]$Jobs = 12, [string]$GameBuildDir = '', [string]$RunnerBuildDir = '', - [string]$LauncherBuildDir = 'launcher\recomp-ui\build-release', + [string]$LauncherBuildDir = '', [string]$RuntimeBinDir = 'C:\msys64\mingw64\bin', [string]$RecompUiRoot = 'F:\Projects\recomp-ui' ) @@ -38,6 +40,17 @@ if ($null -eq $profileProperty) { } $profile = $profileProperty.Value $romSha1 = [string]$profile.sha1 +$region = [string]$profile.region +$gameConfig = [IO.Path]::GetFullPath( + (Join-Path $root ([string]$profile.game_config))) + +if ([string]::IsNullOrWhiteSpace($RomPath)) { + $RomPath = Join-Path $root 'Metroid Prime Hunters.nds' +} +$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') { @@ -53,6 +66,13 @@ if ([string]::IsNullOrWhiteSpace($RunnerBuildDir)) { $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)) @@ -65,6 +85,11 @@ if ($MphVersion -eq 'US1_0') { (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))" @@ -72,12 +97,17 @@ try { & $cmakePath -G $Generator -S $root -B $gameBuild ` -DCMAKE_BUILD_TYPE=Release ` -DNDSRECOMP_ROOT="$frameworkRoot" ` - -DMPH_VERSION="$MphVersion" + -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 ` @@ -88,32 +118,32 @@ try { & $cmakePath --build $runnerBuild -j $Jobs if ($LASTEXITCODE -ne 0) { throw 'Runner build failed.' } - if ($MphVersion -ne 'US1_0') { - $gameConfig = [IO.Path]::GetFullPath( - (Join-Path $root ([string]$profile.game_config))) - Write-Host '' - Write-Host "Bring-up runner built: $runnerBuild\nds_runner.exe" - Write-Host "Use the revision-specific config: $gameConfig" - Write-Host 'Launcher/release packaging intentionally skipped for this profile.' - Write-Host 'Prime Controls and direct mouse aim remain USA-only until revision addresses are validated.' - return - } - & $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" 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 + ) + if (-not [bool]$profile.fmv_runtime) { + $releaseArgs += '-AllowNoFmvRuntime' + } + & powershell.exe @releaseArgs if ($LASTEXITCODE -ne 0) { throw 'Release packaging failed.' } } finally { Pop-Location From 5793dea6848b5648a99b1c7193c40485a4e77898 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:48:20 +0900 Subject: [PATCH 18/97] Enable complete EU1.1 runtime profile on Linux --- tools/build-linux.sh | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/tools/build-linux.sh b/tools/build-linux.sh index 9b21fd2..b46d0eb 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Build Metroid Prime Hunters Recomp for a configured retail revision. # -# US1_0 keeps the existing AppImage behavior. EU1_1 uses its own generated -# bank/config paths and deliberately omits the USA-only FMV-bank assertion. +# US1_0 keeps the existing AppImage naming. EU1_1 uses isolated generated +# banks, its own game config, and the exact-ROM runtime-address shim used by +# Prime Controls/direct mouse aim. set -euo pipefail APP_NAME="MetroidPrimeHuntersRecomp" @@ -10,6 +11,7 @@ TITLE_TARGET="metroidprimehuntersrecomp" RUNNER_NAME="nds_runner" VERSION="0.1.0" MPH_VERSION="US1_0" +ROM_PATH="" JOBS="$(nproc 2>/dev/null || echo 4)" DO_PACKAGE=1 @@ -22,11 +24,12 @@ while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2;; --mph-version) MPH_VERSION="$2"; shift 2;; + --rom) ROM_PATH="$2"; shift 2;; --jobs) JOBS="$2"; shift 2;; --out) OUT="$2"; shift 2;; --no-package) DO_PACKAGE=0; shift;; -h|--help) - sed -n '2,16p' "$0" + sed -n '2,18p' "$0" exit 0 ;; *) echo "unknown arg: $1" >&2; exit 2;; @@ -55,6 +58,10 @@ ROM_SHA1="$(profile_value sha1)" GAME_CONFIG_REL="$(profile_value game_config)" FMV_RUNTIME="$(profile_value fmv_runtime)" GAME_CONFIG="$REPO/$GAME_CONFIG_REL" +if [ -z "$ROM_PATH" ]; then + ROM_PATH="$REPO/Metroid Prime Hunters.nds" +fi +ROM_PATH="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$ROM_PATH")" if [ "$MPH_VERSION" = "US1_0" ]; then GAME_BUILD="$REPO/build-linux-release" @@ -71,8 +78,8 @@ test -f "$FRAMEWORK_ROOT/recompiler/CMakeLists.txt" || { echo "ERROR: sibling ndsrecomp checkout is missing." >&2 exit 1 } -test -f "$REPO/Metroid Prime Hunters.nds" || { - echo "ERROR: verified Metroid Prime Hunters ROM is missing from the repo root." >&2 +test -f "$ROM_PATH" || { + echo "ERROR: Metroid Prime Hunters ROM is missing: $ROM_PATH" >&2 exit 1 } test -f "$GAME_CONFIG" || { @@ -80,15 +87,21 @@ test -f "$GAME_CONFIG" || { exit 1 } -echo "[1/4] configure title banks ($MPH_VERSION)" +echo "[1/5] configure title banks ($MPH_VERSION)" cmake -S "$REPO" -B "$GAME_BUILD" -G "Unix Makefiles" \ -DCMAKE_BUILD_TYPE=Release \ -DNDSRECOMP_ROOT="$FRAMEWORK_ROOT" \ - -DMPH_VERSION="$MPH_VERSION" -echo "[2/4] build title banks" + -DMPH_VERSION="$MPH_VERSION" \ + -DMPH_ROM="$ROM_PATH" +echo "[2/5] build title banks" cmake --build "$GAME_BUILD" --target "$TITLE_TARGET" -j"$JOBS" -echo "[3/4] configure runner" +echo "[3/5] install exact-ROM MPH runtime profile into pinned ndsrecomp runner" +python3 "$REPO/tools/patch_ndsrecomp_mph_runtime.py" \ + --framework-root "$FRAMEWORK_ROOT" \ + --profiles "$PROFILE_FILE" + +echo "[4/5] configure runner" cmake -S "$FRAMEWORK_ROOT/runner" -B "$RUNNER_BUILD" -G "Unix Makefiles" \ -DCMAKE_BUILD_TYPE=Release \ -DNDS_BOOTSTRAP_FIRMWARE=ON \ @@ -194,7 +207,7 @@ exec "$HERE/usr/bin/nds_runner" "$@" EOF chmod +x "$APPDIR/AppRun" "$APPDIR/usr/bin/$RUNNER_NAME" -echo "[4/4] package AppImage" +echo "[5/5] 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 From c0ff50831feea2bbd8f368abe3105eec5fb07e05 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:51:28 +0900 Subject: [PATCH 19/97] Add static multi-ROM profile validator --- tools/check_mph_multirom_profiles.py | 225 +++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 tools/check_mph_multirom_profiles.py diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py new file mode 100644 index 0000000..3cea998 --- /dev/null +++ b/tools/check_mph_multirom_profiles.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Static consistency checks for Metroid Prime Hunters ROM profiles. + +This intentionally does not need a copyrighted ROM. It verifies that each +profile's identity, coverage seed, game config, and host-side runtime addresses +agree. When --melonprime-table is provided, Aim/Morph addresses are also +cross-checked against melonPrimeDS's MelonPrimeGameRomAddrTable.h, which is the +source of truth for those fields. +""" + +from __future__ import annotations + +import argparse +import json +import re +import tomllib +from pathlib import Path + + +SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +HEX_RE = re.compile(r"0x[0-9A-Fa-f]+u?") +REQUIRED_RUNTIME_FIELDS = { + "morph_state": "baseIsAltForm", + "aim_x": "baseAimX", + "aim_y": "baseAimY", +} + + +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 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_registry(repo: Path, table: Path | None) -> None: + registry_path = repo / "config" / "mph_rom_profiles.json" + registry = load_json(registry_path) + if not isinstance(registry, dict): + die("ROM profile registry must be a JSON object") + if registry.get("schema") != 2: + die("ROM profile registry schema must be 2") + + expected_source = ( + "https://github.com/ag-advania/melonPrimeDS/blob/main/" + "src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h" + ) + if registry.get("runtime_address_source") != expected_source: + die("runtime_address_source is not the approved melonPrimeDS address table") + + profiles = registry.get("profiles") + if not isinstance(profiles, dict) or not profiles: + die("ROM profile registry has no profiles") + + melon = parse_melonprime_table(table) if table else None + seen_sha1: set[str] = set() + seen_identity: set[tuple[str, int]] = set() + + for key, profile in profiles.items(): + if not isinstance(profile, dict): + die(f"profile {key} must be an object") + 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) + if not isinstance(game_code, str) or len(game_code) != 4: + die(f"{key}.game_code must be four characters") + if not isinstance(revision, int) or not 0 <= revision <= 255: + die(f"{key}.revision must be an unsigned byte") + identity = (game_code, revision) + if identity in seen_identity: + die(f"duplicate cartridge identity: {game_code} rev {revision}") + seen_identity.add(identity) + + 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 = melon[key][melon_field] + if actual != expected: + die( + f"{key}.{profile_field}=0x{actual:08X}, but " + f"melonPrimeDS {melon_field}=0x{expected:08X}" + ) + + 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}" + ) + + # Explicit regression guard for the first non-US revision. These are the + # values currently published by melonPrimeDS's source-of-truth table. + eu = profiles.get("EU1_1") + if not isinstance(eu, dict): + die("EU1_1 profile is required") + 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") + eu_runtime = eu.get("runtime", {}) + expected_eu_runtime = { + "morph_state": "0x020DB138", + "aim_x": "0x020DEE46", + "aim_y": "0x020DEE4E", + } + if eu_runtime != expected_eu_runtime: + die(f"EU1_1 runtime profile changed unexpectedly: {eu_runtime!r}") + if eu.get("fmv_runtime") is not False: + die("EU1_1 must not reuse the US1.0 FMV runtime capture") + + print(f"OK: validated {len(profiles)} MPH ROM profiles") + if melon is not None: + print(f"OK: Aim/Morph addresses 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() From b9e153d2c12f0d3b42a313b55d4d2866c010ef65 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:51:47 +0900 Subject: [PATCH 20/97] Add multi-ROM static verification workflow --- .github/workflows/mph-multirom-static.yml | 104 ++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 .github/workflows/mph-multirom-static.yml diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml new file mode 100644 index 0000000..8775ccf --- /dev/null +++ b/.github/workflows/mph-multirom-static.yml @@ -0,0 +1,104 @@ +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 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check Python syntax + run: | + python -m py_compile \ + tools/prepare_mph.py \ + tools/check_mph_multirom_profiles.py \ + tools/patch_ndsrecomp_mph_runtime.py + + - name: Check shell syntax + run: bash -n tools/build-linux.sh + + - name: Check PowerShell syntax + shell: pwsh + run: | + $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 "$path has PowerShell parser errors:`n$($errors | Out-String)" + } + } + if ($failed) { exit 1 } + + - name: Cross-check runtime addresses against melonPrimeDS + run: | + curl -fsSL --retry 3 \ + https://raw.githubusercontent.com/ag-advania/melonPrimeDS/main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h \ + -o /tmp/MelonPrimeGameRomAddrTable.h + python tools/check_mph_multirom_profiles.py \ + --melonprime-table /tmp/MelonPrimeGameRomAddrTable.h + + - name: Fetch exact pinned ndsrecomp revision + run: | + pin="$(tr -d '\r\n' < ndsrecomp.pin)" + test "${#pin}" -eq 40 + 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: Verify ndsrecomp runtime patch is idempotent + run: | + 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/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/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 '0x020DB138u, 0x020DEE46u, 0x020DEE4Eu' \ + /tmp/ndsrecomp/runner/src/mph_runtime_profiles.generated.h + grep -q 'bdcd1dea293e24c98d4c481430e90d21198985a5' \ + /tmp/ndsrecomp/runner/src/mph_runtime_profiles.generated.h + grep -q 'nds_title_patches_select_mph_runtime_profile' \ + /tmp/ndsrecomp/runner/src/main.cpp + grep -q 'nds_title_patches_mph_in_ball' \ + /tmp/ndsrecomp/runner/src/frontend.cpp + ! grep -q 'kMphUs10MorphState' /tmp/ndsrecomp/runner/src/frontend.cpp + ! grep -q 'kMphUs10AimX' /tmp/ndsrecomp/runner/src/title_patches.cpp + ! grep -q 'kMphUs10AimY' /tmp/ndsrecomp/runner/src/title_patches.cpp + + - name: Diff sanity + run: git diff --check From 4015956d13eab90c6192cb7cf35617114c997b7b Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:52:57 +0900 Subject: [PATCH 21/97] Compile both ROM checker profiles in static CI --- .github/workflows/mph-multirom-static.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index 8775ccf..f28600f 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -100,5 +100,22 @@ jobs: ! grep -q 'kMphUs10AimX' /tmp/ndsrecomp/runner/src/title_patches.cpp ! grep -q 'kMphUs10AimY' /tmp/ndsrecomp/runner/src/title_patches.cpp + - name: Compile US1.0 and EU1.1 ROM checkers + run: | + 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 run: git diff --check From e7c2fe48ff95b13d767965a246cf2888011cc30d Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:53:43 +0900 Subject: [PATCH 22/97] Update EU1.1 bring-up documentation to current implementation --- docs/EU1_1_BRINGUP.md | 413 ++++++++++++++++++++++++++++-------------- 1 file changed, 272 insertions(+), 141 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index 817c7c1..643eece 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -1,22 +1,28 @@ # Metroid Prime Hunters Recomp - EU1.1 Bring-up 作成日: 2026-08-17 +更新日: 2026-08-17 ## 1. 目的 -`MetroidPrimeHuntersRecomp` を USA revision 0 (`AMHE`, revision 0) 固定から段階的に +`MetroidPrimeHuntersRecomp` を USA revision 0 (`AMHE`, revision 0) 固定から multi-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revision 1) を安全にbring-upする。 -この段階は「EU1.1を正式対応済みにする」ものではない。 -まず以下を成立させる。 +現在はROM実体なしで実装・検証できる範囲をさらに進め、次まで完了している。 1. ROM identityを版別profileとして管理する。 -2. EU1.1 ROMからARM9 / ARM7 / ARM9 overlayを直接抽出できる。 -3. EU1.1専用bankをUS1.0生成物から分離して生成できる。 +2. EU1.1 ROMからARM9 / ARM7 / ARM9 overlayを直接抽出するprofile-driven prepare経路を持つ。 +3. EU1.1専用bankをUS1.0生成物から分離する。 4. US1.0のcoverage seedやFMV runtime captureをEU1.1へ流用しない。 -5. exact ROM SHA-1でrunner側のbank登録をgateする。 -6. EU1.1固有の未解析箇所はInterpreter fallbackへ安全に落とす。 +5. exact ROM SHA-1でrunner側のbankとhost-side runtime address profileをgateする。 +6. Prime ControlsのMorph判定とDirect Mouse AimをEU1.1固有RAMアドレスへ対応させる。 +7. EU1.1専用ROM identityを持つlauncherを同一launcher sourceから生成する。 +8. Windows/Linux build入口をprofile-awareにする。 +9. ROM不要のstatic CIでprofile整合性とpinned ndsrecomp patchを検証する。 + +未完了なのは、EU1.1実ROMと実行環境を必要とするruntime validation、 +EU1.1固有coverageの拡張、EU1.1固有FMV runtime captureである。 ## 2. EU1.1 identity @@ -28,18 +34,16 @@ multi-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revi | ROM size | `0x04000000` / 64 MiB | | SHA-1 | `bdcd1dea293e24c98d4c481430e90d21198985a5` | | Program ID prefix | `mph_amhp1` | +| Game config | `config/game-eu11.toml` | +| FMV runtime bank | disabled until EU1.1 capture exists | identityは `config/mph_rom_profiles.json` に集約する。 -## 3. 今回追加するbootstrap構造 - -### 3.1 Profile registry +## 3. ROM profile registry -`config/mph_rom_profiles.json` +`config/mph_rom_profiles.json` は現在schema 2で、US1.0とEU1.1を同じ構造で管理する。 -US1.0とEU1.1を同一schemaで管理する。 - -Profileには最低限、 +Profileには次を持たせる。 - `game_code` - `revision` @@ -49,10 +53,92 @@ Profileには最低限、 - `coverage` - `game_config` - `fmv_runtime` +- `runtime.morph_state` +- `runtime.aim_x` +- `runtime.aim_y` + +host-side runtime addressのsource of truthは以下に固定する。 + +```text +https://github.com/ag-advania/melonPrimeDS/blob/main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h +``` + +AimアドレスはmphCodexから推測せず、このmelonPrimeDS address tableを正として扱う。 + +## 4. melonPrimeDSから確定したEU1.1 runtime addresses + +`MelonPrimeGameRomAddrTable.h` の `RomGroup` は次の順序である。 + +```text +JP1_0, JP1_1, US1_0, US1_1, EU1_0, EU1_1, KR1_0 +``` + +Recomp runnerで現在必要なフィールドは以下である。 + +| Semantic | melonPrimeDS field | US1.0 | EU1.1 | +|---|---|---:|---:| +| Morph / Alt Form state | `baseIsAltForm` | `0x020DA818` | `0x020DB138` | +| Direct Aim X | `baseAimX` | `0x020DE526` | `0x020DEE46` | +| Direct Aim Y | `baseAimY` | `0x020DE52E` | `0x020DEE4E` | + +これらは `config/mph_rom_profiles.json` の `runtime` に登録済みである。 + +## 5. pinned ndsrecompのUS1.0固定を除去する方法 + +pinned framework revision: + +```text +46b12e6c18dea47f87d2c1f98c3054149dcbca5d +``` + +このrevisionのrunnerには元々、 + +```text +frontend.cpp: + kMphUs10MorphState = 0x020DA818 + +title_patches.cpp: + kMphUs10AimX = 0x020DE526 + kMphUs10AimY = 0x020DE52E + +main.cpp: + Prime Controls policy = exact US1.0 SHA-1 only +``` + +というUS1.0固定が存在する。 + +プロジェクト側で `tools/patch_ndsrecomp_mph_runtime.py` を追加し、build前に +pinned `ndsrecomp` checkoutへ小さなprofile-selection shimを適用する。 + +パッチ後は概念的に次の経路になる。 + +```text +ROM SHA-1 + -> NdsMphRuntimeProfile選択 + -> morph_state + -> aim_x + -> aim_y + -> exact profileがある場合のみPrime Controls / Direct Mouse Aimを許可 + -> 未登録SHA-1ではhost hookを無効化 +``` + +生成されるframework側header: + +```text +runner/src/mph_runtime_profiles.generated.h +``` + +このheaderは直接編集せず、`config/mph_rom_profiles.json` から生成する。 + +パッチャーは以下の性質を持つ。 -を持たせる。 +- exact pinned source preimageを要求する。 +- upstream sourceが想定外に変わった場合はguessせず失敗する。 +- 同じcheckoutへ複数回適用しても結果が変わらない。 +- 未知ROMはfail-closedになる。 +- runtime addressはDS main RAM範囲内か検証する。 -### 3.2 EU1.1 coverage seed +## 6. EU1.1 coverage bootstrap `coverage/eu11-bootstrap-entry-points.json` @@ -60,14 +146,16 @@ Profileには最低限、 これは意図的である。 -`prepare_mph.py` はROM headerのARM9/ARM7 entry PCを必ずseedするため、 -EU1.1はまずそのrootからstatic discoveryを行い、未コンパイル領域は -runtime Interpreterへfallbackする。 +`prepare_mph.py` はROM headerのARM9/ARM7 entry PCを必ずseedするため、EU1.1は +まずそのrootからstatic discoveryを行い、未コンパイル領域はruntime Interpreterへ +fallbackする。 -US1.0の `coverage/adventure-main-entry-points.json` に入っているアドレスを -EU1.1へそのままコピーしてはいけない。 +US1.0の `coverage/adventure-main-entry-points.json` に入っているabsolute PCを +EU1.1へコピーしてはいけない。 -### 3.3 Generated tree separation +EU1.1自身を実行したtraceからのみEU1.1 coverageを拡張する。 + +## 7. Generated tree separation US1.0は後方互換性のため従来通り: @@ -91,202 +179,232 @@ generated/ これにより異なるROM由来のbankやbinaryが同じパスへ混ざらない。 -## 4. mphCodexで確認済みのEU1.1差分例 +## 8. Launcher identity separation -AllVersions調査ではEU1.1に次の対応が確認されている。 +launcherの大きなsourceをROM revisionごとに複製しない。 -| Semantic | US1.0 | EU1.1 | -|---|---:|---:| -| Current Camera Sequence | `0x020D9CB0` | `0x020DA5D0` | -| Game Mode | `0x020E78FC` | `0x020E845C` | -| Upper HUD function | `0x0202F600` | `0x0202F5E0` | -| Crosshair callsite | `0x0202F934` | `0x0202F904` | -| Crosshair renderer | `0x020393D4` | `0x02039338` | -| Local Player Pointer | `0x020BCA70` | `0x020BD370` | -| HUD suppression storage | `0x020DE748` | `0x020DF068` | +`launcher/recomp-ui/CMakeLists.txt` はconfigure時にbaseline `launcher_main.cpp` を読み、 +選択profileの -重要なのは、差分が単一の固定deltaではないことである。 +- ROM SHA-1 +- Region -したがって今後のtitle patch / enhancementは、 +だけを反映したgenerated translation unitをbuild directoryへ作る。 ```text -US1.0 address + region offset +launcher_main.cpp + -> configure-time identity transform + -> launcher_main_profile.cpp + -> mph-recomp-ui ``` -ではなく、 +US1.0は従来SHA-1を保持し、EU1.1 buildでは ```text -ROM identity -> semantic runtime profile -> exact address +bdcd1dea293e24c98d4c481430e90d21198985a5 +Europe ``` -で管理する。 +を持つlauncherになる。 -## 5. 現時点でEU1.1へ有効化しない機能 +これによりEU1.1 ROMをUS1.0 launcherへ誤認させず、同一UI実装を共有できる。 -### 5.1 Prime Controls / Morph state +## 9. FMV runtime bank -pinned `ndsrecomp` runnerのPrime ControlsにはUS1.0専用の +US1.0の ```text -0x020DA818 +config/mph_arm9_fmv_runtime.toml +generated/capture/mph_arm9_fmv_runtime.bin ``` -が直接使われている。 +はUS1.0 runtime bytesとobserved PCsに対するbankである。 -EU1.1側の同semanticアドレスを確認するまでは無効のままにする。 - -### 5.2 Direct Mouse Aim - -`runner/src/title_patches.cpp` のDirect Mouse AimにはUS1.0専用の +EU1.1 profileは: ```text -Aim X: 0x020DE526 -Aim Y: 0x020DE52E +fmv_runtime = false ``` -が使われている。 +とし、このbankを絶対に登録しない。 -これもEU1.1 mapping完了前には有効化しない。 +EU1.1でopening FMV等がInterpreter fallbackでは遅い場合でも、US1.0 captureを +流用してはいけない。EU1.1自身からITCM + main RAM captureを取得し、live-byte +validation付きのEU1.1専用runtime bankを作る。 -### 5.3 USA FMV runtime bank +## 10. Build -US1.0の +### 10.1 Windows -```text -config/mph_arm9_fmv_runtime.toml -generated/capture/mph_arm9_fmv_runtime.bin +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + tools\build-windows.ps1 ` + -Version 0.3.0 ` + -MphVersion EU1_1 ` + -RomPath 'D:\ROMs\Metroid Prime Hunters (Europe) (Rev 1).nds' ``` -はUS1.0のruntime bytesとobserved PCsに対するbankである。 - -EU1.1では `fmv_runtime=false` とし、絶対に登録しない。 +現在のWindows buildはEU1.1についても以下まで一貫して行う。 -EU1.1のFMV高速化はEU1.1実行から独立captureを作成した後に行う。 +1. EU1.1 ROM identity verify +2. EU1.1 ARM9/ARM7 extraction +3. EU1.1 static bank generation +4. pinned ndsrecomp runtime-profile patch +5. exact EU1.1 SHA-1 runner build +6. EU1.1 identity launcher build +7. `config/game-eu11.toml` をrelease内 `game.toml` としてpackage -### 5.4 Launcherのsupported-ROM list +EU1.1にはFMV runtime captureがまだないため、そのbankの存在はrelease gateにしない。 -recomp-ui launcherのsupported SHA-1配列にはまだEU1.1を追加しない。 +### 10.2 Linux -理由は、launcherがPrime Controls等を通常のplayer-facing featureとして有効化する -経路を持つためである。 +runnerまで: -EU1.1の基本bootとruntime profileが検証されるまで「正式対応」と表示しない。 +```bash +tools/build-linux.sh \ + --mph-version EU1_1 \ + --rom '/path/to/Metroid Prime Hunters (Europe) (Rev 1).nds' \ + --no-package +``` -## 6. Build +AppImageまで: -### Windows bring-up +```bash +tools/build-linux.sh \ + --mph-version EU1_1 \ + --rom '/path/to/Metroid Prime Hunters (Europe) (Rev 1).nds' +``` -EU1.1 ROMをrepo rootの +EU1.1 package名には `EU1_1` suffixを付け、US1.0のhistorical filenameとは分離する。 -```text -Metroid Prime Hunters.nds -``` +## 11. ROM不要static CI -として配置した上で: +`.github/workflows/mph-multirom-static.yml` -```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` - tools\build-windows.ps1 ` - -MphVersion EU1_1 -``` +PRごとに以下を検証する。 -EU1.1ではtitle bankとrunnerまでをbuildし、launcher/release packagingは -意図的にskipする。 +1. `prepare_mph.py` / profile checker / framework patcherのPython syntax +2. Linux build scriptのshell syntax +3. Windows build/release scriptのPowerShell syntax +4. melonPrimeDS `main` の `MelonPrimeGameRomAddrTable.h` を取得 +5. `baseIsAltForm` / `baseAimX` / `baseAimY` をprofileと自動照合 +6. exact `ndsrecomp.pin` revisionを取得 +7. runtime-profile patchを適用 +8. 同じpatchを2回適用し、対象ファイルhashが完全一致することを確認 +9. US1.0固定Aim/Morph symbolが除去されていることを確認 +10. US1.0/EU1.1それぞれの`mph_romcheck`をcompile +11. EU1.1 checkerにEU1.1 SHA-1/profile keyが埋め込まれていることを確認 +12. `git diff --check` -revision-specific runtime config: +このCIはROMを一切取得・保存しない。 -```text -config/game-eu11.toml -``` +## 12. mphCodexの役割 -### Linux bring-up +Aim X/YについてはmelonPrimeDS address tableをsource of truthとする。 -```bash -tools/build-linux.sh --mph-version EU1_1 --no-package -``` +mphCodexは引き続き、今後Recomp固有のhost enhancementがsemantic stateを読む必要が +出た場合のcross-version調査に利用できる。 -AppImage生成まで行う場合: +例: -```bash -tools/build-linux.sh --mph-version EU1_1 -``` +| Semantic | US1.0 | EU1.1 | +|---|---:|---:| +| Current Camera Sequence | `0x020D9CB0` | `0x020DA5D0` | +| Game Mode | `0x020E78FC` | `0x020E845C` | +| Upper HUD function | `0x0202F600` | `0x0202F5E0` | +| Crosshair callsite | `0x0202F934` | `0x0202F904` | +| Crosshair renderer | `0x020393D4` | `0x02039338` | +| Local Player Pointer | `0x020BCA70` | `0x020BD370` | +| HUD suppression storage | `0x020DE748` | `0x020DF068` | -package内の `game.toml` は `config/game-eu11.toml` から生成する。 +この表からも、US1.0 -> EU1.1を単一deltaで変換できないことが分かる。 -## 7. ROMが手元にある段階で行う次工程 +## 13. 実ROMで残るvalidation gates -### Gate A - extraction / static bank +### Gate A - extraction / bank generation -1. `prepare_mph.py` がEU1.1 identityをacceptする。 -2. ARM9を正しくdecompressする。 -3. ARM7を抽出する。 -4. overlay tableを列挙する。 -5. `generated/EU1_1/recomp/` にEU1.1専用bankを生成する。 -6. US1.0 artifactを一切参照していないことを確認する。 +EU1.1実ROMで次を確認する。 -### Gate B - interpreter bootstrap +- `prepare_mph.py` がEU1.1 identityをaccept +- ARM9 decompress成功 +- ARM7 extraction成功 +- ARM9 overlay table列挙成功 +- `generated/EU1_1/recomp/` にEU1.1専用bank生成 +- US1.0 artifactが混入していない -EU1.1専用bankをexact SHA-1 gateでrunnerへ登録し、 +### Gate B - boot / interpreter bootstrap - firmware boot - cartridge handoff - opening logos -- FMV +- opening FMV - title screen +- attract loop -まで進むか確認する。 +static missはInterpreterへfallbackさせ、最初はcorrectnessを優先する。 -最初から高static coverageを要求しない。 -missはInterpreterへ落として正しさを優先する。 +### Gate C - gameplay -### Gate C - deterministic coverage +最低限: -EU1.1で実行したtraceから、 +- Adventure file作成/読込 +- Celestial Archives landing +- first-person gameplay +- movement +- aim +- shoot +- Morph Ball +- Scan Visor +- pause +- save/reload +- multiplayer menu -- immutable ARM9 main-image call target -- immutable ARM9 main-image indirect target -- ARM7 main-image target +### Gate D - Prime Controls -のみを抽出し、 +EU1.1 selected profileで以下を実機能確認する。 ```text -coverage/eu11-main-entry-points.json +Morph state = 0x020DB138 +Aim X = 0x020DEE46 +Aim Y = 0x020DEE4E ``` -へ昇格する。 +確認項目: -US1.0のPCをアドレス変換して生成してはいけない。 +- normal formでcenter touch保持 +- Morph Ball時にcenter touchを解除 +- mouse X/Y deltaがEU1.1 fieldsへ書かれる +- US1.0 fieldsへ書かれない +- menu/touch操作へ戻れる +- keyboard/gamepad Prime Controls -### Gate D - FMV runtime bank +### Gate E - EU1.1 deterministic coverage -EU1.1自身からITCM + main RAM captureを作り、 +EU1.1 execution traceから -- capture SHA-1 -- live-byte validation -- observed call / indirect roots +- immutable ARM9 main-image call target +- immutable ARM9 main-image indirect target +- ARM7 main-image target -をEU1.1専用configへ固定する。 +のみを抽出し、EU1.1専用coverageへ昇格する。 -### Gate E - runtime semantic profile +US1.0 absolute PCのaddress translationは行わない。 -mphCodexを使い、 +### Gate F - EU1.1 FMV runtime optimization -- morph state -- aim X/Y -- local player -- game mode -- HUD / camera state -- Scan Visor state -- Adventure camera state +必要な場合だけEU1.1自身からcaptureを作る。 -をEU1.1へ対応させる。 +- capture SHA-1 +- live-byte validation +- observed call targets +- observed indirect targets +- performance comparison -ここまで完了して初めてPrime Controls / Direct Mouse Aim / adaptive HUD等を -EU1.1に段階的に解禁する。 +を固定してからEU1.1 profileの `fmv_runtime` をtrueへ変更する。 -## 8. 完了条件 +## 14. Supported判定 -EU1.1を「supported」とする条件は最低でも以下。 +EU1.1をruntime検証済みsupportedと宣言する条件: - exact EU1.1 ROM identity gate - EU1.1 main ARM9 bank @@ -296,12 +414,25 @@ EU1.1を「supported」とする条件は最低でも以下。 - Adventure gameplay到達 - save/load確認 - pause/reload確認 -- title patchがUS1.0 addressを参照しない +- host-side title patchがUS1.0 addressをEU1.1へ使用しない - Prime Controls動作確認 - Direct Mouse Aim動作確認 - native/reference checkpoint比較 - US1.0 regressionなし -現時点の実装は、このうちROM実体なしで先に安全に整備できる -**profile / extraction / bank isolation / bootstrap build infrastructure** -までを対象とする。 +## 15. 現在の判定 + +### Code / infrastructure + +**READY FOR EU1.1 ROM VALIDATION** + +ROMなしで可能なidentity/profile、extraction routing、bank isolation、runtime address +selection、launcher identity、Windows/Linux packaging、static CIまで実装済み。 + +### Runtime correctness + +**NOT YET CLAIMED** + +EU1.1実ROMによるboot/gameplay/reference validationは別途必要である。 +このvalidationを通すまでは、コードがEU1.1を受理できることと、ゲーム動作が完全に +検証済みであることを混同しない。 From 8a1c6dc242789c8d0e0bd14afa9ebf252d8f757d Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 10:55:26 +0900 Subject: [PATCH 23/97] Compile patched runner in multi-ROM CI --- .github/workflows/mph-multirom-static.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index f28600f..5311594 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -100,6 +100,16 @@ jobs: ! grep -q 'kMphUs10AimX' /tmp/ndsrecomp/runner/src/title_patches.cpp ! grep -q 'kMphUs10AimY' /tmp/ndsrecomp/runner/src/title_patches.cpp + - name: Compile patched headless runner + run: | + 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 nds_runner -j2 + test -x /tmp/nds-runner-profile-check/nds_runner + - name: Compile US1.0 and EU1.1 ROM checkers run: | for profile in US1_0 EU1_1; do From fca2b01117453f647bc59942879dc6d04a8e2715 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:01:43 +0900 Subject: [PATCH 24/97] Compile patched runner sources without private BIOS artifacts --- .github/workflows/mph-multirom-static.yml | 25 ++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index 5311594..f350fb6 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -100,15 +100,34 @@ jobs: ! grep -q 'kMphUs10AimX' /tmp/ndsrecomp/runner/src/title_patches.cpp ! grep -q 'kMphUs10AimY' /tmp/ndsrecomp/runner/src/title_patches.cpp - - name: Compile patched headless runner + - name: Compile patched runner translation units run: | + # The pinned runner CMake expects locally generated BIOS-bank C files, + # which are deliberately absent from a clean source checkout. Empty + # placeholders are sufficient for CMake generation because this job + # compiles only the three modified runner C++ translation units and + # never links or executes the placeholder banks. + 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 nds_runner -j2 - test -x /tmp/nds-runner-profile-check/nds_runner + + cmake --build /tmp/nds-runner-profile-check --target \ + CMakeFiles/nds_runner.dir/src/title_patches.cpp.o -j2 + cmake --build /tmp/nds-runner-profile-check --target \ + CMakeFiles/nds_runner.dir/src/frontend.cpp.o -j2 + cmake --build /tmp/nds-runner-profile-check --target \ + CMakeFiles/nds_runner.dir/src/main.cpp.o -j2 - name: Compile US1.0 and EU1.1 ROM checkers run: | From 535d2a022b943c89508a08feba41ab15a54a15fd Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:02:59 +0900 Subject: [PATCH 25/97] Use public Makefile object targets for runner source checks --- .github/workflows/mph-multirom-static.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index f350fb6..54f4e77 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -123,11 +123,11 @@ jobs: -DNDS_ENABLE_PCAP_BACKEND=OFF cmake --build /tmp/nds-runner-profile-check --target \ - CMakeFiles/nds_runner.dir/src/title_patches.cpp.o -j2 + src/title_patches.o -j2 cmake --build /tmp/nds-runner-profile-check --target \ - CMakeFiles/nds_runner.dir/src/frontend.cpp.o -j2 + src/frontend.o -j2 cmake --build /tmp/nds-runner-profile-check --target \ - CMakeFiles/nds_runner.dir/src/main.cpp.o -j2 + src/main.o -j2 - name: Compile US1.0 and EU1.1 ROM checkers run: | From 683c23cb468c8d5bb8a268357cdd57eddb209c6e Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:04:36 +0900 Subject: [PATCH 26/97] Test exact-ROM MPH runtime address dispatch --- tools/tests/mph_runtime_profile_test.cpp | 136 +++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tools/tests/mph_runtime_profile_test.cpp diff --git a/tools/tests/mph_runtime_profile_test.cpp b/tools/tests/mph_runtime_profile_test.cpp new file mode 100644 index 0000000..bb94478 --- /dev/null +++ b/tools/tests/mph_runtime_profile_test.cpp @@ -0,0 +1,136 @@ +#include +#include +#include +#include + +#include "state.h" +#include "title_patches.h" + +namespace { + +struct Write32 { + uint32_t addr; + uint32_t value; +}; + +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); +} + +} // namespace + +// title_patches.cpp only needs these bus surfaces. Keeping the test at the +// ABI boundary lets it link the real patched translation unit without a ROM, +// BIOS dump, or generated recomp bank. +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() { + constexpr const char* kUs10Sha1 = + "90164d1ac127ee5f9815ea4ae7de798c7b5fc629"; + constexpr const char* kEu11Sha1 = + "bdcd1dea293e24c98d4c481430e90d21198985a5"; + + // Unknown or absent identities must fail closed. Enabling direct aim after + // a failed selection must not create guest-memory writes. + expect(!nds_title_patches_select_mph_runtime_profile(nullptr), + "null ROM identity must not select a runtime profile"); + expect(!nds_title_patches_select_mph_runtime_profile( + "0000000000000000000000000000000000000000"), + "unknown ROM identity must not select a runtime profile"); + nds_title_patches_set_mph_mouse_aim(true); + expect(!nds_title_patches_apply_mph_mouse_delta(1, 1), + "unknown ROM must not accept direct mouse aim"); + expect(g_writes.empty(), "unknown ROM must not write guest aim fields"); + expect(!nds_title_patches_mph_in_ball(), + "unknown ROM must not read a guessed morph address"); + + // Baseline regression: US1.0 must keep the exact addresses that were + // hard-coded before multi-ROM support. + expect(nds_title_patches_select_mph_runtime_profile(kUs10Sha1), + "US1.0 profile must be selectable"); + nds_title_patches_set_mph_mouse_aim(true); + g_writes.clear(); + expect(nds_title_patches_apply_mph_mouse_delta(11, -7), + "US1.0 direct mouse aim must accept a non-zero delta"); + expect(g_writes.size() == 2, "US1.0 aim must perform two writes"); + expect_write(0, 0x020DE526u, 11u, + "US1.0 X delta must target baseAimX"); + expect_write(1, 0x020DE52Eu, static_cast(-7), + "US1.0 Y delta must target baseAimY"); + g_morph_addr = 0x020DA818u; + g_morph_value = 0x02u; + expect(nds_title_patches_mph_in_ball(), + "US1.0 baseIsAltForm=2 must report Morph Ball"); + + // EU1.1 addresses come from melonPrimeDS + // MelonPrimeGameRomAddrTable.h, not from an inferred relocation delta. + expect(nds_title_patches_select_mph_runtime_profile(kEu11Sha1), + "EU1.1 profile must be selectable"); + + // Selection deliberately clears the prior direct-aim enable. This prevents + // state from one cartridge identity leaking into another profile. + g_writes.clear(); + expect(!nds_title_patches_apply_mph_mouse_delta(3, 4), + "profile switch must clear direct-aim enable state"); + expect(g_writes.empty(), "disabled aim after profile switch must not write"); + + nds_title_patches_set_mph_mouse_aim(true); + expect(nds_title_patches_apply_mph_mouse_delta(3, -4), + "EU1.1 direct mouse aim must accept a non-zero delta"); + expect(g_writes.size() == 2, "EU1.1 aim must perform two writes"); + expect_write(0, 0x020DEE46u, 3u, + "EU1.1 X delta must target melonPrimeDS baseAimX"); + expect_write(1, 0x020DEE4Eu, static_cast(-4), + "EU1.1 Y delta must target melonPrimeDS baseAimY"); + + g_morph_addr = 0x020DB138u; + g_morph_value = 0x02u; + expect(nds_title_patches_mph_in_ball(), + "EU1.1 baseIsAltForm=2 must report Morph Ball"); + g_morph_value = 0x00u; + expect(!nds_title_patches_mph_in_ball(), + "EU1.1 non-alt form must not report Morph Ball"); + + // A final invalid identity clears the active profile and prevents stale + // EU1.1 addresses from remaining live. + expect(!nds_title_patches_select_mph_runtime_profile("bad"), + "invalid final identity must clear the runtime profile"); + g_writes.clear(); + nds_title_patches_set_mph_mouse_aim(true); + expect(!nds_title_patches_apply_mph_mouse_delta(9, 9), + "cleared profile must reject mouse aim"); + expect(g_writes.empty(), "cleared profile must not retain EU1.1 writes"); + + if (g_failures != 0) { + std::fprintf(stderr, "%d runtime-profile assertion(s) failed\n", + g_failures); + return 1; + } + std::puts("OK: exact-ROM MPH runtime profiles dispatch US1.0/EU1.1 safely"); + return 0; +} From ea08c515c4d49d3153c09fd716d4424b8045ef39 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:15:07 +0900 Subject: [PATCH 27/97] Run exact-ROM MPH runtime dispatch test in CI --- .github/workflows/mph-multirom-static.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index 54f4e77..add43e5 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -129,6 +129,16 @@ jobs: cmake --build /tmp/nds-runner-profile-check --target \ src/main.o -j2 + - name: Test exact-ROM runtime address dispatch + run: | + 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 US1.0 and EU1.1 ROM checkers run: | for profile in US1_0 EU1_1; do From d4ee43e4ee06853e8c74e48e6a7477365fdeee8e Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:19:05 +0900 Subject: [PATCH 28/97] Gate launcher enhancements per ROM profile --- config/mph_rom_profiles.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index 2f0c680..70d2bd8 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -13,6 +13,8 @@ "coverage": "coverage/adventure-main-entry-points.json", "game_config": "game.toml", "fmv_runtime": true, + "launcher_default_rom": "Metroid Prime Hunters.nds", + "adaptive_widescreen": true, "runtime": { "morph_state": "0x020DA818", "aim_x": "0x020DE526", @@ -30,6 +32,8 @@ "coverage": "coverage/eu11-bootstrap-entry-points.json", "game_config": "config/game-eu11.toml", "fmv_runtime": false, + "launcher_default_rom": "Metroid Prime Hunters (Europe Rev 1).nds", + "adaptive_widescreen": false, "runtime": { "morph_state": "0x020DB138", "aim_x": "0x020DEE46", From b1ae13516c1611874196f1137d16d4281a9d8fcc Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:19:33 +0900 Subject: [PATCH 29/97] Validate launcher policy for each ROM profile --- tools/check_mph_multirom_profiles.py | 44 ++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py index 3cea998..7bfcd3c 100644 --- a/tools/check_mph_multirom_profiles.py +++ b/tools/check_mph_multirom_profiles.py @@ -2,10 +2,10 @@ """Static consistency checks for Metroid Prime Hunters ROM profiles. This intentionally does not need a copyrighted ROM. It verifies that each -profile's identity, coverage seed, game config, and host-side runtime addresses -agree. When --melonprime-table is provided, Aim/Morph addresses are also -cross-checked against melonPrimeDS's MelonPrimeGameRomAddrTable.h, which is the -source of truth for those fields. +profile's identity, coverage seed, game config, launcher policy, and host-side +runtime addresses agree. When --melonprime-table is provided, Aim/Morph +addresses are also cross-checked against melonPrimeDS's +MelonPrimeGameRomAddrTable.h, which is the source of truth for those fields. """ from __future__ import annotations @@ -15,6 +15,7 @@ import re import tomllib from pathlib import Path +from typing import NoReturn SHA1_RE = re.compile(r"^[0-9a-f]{40}$") @@ -26,7 +27,7 @@ } -def die(message: str) -> "NoReturn": +def die(message: str) -> NoReturn: raise SystemExit(f"ERROR: {message}") @@ -132,6 +133,19 @@ def validate_registry(repo: Path, table: Path | None) -> None: die(f"duplicate cartridge identity: {game_code} rev {revision}") seen_identity.add(identity) + 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") + runtime = profile.get("runtime") if not isinstance(runtime, dict): die(f"{key}.runtime is required") @@ -182,6 +196,22 @@ def validate_registry(repo: Path, table: Path | None) -> None: 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}" + ) + # Explicit regression guard for the first non-US revision. These are the # values currently published by melonPrimeDS's source-of-truth table. eu = profiles.get("EU1_1") @@ -201,6 +231,10 @@ def validate_registry(repo: Path, table: Path | None) -> None: die(f"EU1_1 runtime profile changed unexpectedly: {eu_runtime!r}") if eu.get("fmv_runtime") is not False: die("EU1_1 must not reuse the US1.0 FMV runtime capture") + 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(profiles)} MPH ROM profiles") if melon is not None: From 0c2a14383895380135feaa7084d59b4e4f7e6773 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:20:03 +0900 Subject: [PATCH 30/97] Gate launcher widescreen by ROM profile --- launcher/recomp-ui/CMakeLists.txt | 77 +++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index 8d53608..e23e429 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -14,26 +14,73 @@ set(MPH_LAUNCHER_ROM_SHA1 "Exact retail ROM SHA-1 accepted by 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") +option(MPH_LAUNCHER_ADAPTIVE_WIDESCREEN + "Expose and allow the title-specific adaptive widescreen enhancement" ON) # Keep launcher_main.cpp as the readable US1.0 baseline, but compile a -# profile-specific generated TU. This avoids duplicating the 40+ KiB launcher -# just to change cartridge identity metadata for another retail revision. +# profile-specific generated TU. Every replacement below is preimage-guarded: +# if the baseline launcher changes, configure fails instead of silently +# applying a stale multi-ROM transformation. file(READ "${CMAKE_CURRENT_SOURCE_DIR}/launcher_main.cpp" MPH_LAUNCHER_SOURCE) -set(_mph_us10_sha1 "90164d1ac127ee5f9815ea4ae7de798c7b5fc629") -string(FIND "${MPH_LAUNCHER_SOURCE}" "${_mph_us10_sha1}" _mph_sha_pos) -if(_mph_sha_pos EQUAL -1) - message(FATAL_ERROR "launcher_main.cpp no longer contains the US1.0 SHA-1 baseline") -endif() -string(REPLACE "${_mph_us10_sha1}" "${MPH_LAUNCHER_ROM_SHA1}" - MPH_LAUNCHER_SOURCE "${MPH_LAUNCHER_SOURCE}") -set(_mph_region_line "game.region = \"USA\";") -string(FIND "${MPH_LAUNCHER_SOURCE}" "${_mph_region_line}" _mph_region_pos) -if(_mph_region_pos EQUAL -1) - message(FATAL_ERROR "launcher_main.cpp no longer contains the USA region baseline") + +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() + +if(MPH_LAUNCHER_ADAPTIVE_WIDESCREEN) + set(_mph_adaptive_literal "true") + set(_mph_mod_count "2") +else() + set(_mph_adaptive_literal "false") + set(_mph_mod_count "1") endif() -string(REPLACE "${_mph_region_line}" + +mph_launcher_replace_required( + "90164d1ac127ee5f9815ea4ae7de798c7b5fc629" + "${MPH_LAUNCHER_ROM_SHA1}" + "the US1.0 SHA-1 baseline") +mph_launcher_replace_required( + "game.region = \"USA\";" "game.region = \"${MPH_LAUNCHER_REGION}\";" - MPH_LAUNCHER_SOURCE "${MPH_LAUNCHER_SOURCE}") + "the USA region baseline") +mph_launcher_replace_required( + "exe / \"Metroid Prime Hunters.nds\";" + "exe / \"${MPH_LAUNCHER_DEFAULT_ROM}\";" + "the default MPH ROM filename") +mph_launcher_replace_required( + "bool adaptive_widescreen = true;" + "bool adaptive_widescreen = ${_mph_adaptive_literal};" + "the adaptive-widescreen default") +mph_launcher_replace_required( + "int mod_feature_count(void*) {\n return 2;\n}" + "int mod_feature_count(void*) {\n return ${_mph_mod_count};\n}" + "the two-feature mod count") +mph_launcher_replace_required( + "if (!context || !output || index < 0 || index > 1) return 0;\n const auto* state = static_cast(context);" + "if (!context || !output || index < 0 || index >= ${_mph_mod_count}) return 0;\n if (!${_mph_adaptive_literal}) ++index;\n const auto* state = static_cast(context);" + "the mod feature index guard") +mph_launcher_replace_required( + "if (std::strcmp(package_id, \"mph-adaptive-widescreen\") == 0 &&\n std::strcmp(feature_id, \"adaptive-widescreen\") == 0) {\n state->adaptive_widescreen = enabled != 0;\n return 1;\n }" + "if (std::strcmp(package_id, \"mph-adaptive-widescreen\") == 0 &&\n std::strcmp(feature_id, \"adaptive-widescreen\") == 0) {\n if (!${_mph_adaptive_literal}) {\n state->adaptive_widescreen = false;\n return 0;\n }\n state->adaptive_widescreen = enabled != 0;\n return 1;\n }" + "the adaptive-widescreen enable handler") +mph_launcher_replace_required( + "load_mod_state(mod_state);\n RecompLauncherCModProvider mod_provider = make_mod_provider(&mod_state);" + "load_mod_state(mod_state);\n if (!${_mph_adaptive_literal}) mod_state.adaptive_widescreen = false;\n RecompLauncherCModProvider mod_provider = make_mod_provider(&mod_state);" + "the post-load mod-state initialization") +mph_launcher_replace_required( + "const std::wstring rom_wide = widen(rom);\n if (rom_wide.empty()) return false;" + "const std::wstring rom_wide = widen(rom);\n if (rom_wide.empty()) return false;\n adaptive = adaptive && ${_mph_adaptive_literal};" + "the runner launch profile gate") + set(MPH_PROFILE_LAUNCHER_SOURCE "${CMAKE_CURRENT_BINARY_DIR}/launcher_main_profile.cpp") file(WRITE "${MPH_PROFILE_LAUNCHER_SOURCE}" "${MPH_LAUNCHER_SOURCE}") From 58b5e2ef763cf1a758bed17f343bea14cfb3ced8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:20:25 +0900 Subject: [PATCH 31/97] Pass profile launcher policy to Windows build --- tools/build-windows.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tools/build-windows.ps1 b/tools/build-windows.ps1 index 0de94a2..d9b7c0e 100644 --- a/tools/build-windows.ps1 +++ b/tools/build-windows.ps1 @@ -2,8 +2,9 @@ Build Metroid Prime Hunters Recomp for one configured retail revision. US1_0 keeps the existing release paths. EU1_1 uses isolated generated banks, -a revision-specific game config, a profile-specific launcher identity, and the -shared exact-ROM runtime-address shim for Prime Controls/direct mouse aim. +a revision-specific game config, a profile-specific launcher identity/policy, +and the shared exact-ROM runtime-address shim for Prime Controls/direct mouse +aim. Usage: powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` @@ -41,11 +42,13 @@ if ($null -eq $profileProperty) { $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' } $gameConfig = [IO.Path]::GetFullPath( (Join-Path $root ([string]$profile.game_config))) if ([string]::IsNullOrWhiteSpace($RomPath)) { - $RomPath = Join-Path $root 'Metroid Prime Hunters.nds' + $RomPath = Join-Path $root $launcherDefaultRom } $romFull = [IO.Path]::GetFullPath($RomPath) if (-not (Test-Path -LiteralPath $romFull)) { @@ -93,6 +96,7 @@ if (-not (Test-Path -LiteralPath $patchPython)) { Push-Location $root try { Write-Host "Building MPH profile $MphVersion ($($profile.game_code) rev $($profile.revision))" + Write-Host "Launcher adaptive widescreen: $launcherAdaptive" & $cmakePath -G $Generator -S $root -B $gameBuild ` -DCMAKE_BUILD_TYPE=Release ` @@ -124,7 +128,9 @@ try { -DRECOMP_UI_ROOT="$RecompUiRoot" ` -DCMAKE_PREFIX_PATH="$RuntimeBinDir\..\lib\cmake" ` "-DMPH_LAUNCHER_ROM_SHA1=$romSha1" ` - "-DMPH_LAUNCHER_REGION=$region" + "-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 From 35cd9c012e6e7b4857c42d8bfd94ff083aed54fa Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:21:11 +0900 Subject: [PATCH 32/97] Verify profile-specific launcher generation in CI --- .github/workflows/mph-multirom-static.yml | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index add43e5..fdfc070 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -63,6 +63,51 @@ jobs: 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 + run: | + 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' \ + -DMPH_LAUNCHER_ADAPTIVE_WIDESCREEN=OFF + + us=/tmp/mph-launcher-us10/launcher_main_profile.cpp + eu=/tmp/mph-launcher-eu11/launcher_main_profile.cpp + test -f "$us" -a -f "$eu" + + grep -q '90164d1ac127ee5f9815ea4ae7de798c7b5fc629' "$us" + grep -q 'game.region = "USA";' "$us" + grep -q 'bool adaptive_widescreen = true;' "$us" + grep -A1 'int mod_feature_count' "$us" | grep -q 'return 2;' + grep -q 'adaptive = adaptive && true;' "$us" + + grep -q 'bdcd1dea293e24c98d4c481430e90d21198985a5' "$eu" + grep -q 'game.region = "Europe";' "$eu" + grep -q 'exe / "Metroid Prime Hunters (Europe Rev 1).nds";' "$eu" + grep -q 'bool adaptive_widescreen = false;' "$eu" + grep -A1 'int mod_feature_count' "$eu" | grep -q 'return 1;' + grep -q 'if (!false) ++index;' "$eu" + grep -q 'adaptive = adaptive && false;' "$eu" + - name: Verify ndsrecomp runtime patch is idempotent run: | python tools/patch_ndsrecomp_mph_runtime.py \ From fecfcbe491f377d1d26ed3dc1ba9307045b2c4f5 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:23:53 +0900 Subject: [PATCH 33/97] Update EU1.1 bring-up status and launcher safety gates --- docs/EU1_1_BRINGUP.md | 192 +++++++++++++++++++++++++++++------------- 1 file changed, 134 insertions(+), 58 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index 643eece..0d5b5b7 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -9,7 +9,7 @@ multi-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revision 1) を安全にbring-upする。 -現在はROM実体なしで実装・検証できる範囲をさらに進め、次まで完了している。 +現在はROM実体なしで実装・検証できる範囲を進め、次まで完了している。 1. ROM identityを版別profileとして管理する。 2. EU1.1 ROMからARM9 / ARM7 / ARM9 overlayを直接抽出するprofile-driven prepare経路を持つ。 @@ -17,12 +17,14 @@ multi-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revi 4. US1.0のcoverage seedやFMV runtime captureをEU1.1へ流用しない。 5. exact ROM SHA-1でrunner側のbankとhost-side runtime address profileをgateする。 6. Prime ControlsのMorph判定とDirect Mouse AimをEU1.1固有RAMアドレスへ対応させる。 -7. EU1.1専用ROM identityを持つlauncherを同一launcher sourceから生成する。 -8. Windows/Linux build入口をprofile-awareにする。 -9. ROM不要のstatic CIでprofile整合性とpinned ndsrecomp patchを検証する。 +7. EU1.1専用ROM identity / default ROM filename / feature policyを持つlauncherを同一launcher sourceから生成する。 +8. EU1.1では未検証のAdaptive Widescreenをlauncher UIから隠し、persisted stateとlaunch commandの両方でも強制OFFにする。 +9. Windows/Linux build入口をprofile-awareにする。 +10. ROM不要CIでmelonPrimeDS address tableとの照合、pinned ndsrecomp patch、runner C++ compile、exact-ROM runtime dispatchを検証する。 +11. ROM不要CIでUS1.0/EU1.1のlauncher generated sourceを生成し、identityとfeature policyを直接検証する。 未完了なのは、EU1.1実ROMと実行環境を必要とするruntime validation、 -EU1.1固有coverageの拡張、EU1.1固有FMV runtime captureである。 +EU1.1固有coverageの拡張、必要に応じたEU1.1固有FMV runtime captureである。 ## 2. EU1.1 identity @@ -35,9 +37,11 @@ EU1.1固有coverageの拡張、EU1.1固有FMV runtime captureである。 | SHA-1 | `bdcd1dea293e24c98d4c481430e90d21198985a5` | | Program ID prefix | `mph_amhp1` | | Game config | `config/game-eu11.toml` | +| Launcher default ROM | `Metroid Prime Hunters (Europe Rev 1).nds` | +| Adaptive Widescreen | disabled until EU1.1 validation | | FMV runtime bank | disabled until EU1.1 capture exists | -identityは `config/mph_rom_profiles.json` に集約する。 +identityと版別policyは `config/mph_rom_profiles.json` に集約する。 ## 3. ROM profile registry @@ -53,6 +57,8 @@ Profileには次を持たせる。 - `coverage` - `game_config` - `fmv_runtime` +- `launcher_default_rom` +- `adaptive_widescreen` - `runtime.morph_state` - `runtime.aim_x` - `runtime.aim_y` @@ -63,7 +69,9 @@ host-side runtime addressのsource of truthは以下に固定する。 https://github.com/ag-advania/melonPrimeDS/blob/main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h ``` -AimアドレスはmphCodexから推測せず、このmelonPrimeDS address tableを正として扱う。 +Aim/Morphアドレスはglobal relocation deltaから推測せず、このmelonPrimeDS address tableを正として扱う。 + +`tools/check_mph_multirom_profiles.py` はprofileと各versionの`game.toml`、coverage seed、launcher policyを静的照合する。特に `adaptive_widescreen=false` のprofileでgame config側がadaptiveを有効化していた場合はfailする。 ## 4. melonPrimeDSから確定したEU1.1 runtime addresses @@ -83,6 +91,8 @@ Recomp runnerで現在必要なフィールドは以下である。 これらは `config/mph_rom_profiles.json` の `runtime` に登録済みである。 +CIはmelonPrimeDS `main` の実ファイルを取得し、`RomGroup`と上記3フィールドをparserで読み取り、profileと自動照合する。値が変化した場合はCIで検出する。 + ## 5. pinned ndsrecompのUS1.0固定を除去する方法 pinned framework revision: @@ -107,8 +117,7 @@ main.cpp: というUS1.0固定が存在する。 -プロジェクト側で `tools/patch_ndsrecomp_mph_runtime.py` を追加し、build前に -pinned `ndsrecomp` checkoutへ小さなprofile-selection shimを適用する。 +プロジェクト側の `tools/patch_ndsrecomp_mph_runtime.py` がbuild前にpinned `ndsrecomp` checkoutへ小さなprofile-selection shimを適用する。 パッチ後は概念的に次の経路になる。 @@ -135,9 +144,18 @@ runner/src/mph_runtime_profiles.generated.h - exact pinned source preimageを要求する。 - upstream sourceが想定外に変わった場合はguessせず失敗する。 - 同じcheckoutへ複数回適用しても結果が変わらない。 +- profile切替時にDirect Aim enable stateをclearする。 - 未知ROMはfail-closedになる。 - runtime addressはDS main RAM範囲内か検証する。 +さらに `tools/tests/mph_runtime_profile_test.cpp` は実際のpatched `title_patches.cpp` をリンクして、ROMなしで次を実行検証する。 + +- unknown SHA-1ではAim writeもMorph readも行わない。 +- US1.0は従来の3アドレスを維持する。 +- EU1.1は `0x020DB138 / 0x020DEE46 / 0x020DEE4E` のみを使う。 +- US1.0からEU1.1へprofile切替した際にold enable stateを引き継がない。 +- valid profileの後にunknown SHA-1を選択した場合もstale addressを保持しない。 + ## 6. EU1.1 coverage bootstrap `coverage/eu11-bootstrap-entry-points.json` @@ -146,12 +164,9 @@ runner/src/mph_runtime_profiles.generated.h これは意図的である。 -`prepare_mph.py` はROM headerのARM9/ARM7 entry PCを必ずseedするため、EU1.1は -まずそのrootからstatic discoveryを行い、未コンパイル領域はruntime Interpreterへ -fallbackする。 +`prepare_mph.py` はROM headerのARM9/ARM7 entry PCを必ずseedするため、EU1.1はまずそのrootからstatic discoveryを行い、未コンパイル領域はruntime Interpreterへfallbackする。 -US1.0の `coverage/adventure-main-entry-points.json` に入っているabsolute PCを -EU1.1へコピーしてはいけない。 +US1.0の `coverage/adventure-main-entry-points.json` に入っているabsolute PCをEU1.1へコピーしてはいけない。 EU1.1自身を実行したtraceからのみEU1.1 coverageを拡張する。 @@ -179,35 +194,51 @@ generated/ これにより異なるROM由来のbankやbinaryが同じパスへ混ざらない。 -## 8. Launcher identity separation +## 8. Launcher identity / feature policy separation launcherの大きなsourceをROM revisionごとに複製しない。 -`launcher/recomp-ui/CMakeLists.txt` はconfigure時にbaseline `launcher_main.cpp` を読み、 -選択profileの +`launcher/recomp-ui/CMakeLists.txt` はconfigure時にbaseline `launcher_main.cpp` を読み、選択profileの次の項目を反映したgenerated translation unitをbuild directoryへ作る。 -- ROM SHA-1 +- exact ROM SHA-1 - Region - -だけを反映したgenerated translation unitをbuild directoryへ作る。 +- default ROM filename +- Adaptive Widescreen availability/default ```text launcher_main.cpp - -> configure-time identity transform + -> configure-time guarded transform -> launcher_main_profile.cpp -> mph-recomp-ui ``` -US1.0は従来SHA-1を保持し、EU1.1 buildでは +各replaceはbaseline preimageを要求する。launcher sourceが将来変わって想定文字列が消えた場合は、stale transformを続行せずCMake configureを失敗させる。 + +US1.0は従来挙動を維持する。 + +```text +SHA-1: 90164d1ac127ee5f9815ea4ae7de798c7b5fc629 +Region: USA +Default ROM: Metroid Prime Hunters.nds +Adaptive Widescreen: enabled / UI exposed +``` + +EU1.1 buildは次になる。 ```text -bdcd1dea293e24c98d4c481430e90d21198985a5 -Europe +SHA-1: bdcd1dea293e24c98d4c481430e90d21198985a5 +Region: Europe +Default ROM: Metroid Prime Hunters (Europe Rev 1).nds +Adaptive Widescreen: disabled / UI hidden ``` -を持つlauncherになる。 +EU1.1のAdaptive Widescreenは三重にfail-closedにする。 + +1. launcher mod listからAdaptive Widescreen項目を隠す。 +2. 旧/shared `mods.ini` に `adaptive_widescreen=true` が残っていてもload後にfalseへ戻す。 +3. `launch_runner()` の最終段でもprofile capabilityとANDし、EU1.1では `--adaptive-widescreen top` を生成できないようにする。 -これによりEU1.1 ROMをUS1.0 launcherへ誤認させず、同一UI実装を共有できる。 +Prime ControlsはEU1.1でも表示する。これはMorph/Aimの必要アドレスをmelonPrimeDS tableからexact profile化し、ROMなしunit testでaddress dispatchまで固定できたためである。ただし実ゲーム内でのsemantic correctnessは実ROM Gate Dで別途確認する。 ## 9. FMV runtime bank @@ -228,9 +259,7 @@ fmv_runtime = false とし、このbankを絶対に登録しない。 -EU1.1でopening FMV等がInterpreter fallbackでは遅い場合でも、US1.0 captureを -流用してはいけない。EU1.1自身からITCM + main RAM captureを取得し、live-byte -validation付きのEU1.1専用runtime bankを作る。 +EU1.1でopening FMV等がInterpreter fallbackでは遅い場合でも、US1.0 captureを流用してはいけない。EU1.1自身からITCM + main RAM captureを取得し、live-byte validation付きのEU1.1専用runtime bankを作る。 ## 10. Build @@ -244,6 +273,8 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` -RomPath 'D:\ROMs\Metroid Prime Hunters (Europe) (Rev 1).nds' ``` +`-RomPath` を省略した場合は選択profileの `launcher_default_rom` を使う。EU1.1なら `Metroid Prime Hunters (Europe Rev 1).nds` になる。 + 現在のWindows buildはEU1.1についても以下まで一貫して行う。 1. EU1.1 ROM identity verify @@ -251,8 +282,9 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` 3. EU1.1 static bank generation 4. pinned ndsrecomp runtime-profile patch 5. exact EU1.1 SHA-1 runner build -6. EU1.1 identity launcher build -7. `config/game-eu11.toml` をrelease内 `game.toml` としてpackage +6. EU1.1 identity + feature policy launcher build +7. EU1.1 launcherでAdaptive Widescreenを非公開/強制OFF +8. `config/game-eu11.toml` をrelease内 `game.toml` としてpackage EU1.1にはFMV runtime captureがまだないため、そのbankの存在はrelease gateにしない。 @@ -277,6 +309,8 @@ tools/build-linux.sh \ EU1.1 package名には `EU1_1` suffixを付け、US1.0のhistorical filenameとは分離する。 +Linux AppRunはprofile別 `game.toml` をrunnerへ渡し、launcherのようなUS1.0固定Adaptive Widescreen CLI overrideを行わない。EU1.1では `config/game-eu11.toml` のnative presentation policyがそのまま有効になることを維持する。 + ## 11. ROM不要static CI `.github/workflows/mph-multirom-static.yml` @@ -286,24 +320,32 @@ PRごとに以下を検証する。 1. `prepare_mph.py` / profile checker / framework patcherのPython syntax 2. Linux build scriptのshell syntax 3. Windows build/release scriptのPowerShell syntax -4. melonPrimeDS `main` の `MelonPrimeGameRomAddrTable.h` を取得 -5. `baseIsAltForm` / `baseAimX` / `baseAimY` をprofileと自動照合 -6. exact `ndsrecomp.pin` revisionを取得 -7. runtime-profile patchを適用 -8. 同じpatchを2回適用し、対象ファイルhashが完全一致することを確認 -9. US1.0固定Aim/Morph symbolが除去されていることを確認 -10. US1.0/EU1.1それぞれの`mph_romcheck`をcompile -11. EU1.1 checkerにEU1.1 SHA-1/profile keyが埋め込まれていることを確認 -12. `git diff --check` - -このCIはROMを一切取得・保存しない。 +4. `config/mph_rom_profiles.json` とcoverage/game config/launcher policyの整合性 +5. melonPrimeDS `main` の `MelonPrimeGameRomAddrTable.h` を取得 +6. `baseIsAltForm` / `baseAimX` / `baseAimY` をprofileと自動照合 +7. exact `ndsrecomp.pin` revisionを取得 +8. fake SDL2 / recomp-ui CMake interfaceでUS1.0 launcher sourceを生成 +9. fake SDL2 / recomp-ui CMake interfaceでEU1.1 launcher sourceを生成 +10. generated EU1.1 launcherがEU SHA-1 / Europe / EU default ROM filenameを持つことを確認 +11. generated EU1.1 launcherがAdaptive Widescreen default OFF / UI hidden / final launch gate OFFであることを確認 +12. runtime-profile patchを適用 +13. 同じpatchを2回適用し、対象ファイルhashが完全一致することを確認 +14. US1.0固定Aim/Morph symbolがpatched runnerから除去されていることを確認 +15. patched `title_patches.cpp` / `frontend.cpp` / `main.cpp` をpinned runnerの実CMake compile flagsでcompile +16. `tools/tests/mph_runtime_profile_test.cpp` をpatched `title_patches.cpp` とリンクして実行 +17. US1.0/EU1.1それぞれの`mph_romcheck`をcompile +18. EU1.1 checkerにEU1.1 SHA-1/profile keyが埋め込まれていることを確認 +19. `git diff --check` + +このCIはROM、BIOS、firmware dumpを一切取得・保存しない。 + +2026-08-17時点の最新CIでは上記項目がすべてPASSしている。 ## 12. mphCodexの役割 -Aim X/YについてはmelonPrimeDS address tableをsource of truthとする。 +Aim X/Y/MorphについてはmelonPrimeDS address tableをsource of truthとする。 -mphCodexは引き続き、今後Recomp固有のhost enhancementがsemantic stateを読む必要が -出た場合のcross-version調査に利用できる。 +mphCodexは引き続き、今後Recomp固有のhost enhancementがsemantic stateを読む必要が出た場合のcross-version調査に利用する。 例: @@ -359,26 +401,47 @@ static missはInterpreterへfallbackさせ、最初はcorrectnessを優先する - save/reload - multiplayer menu -### Gate D - Prime Controls +### Gate D - Prime Controls / Direct Mouse Aim semantic validation -EU1.1 selected profileで以下を実機能確認する。 +ROM不要unit testによってaddress routing自体は既に固定済みである。 ```text Morph state = 0x020DB138 -Aim X = 0x020DEE46 -Aim Y = 0x020DEE4E +Aim X = 0x020DEE46 +Aim Y = 0x020DEE4E ``` -確認項目: +実ROM Gate Dでは「そのアドレスを使うか」ではなく、ゲーム内semanticが期待通りかを確認する。 - normal formでcenter touch保持 - Morph Ball時にcenter touchを解除 -- mouse X/Y deltaがEU1.1 fieldsへ書かれる -- US1.0 fieldsへ書かれない +- mouse X/Y deltaでcamera aimが正しく変化 - menu/touch操作へ戻れる - keyboard/gamepad Prime Controls +- profile切替・再起動後にstale stateが残らない + +### Gate E - Adaptive Widescreen + +EU1.1では現在意図的に無効である。EU1.1を基本対応とするための必須条件ではない。 + +将来EU1.1でも有効化する場合は、少なくとも次をEU1.1実ROMで確認する。 + +- 3D projection +- frustum/culling +- upper-screen HUD anchoring +- lower touchscreen native layout +- Adventure camera / Scan Visor等の特殊scene +- US1.0との差分があるsemantic addressを固定値で流用していないこと + +検証後にのみ、 -### Gate E - EU1.1 deterministic coverage +```json +"adaptive_widescreen": true +``` + +とEU1.1 `game.toml` の対応display設定を同時に有効化する。 + +### Gate F - EU1.1 deterministic coverage EU1.1 execution traceから @@ -390,7 +453,7 @@ EU1.1 execution traceから US1.0 absolute PCのaddress translationは行わない。 -### Gate F - EU1.1 FMV runtime optimization +### Gate G - EU1.1 FMV runtime optimization 必要な場合だけEU1.1自身からcaptureを作る。 @@ -417,22 +480,35 @@ EU1.1をruntime検証済みsupportedと宣言する条件: - host-side title patchがUS1.0 addressをEU1.1へ使用しない - Prime Controls動作確認 - Direct Mouse Aim動作確認 +- EU1.1未検証enhancementがfail-closedであること - native/reference checkpoint比較 - US1.0 regressionなし +Adaptive WidescreenやEU1.1 FMV runtime bankは、基本的なEU1.1 correctnessを満たすための必須機能ではない。未検証のまま誤って有効化しないことを優先する。 + ## 15. 現在の判定 ### Code / infrastructure **READY FOR EU1.1 ROM VALIDATION** -ROMなしで可能なidentity/profile、extraction routing、bank isolation、runtime address -selection、launcher identity、Windows/Linux packaging、static CIまで実装済み。 +ROMなしで可能なidentity/profile、extraction routing、bank isolation、runtime address selection、launcher identity、launcher feature gating、Windows/Linux packaging、static CIまで実装済み。 + +特に以下はROMなしで実行検証済みである。 + +- melonPrimeDS tableとEU1.1 Aim/Morph profileの一致 +- runtime patchのidempotency +- patched runner C++ translation unitsのcompile +- unknown ROM fail-closed +- US1.0 runtime address regressionなし +- EU1.1 exact Aim/Morph dispatch +- US1.0/EU1.1 launcher generated sourceのprofile分離 +- EU1.1 Adaptive WidescreenのUI/persisted state/launch command三重gate +- US1.0/EU1.1 ROM checker compile ### Runtime correctness **NOT YET CLAIMED** EU1.1実ROMによるboot/gameplay/reference validationは別途必要である。 -このvalidationを通すまでは、コードがEU1.1を受理できることと、ゲーム動作が完全に -検証済みであることを混同しない。 +このvalidationを通すまでは、コードがEU1.1を受理できることと、ゲーム動作が完全に検証済みであることを混同しない。 From c3c80884e1a953ad3682591ac82e3cf0ab284640 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:24:26 +0900 Subject: [PATCH 34/97] Keep Linux EU1.1 launch policy profile-owned --- tools/build-linux.sh | 359 ++++++++++++++++++++++--------------------- 1 file changed, 184 insertions(+), 175 deletions(-) diff --git a/tools/build-linux.sh b/tools/build-linux.sh index b46d0eb..1498a5f 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -1,224 +1,233 @@ #!/usr/bin/env bash -# Build Metroid Prime Hunters Recomp for a configured retail revision. -# -# US1_0 keeps the existing AppImage naming. EU1_1 uses isolated generated -# banks, its own game config, and the exact-ROM runtime-address shim used by -# Prime Controls/direct mouse aim. set -euo pipefail -APP_NAME="MetroidPrimeHuntersRecomp" -TITLE_TARGET="metroidprimehuntersrecomp" -RUNNER_NAME="nds_runner" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FRAMEWORK_ROOT="$(cd "$ROOT/../ndsrecomp" && pwd)" VERSION="0.1.0" MPH_VERSION="US1_0" -ROM_PATH="" -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" -PROFILE_FILE="$REPO/config/mph_rom_profiles.json" +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;; - --mph-version) MPH_VERSION="$2"; shift 2;; - --rom) ROM_PATH="$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,18p' "$0" + usage exit 0 ;; - *) echo "unknown arg: $1" >&2; exit 2;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; esac done -profile_value() { - python3 - "$PROFILE_FILE" "$MPH_VERSION" "$1" <<'PY' +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, version, key = sys.argv[1:] -with open(path, encoding="utf-8") as f: - registry = json.load(f) -try: - value = registry["profiles"][version][key] -except KeyError as exc: - raise SystemExit(f"unknown profile/field: {version}.{key}") from exc -if isinstance(value, bool): - print("1" if value else "0") -else: - print(value) + +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"): + 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"]) 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]}" +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 + +if [[ ! -f "$ROM_PATH" ]]; then + printf 'ROM not found: %s\n' "$ROM_PATH" >&2 + exit 1 +fi +if [[ ! -f "$GAME_CONFIG" ]]; then + printf 'Game config not found: %s\n' "$GAME_CONFIG" >&2 + exit 1 +fi -ROM_SHA1="$(profile_value sha1)" -GAME_CONFIG_REL="$(profile_value game_config)" -FMV_RUNTIME="$(profile_value fmv_runtime)" -GAME_CONFIG="$REPO/$GAME_CONFIG_REL" -if [ -z "$ROM_PATH" ]; then - ROM_PATH="$REPO/Metroid Prime Hunters.nds" +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 -ROM_PATH="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$ROM_PATH")" -if [ "$MPH_VERSION" = "US1_0" ]; then - GAME_BUILD="$REPO/build-linux-release" - RUNNER_BUILD="$FRAMEWORK_ROOT/runner/build-mph-linux-release" - TITLE_BANK_DIR="$REPO/generated/recomp" +if [[ "$MPH_VERSION" == "US1_0" ]]; then + TITLE_BANK_DIR="$ROOT/generated/recomp" else - GAME_BUILD="$REPO/build-linux-release-$MPH_VERSION" - RUNNER_BUILD="$FRAMEWORK_ROOT/runner/build-mph-linux-release-$MPH_VERSION" - TITLE_BANK_DIR="$REPO/generated/$MPH_VERSION/recomp" + TITLE_BANK_DIR="$ROOT/generated/$MPH_VERSION/recomp" fi -cd "$REPO" -test -f "$FRAMEWORK_ROOT/recompiler/CMakeLists.txt" || { - echo "ERROR: sibling ndsrecomp checkout is missing." >&2 - exit 1 -} -test -f "$ROM_PATH" || { - echo "ERROR: Metroid Prime Hunters ROM is missing: $ROM_PATH" >&2 - exit 1 -} -test -f "$GAME_CONFIG" || { - echo "ERROR: game config for $MPH_VERSION is missing: $GAME_CONFIG" >&2 - exit 1 -} +printf 'Building MPH profile %s (%s rev %s)\n' "$MPH_VERSION" "$GAME_CODE" "$REVISION" -echo "[1/5] configure title banks ($MPH_VERSION)" -cmake -S "$REPO" -B "$GAME_BUILD" -G "Unix Makefiles" \ +cmake -S "$ROOT" -B "$BUILD_DIR" \ -DCMAKE_BUILD_TYPE=Release \ -DNDSRECOMP_ROOT="$FRAMEWORK_ROOT" \ -DMPH_VERSION="$MPH_VERSION" \ -DMPH_ROM="$ROM_PATH" -echo "[2/5] build title banks" -cmake --build "$GAME_BUILD" --target "$TITLE_TARGET" -j"$JOBS" +cmake --build "$BUILD_DIR" --target metroidprimehuntersrecomp -j "$JOBS" -echo "[3/5] install exact-ROM MPH runtime profile into pinned ndsrecomp runner" -python3 "$REPO/tools/patch_ndsrecomp_mph_runtime.py" \ +python3 "$ROOT/tools/patch_ndsrecomp_mph_runtime.py" \ --framework-root "$FRAMEWORK_ROOT" \ --profiles "$PROFILE_FILE" -echo "[4/5] 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" -if [ "$DO_PACKAGE" = "0" ]; then - echo "done: $RUNNER_BUILD/$RUNNER_NAME" - echo "config: $GAME_CONFIG" - exit 0 +RUNNER="$RUNNER_BUILD_DIR/nds_runner" +if [[ ! -x "$RUNNER" ]]; then + printf 'Runner missing after build: %s\n' "$RUNNER" >&2 + exit 1 fi -BIN="$RUNNER_BUILD/$RUNNER_NAME" -test -f "$BIN" || { echo "ERROR: runner not built: $BIN" >&2; exit 1; } -if [ "$FMV_RUNTIME" = "1" ]; then - strings "$BIN" | grep -q mph_arm9_fmv_runtime || { - echo "ERROR: runner does not contain the MPH FMV runtime bank." >&2 +if [[ "$FMV_RUNTIME" == "1" ]]; then + if ! grep -a -q 'mph_arm9_fmv_runtime' "$RUNNER"; then + printf 'Runner does not contain the required MPH FMV runtime bank.\n' >&2 exit 1 - } + fi fi -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 "$GAME_CONFIG" "$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' -#!/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 -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" - fi - exec "$HERE/usr/bin/nds_runner" "$RUNDIR/bios" --interactive --config "$HERE/usr/bin/game.toml" +#!/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 -exec "$HERE/usr/bin/nds_runner" "$@" +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" "$APPDIR/usr/bin/$RUNNER_NAME" +chmod +x "$APPDIR/AppRun" -echo "[5/5] 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 +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 -if [ "$MPH_VERSION" = "US1_0" ]; then - APP="$OUT/$APP_NAME-linux-v$VERSION-x86_64.AppImage" +if [[ "$MPH_VERSION" == "US1_0" ]]; then + OUTPUT="$ROOT/release-stage/MetroidPrimeHuntersRecomp-linux-v${VERSION}-x86_64.AppImage" else - APP="$OUT/$APP_NAME-$MPH_VERSION-linux-v$VERSION-x86_64.AppImage" + OUTPUT="$ROOT/release-stage/MetroidPrimeHuntersRecomp-${MPH_VERSION}-linux-v${VERSION}-x86_64.AppImage" fi -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" From 756de1d7edab0009c42aa4f85eb87e818a3a508f Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:24:54 +0900 Subject: [PATCH 35/97] Check Linux EU1.1 profile launch policy in CI --- .github/workflows/mph-multirom-static.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index fdfc070..9ea6479 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -108,6 +108,15 @@ jobs: grep -q 'if (!false) ++index;' "$eu" grep -q 'adaptive = adaptive && false;' "$eu" + - name: Verify Linux profile-owned launch policy + run: | + grep -q 'launcher_default_rom' tools/build-linux.sh + grep -q 'cp "$GAME_CONFIG" "$APPDIR/usr/share/mph-recomp/game.toml"' \ + tools/build-linux.sh + grep -q -- '--config "$HERE/usr/share/mph-recomp/game.toml"' \ + tools/build-linux.sh + ! grep -q -- '--adaptive-widescreen' tools/build-linux.sh + - name: Verify ndsrecomp runtime patch is idempotent run: | python tools/patch_ndsrecomp_mph_runtime.py \ From d2bec0173818ed0787cd411ccb4b8ef559427583 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:25:43 +0900 Subject: [PATCH 36/97] Document Linux EU1.1 profile-owned launch policy --- docs/EU1_1_BRINGUP.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index 0d5b5b7..a771117 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -22,6 +22,7 @@ multi-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revi 9. Windows/Linux build入口をprofile-awareにする。 10. ROM不要CIでmelonPrimeDS address tableとの照合、pinned ndsrecomp patch、runner C++ compile、exact-ROM runtime dispatchを検証する。 11. ROM不要CIでUS1.0/EU1.1のlauncher generated sourceを生成し、identityとfeature policyを直接検証する。 +12. Linux AppRunではprofile別 `game.toml` を唯一のtitle presentation policyとし、Adaptive Widescreen等をCLIで上書きしない。 未完了なのは、EU1.1実ROMと実行環境を必要とするruntime validation、 EU1.1固有coverageの拡張、必要に応じたEU1.1固有FMV runtime captureである。 @@ -307,6 +308,8 @@ tools/build-linux.sh \ --rom '/path/to/Metroid Prime Hunters (Europe) (Rev 1).nds' ``` +`--rom`を省略した場合、EU1.1ではprofileの `launcher_default_rom`、すなわち `Metroid Prime Hunters (Europe Rev 1).nds` をrepository rootから探す。 + EU1.1 package名には `EU1_1` suffixを付け、US1.0のhistorical filenameとは分離する。 Linux AppRunはprofile別 `game.toml` をrunnerへ渡し、launcherのようなUS1.0固定Adaptive Widescreen CLI overrideを行わない。EU1.1では `config/game-eu11.toml` のnative presentation policyがそのまま有効になることを維持する。 @@ -328,18 +331,19 @@ PRごとに以下を検証する。 9. fake SDL2 / recomp-ui CMake interfaceでEU1.1 launcher sourceを生成 10. generated EU1.1 launcherがEU SHA-1 / Europe / EU default ROM filenameを持つことを確認 11. generated EU1.1 launcherがAdaptive Widescreen default OFF / UI hidden / final launch gate OFFであることを確認 -12. runtime-profile patchを適用 -13. 同じpatchを2回適用し、対象ファイルhashが完全一致することを確認 -14. US1.0固定Aim/Morph symbolがpatched runnerから除去されていることを確認 -15. patched `title_patches.cpp` / `frontend.cpp` / `main.cpp` をpinned runnerの実CMake compile flagsでcompile -16. `tools/tests/mph_runtime_profile_test.cpp` をpatched `title_patches.cpp` とリンクして実行 -17. US1.0/EU1.1それぞれの`mph_romcheck`をcompile -18. EU1.1 checkerにEU1.1 SHA-1/profile keyが埋め込まれていることを確認 -19. `git diff --check` +12. Linux buildがprofile別 `game.toml` をAppRunへ渡し、`--adaptive-widescreen` を強制していないことを確認 +13. runtime-profile patchを適用 +14. 同じpatchを2回適用し、対象ファイルhashが完全一致することを確認 +15. US1.0固定Aim/Morph symbolがpatched runnerから除去されていることを確認 +16. patched `title_patches.cpp` / `frontend.cpp` / `main.cpp` をpinned runnerの実CMake compile flagsでcompile +17. `tools/tests/mph_runtime_profile_test.cpp` をpatched `title_patches.cpp` とリンクして実行 +18. US1.0/EU1.1それぞれの`mph_romcheck`をcompile +19. EU1.1 checkerにEU1.1 SHA-1/profile keyが埋め込まれていることを確認 +20. `git diff --check` このCIはROM、BIOS、firmware dumpを一切取得・保存しない。 -2026-08-17時点の最新CIでは上記項目がすべてPASSしている。 +2026-08-17時点の最新CIでは上記項目を継続検証している。 ## 12. mphCodexの役割 @@ -504,6 +508,7 @@ ROMなしで可能なidentity/profile、extraction routing、bank isolation、ru - EU1.1 exact Aim/Morph dispatch - US1.0/EU1.1 launcher generated sourceのprofile分離 - EU1.1 Adaptive WidescreenのUI/persisted state/launch command三重gate +- Linux AppRunのprofile-owned game config policy - US1.0/EU1.1 ROM checker compile ### Runtime correctness From ce2756aa5d128da2daab119ffb5a22092be5e874 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:26:30 +0900 Subject: [PATCH 37/97] Keep EU1.1 bring-up document synchronized --- docs/EU1_1_BRINGUP.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index a771117..e7fe9b3 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -343,8 +343,6 @@ PRごとに以下を検証する。 このCIはROM、BIOS、firmware dumpを一切取得・保存しない。 -2026-08-17時点の最新CIでは上記項目を継続検証している。 - ## 12. mphCodexの役割 Aim X/Y/MorphについてはmelonPrimeDS address tableをsource of truthとする。 From 07083f708f8de8270dfbdff7862f1d1ca0bd01d7 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:27:18 +0900 Subject: [PATCH 38/97] Polish EU1.1 bring-up documentation --- docs/EU1_1_BRINGUP.md | 62 ++++++++----------------------------------- 1 file changed, 11 insertions(+), 51 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index e7fe9b3..3001202 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -5,9 +5,7 @@ ## 1. 目的 -`MetroidPrimeHuntersRecomp` を USA revision 0 (`AMHE`, revision 0) 固定から -multi-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revision 1) -を安全にbring-upする。 +`MetroidPrimeHuntersRecomp` を USA revision 0 (`AMHE`, revision 0) 固定からmulti-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revision 1) を安全にbring-upする。 現在はROM実体なしで実装・検証できる範囲を進め、次まで完了している。 @@ -24,8 +22,7 @@ multi-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revi 11. ROM不要CIでUS1.0/EU1.1のlauncher generated sourceを生成し、identityとfeature policyを直接検証する。 12. Linux AppRunではprofile別 `game.toml` を唯一のtitle presentation policyとし、Adaptive Widescreen等をCLIで上書きしない。 -未完了なのは、EU1.1実ROMと実行環境を必要とするruntime validation、 -EU1.1固有coverageの拡張、必要に応じたEU1.1固有FMV runtime captureである。 +未完了なのは、EU1.1実ROMと実行環境を必要とするruntime validation、EU1.1固有coverageの拡張、必要に応じたEU1.1固有FMV runtime captureである。 ## 2. EU1.1 identity @@ -161,15 +158,11 @@ runner/src/mph_runtime_profiles.generated.h `coverage/eu11-bootstrap-entry-points.json` -初期状態ではARM9 / ARM7の追加coverage rootを空にする。 - -これは意図的である。 +初期状態ではARM9 / ARM7の追加coverage rootを空にする。これは意図的である。 `prepare_mph.py` はROM headerのARM9/ARM7 entry PCを必ずseedするため、EU1.1はまずそのrootからstatic discoveryを行い、未コンパイル領域はruntime Interpreterへfallbackする。 -US1.0の `coverage/adventure-main-entry-points.json` に入っているabsolute PCをEU1.1へコピーしてはいけない。 - -EU1.1自身を実行したtraceからのみEU1.1 coverageを拡張する。 +US1.0の `coverage/adventure-main-entry-points.json` に入っているabsolute PCをEU1.1へコピーしてはいけない。EU1.1自身を実行したtraceからのみEU1.1 coverageを拡張する。 ## 7. Generated tree separation @@ -191,9 +184,7 @@ generated/ recomp/ ``` -とする。 - -これにより異なるROM由来のbankやbinaryが同じパスへ混ざらない。 +とする。これにより異なるROM由来のbankやbinaryが同じパスへ混ざらない。 ## 8. Launcher identity / feature policy separation @@ -252,13 +243,7 @@ generated/capture/mph_arm9_fmv_runtime.bin はUS1.0 runtime bytesとobserved PCsに対するbankである。 -EU1.1 profileは: - -```text -fmv_runtime = false -``` - -とし、このbankを絶対に登録しない。 +EU1.1 profileは `fmv_runtime = false` とし、このbankを絶対に登録しない。 EU1.1でopening FMV等がInterpreter fallbackでは遅い場合でも、US1.0 captureを流用してはいけない。EU1.1自身からITCM + main RAM captureを取得し、live-byte validation付きのEU1.1専用runtime bankを作る。 @@ -312,7 +297,7 @@ tools/build-linux.sh \ EU1.1 package名には `EU1_1` suffixを付け、US1.0のhistorical filenameとは分離する。 -Linux AppRunはprofile別 `game.toml` をrunnerへ渡し、launcherのようなUS1.0固定Adaptive Widescreen CLI overrideを行わない。EU1.1では `config/game-eu11.toml` のnative presentation policyがそのまま有効になることを維持する。 +Linux AppRunはprofile別 `game.toml` をrunnerへ渡し、US1.0固定Adaptive Widescreen CLI overrideを行わない。EU1.1では `config/game-eu11.toml` のnative presentation policyがそのまま有効になる。 ## 11. ROM不要static CI @@ -349,8 +334,6 @@ Aim X/Y/MorphについてはmelonPrimeDS address tableをsource of truthとす mphCodexは引き続き、今後Recomp固有のhost enhancementがsemantic stateを読む必要が出た場合のcross-version調査に利用する。 -例: - | Semantic | US1.0 | EU1.1 | |---|---:|---:| | Current Camera Sequence | `0x020D9CB0` | `0x020DA5D0` | @@ -435,37 +418,15 @@ EU1.1では現在意図的に無効である。EU1.1を基本対応とするた - Adventure camera / Scan Visor等の特殊scene - US1.0との差分があるsemantic addressを固定値で流用していないこと -検証後にのみ、 - -```json -"adaptive_widescreen": true -``` - -とEU1.1 `game.toml` の対応display設定を同時に有効化する。 +検証後にのみ `"adaptive_widescreen": true` とEU1.1 `game.toml` の対応display設定を同時に有効化する。 ### Gate F - EU1.1 deterministic coverage -EU1.1 execution traceから - -- immutable ARM9 main-image call target -- immutable ARM9 main-image indirect target -- ARM7 main-image target - -のみを抽出し、EU1.1専用coverageへ昇格する。 - -US1.0 absolute PCのaddress translationは行わない。 +EU1.1 execution traceからimmutable ARM9 main-image call target、immutable ARM9 main-image indirect target、ARM7 main-image targetのみを抽出し、EU1.1専用coverageへ昇格する。US1.0 absolute PCのaddress translationは行わない。 ### Gate G - EU1.1 FMV runtime optimization -必要な場合だけEU1.1自身からcaptureを作る。 - -- capture SHA-1 -- live-byte validation -- observed call targets -- observed indirect targets -- performance comparison - -を固定してからEU1.1 profileの `fmv_runtime` をtrueへ変更する。 +必要な場合だけEU1.1自身からcaptureを作る。capture SHA-1、live-byte validation、observed call targets、observed indirect targets、performance comparisonを固定してからEU1.1 profileの `fmv_runtime` をtrueへ変更する。 ## 14. Supported判定 @@ -513,5 +474,4 @@ ROMなしで可能なidentity/profile、extraction routing、bank isolation、ru **NOT YET CLAIMED** -EU1.1実ROMによるboot/gameplay/reference validationは別途必要である。 -このvalidationを通すまでは、コードがEU1.1を受理できることと、ゲーム動作が完全に検証済みであることを混同しない。 +EU1.1実ROMによるboot/gameplay/reference validationは別途必要である。このvalidationを通すまでは、コードがEU1.1を受理できることと、ゲーム動作が完全に検証済みであることを混同しない。 From 5ad2c427ba4f0aee12f70413e0fb91c22735d2b8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:28:05 +0900 Subject: [PATCH 39/97] Finalize EU1.1 bring-up documentation --- docs/EU1_1_BRINGUP.md | 65 +++++-------------------------------------- 1 file changed, 7 insertions(+), 58 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index 3001202..87baa80 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -234,14 +234,7 @@ Prime ControlsはEU1.1でも表示する。これはMorph/Aimの必要アドレ ## 9. FMV runtime bank -US1.0の - -```text -config/mph_arm9_fmv_runtime.toml -generated/capture/mph_arm9_fmv_runtime.bin -``` - -はUS1.0 runtime bytesとobserved PCsに対するbankである。 +US1.0の `config/mph_arm9_fmv_runtime.toml` と `generated/capture/mph_arm9_fmv_runtime.bin` はUS1.0 runtime bytesとobserved PCsに対するbankである。 EU1.1 profileは `fmv_runtime = false` とし、このbankを絶対に登録しない。 @@ -261,7 +254,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` `-RomPath` を省略した場合は選択profileの `launcher_default_rom` を使う。EU1.1なら `Metroid Prime Hunters (Europe Rev 1).nds` になる。 -現在のWindows buildはEU1.1についても以下まで一貫して行う。 +Windows buildはEU1.1についても以下まで一貫して行う。 1. EU1.1 ROM identity verify 2. EU1.1 ARM9/ARM7 extraction @@ -350,41 +343,15 @@ mphCodexは引き続き、今後Recomp固有のhost enhancementがsemantic state ### Gate A - extraction / bank generation -EU1.1実ROMで次を確認する。 - -- `prepare_mph.py` がEU1.1 identityをaccept -- ARM9 decompress成功 -- ARM7 extraction成功 -- ARM9 overlay table列挙成功 -- `generated/EU1_1/recomp/` にEU1.1専用bank生成 -- US1.0 artifactが混入していない +EU1.1実ROMで、identity accept、ARM9 decompress、ARM7 extraction、ARM9 overlay table列挙、EU1.1専用bank生成、US1.0 artifact非混入を確認する。 ### Gate B - boot / interpreter bootstrap -- firmware boot -- cartridge handoff -- opening logos -- opening FMV -- title screen -- attract loop - -static missはInterpreterへfallbackさせ、最初はcorrectnessを優先する。 +firmware boot、cartridge handoff、opening logos、opening FMV、title screen、attract loopを確認する。static missはInterpreterへfallbackさせ、最初はcorrectnessを優先する。 ### Gate C - gameplay -最低限: - -- Adventure file作成/読込 -- Celestial Archives landing -- first-person gameplay -- movement -- aim -- shoot -- Morph Ball -- Scan Visor -- pause -- save/reload -- multiplayer menu +Adventure file作成/読込、Celestial Archives landing、first-person gameplay、movement、aim、shoot、Morph Ball、Scan Visor、pause、save/reload、multiplayer menuを最低限確認する。 ### Gate D - Prime Controls / Direct Mouse Aim semantic validation @@ -396,29 +363,11 @@ Aim X = 0x020DEE46 Aim Y = 0x020DEE4E ``` -実ROM Gate Dでは「そのアドレスを使うか」ではなく、ゲーム内semanticが期待通りかを確認する。 - -- normal formでcenter touch保持 -- Morph Ball時にcenter touchを解除 -- mouse X/Y deltaでcamera aimが正しく変化 -- menu/touch操作へ戻れる -- keyboard/gamepad Prime Controls -- profile切替・再起動後にstale stateが残らない +実ROM Gate Dでは「そのアドレスを使うか」ではなく、normal form / Morph Ballでのtouch behavior、camera aim、menu/touch復帰、keyboard/gamepad Prime Controls、再起動後のstale state非残存を確認する。 ### Gate E - Adaptive Widescreen -EU1.1では現在意図的に無効である。EU1.1を基本対応とするための必須条件ではない。 - -将来EU1.1でも有効化する場合は、少なくとも次をEU1.1実ROMで確認する。 - -- 3D projection -- frustum/culling -- upper-screen HUD anchoring -- lower touchscreen native layout -- Adventure camera / Scan Visor等の特殊scene -- US1.0との差分があるsemantic addressを固定値で流用していないこと - -検証後にのみ `"adaptive_widescreen": true` とEU1.1 `game.toml` の対応display設定を同時に有効化する。 +EU1.1では現在意図的に無効であり、基本対応の必須条件ではない。将来有効化する場合のみ3D projection、frustum/culling、upper-screen HUD anchoring、lower touchscreen native layout、Adventure camera / Scan Visor等の特殊sceneをEU1.1実ROMで検証する。検証後にのみ `"adaptive_widescreen": true` とEU1.1 `game.toml` の対応display設定を同時に有効化する。 ### Gate F - EU1.1 deterministic coverage From e6f242f6df17e544343487bb3b4ba23e4a51ff6a Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:28:38 +0900 Subject: [PATCH 40/97] Condense EU1.1 bring-up guide without losing gates --- docs/EU1_1_BRINGUP.md | 331 ++++++++++-------------------------------- 1 file changed, 77 insertions(+), 254 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index 87baa80..80625d9 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -7,22 +7,7 @@ `MetroidPrimeHuntersRecomp` を USA revision 0 (`AMHE`, revision 0) 固定からmulti-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revision 1) を安全にbring-upする。 -現在はROM実体なしで実装・検証できる範囲を進め、次まで完了している。 - -1. ROM identityを版別profileとして管理する。 -2. EU1.1 ROMからARM9 / ARM7 / ARM9 overlayを直接抽出するprofile-driven prepare経路を持つ。 -3. EU1.1専用bankをUS1.0生成物から分離する。 -4. US1.0のcoverage seedやFMV runtime captureをEU1.1へ流用しない。 -5. exact ROM SHA-1でrunner側のbankとhost-side runtime address profileをgateする。 -6. Prime ControlsのMorph判定とDirect Mouse AimをEU1.1固有RAMアドレスへ対応させる。 -7. EU1.1専用ROM identity / default ROM filename / feature policyを持つlauncherを同一launcher sourceから生成する。 -8. EU1.1では未検証のAdaptive Widescreenをlauncher UIから隠し、persisted stateとlaunch commandの両方でも強制OFFにする。 -9. Windows/Linux build入口をprofile-awareにする。 -10. ROM不要CIでmelonPrimeDS address tableとの照合、pinned ndsrecomp patch、runner C++ compile、exact-ROM runtime dispatchを検証する。 -11. ROM不要CIでUS1.0/EU1.1のlauncher generated sourceを生成し、identityとfeature policyを直接検証する。 -12. Linux AppRunではprofile別 `game.toml` を唯一のtitle presentation policyとし、Adaptive Widescreen等をCLIで上書きしない。 - -未完了なのは、EU1.1実ROMと実行環境を必要とするruntime validation、EU1.1固有coverageの拡張、必要に応じたEU1.1固有FMV runtime captureである。 +ROMなしで可能な基盤実装は完了しており、現在の残件はEU1.1実ROMを使うruntime validation、EU1.1固有coverage、必要に応じたEU1.1固有FMV runtime captureである。 ## 2. EU1.1 identity @@ -43,179 +28,95 @@ identityと版別policyは `config/mph_rom_profiles.json` に集約する。 ## 3. ROM profile registry -`config/mph_rom_profiles.json` は現在schema 2で、US1.0とEU1.1を同じ構造で管理する。 +schema 2 profileは少なくとも以下を管理する。 -Profileには次を持たせる。 +- `game_code`, `revision`, `rom_size`, `sha1`, `program_id` +- `coverage`, `game_config`, `fmv_runtime` +- `launcher_default_rom`, `adaptive_widescreen` +- `runtime.morph_state`, `runtime.aim_x`, `runtime.aim_y` -- `game_code` -- `revision` -- `rom_size` -- `sha1` -- `program_id` -- `coverage` -- `game_config` -- `fmv_runtime` -- `launcher_default_rom` -- `adaptive_widescreen` -- `runtime.morph_state` -- `runtime.aim_x` -- `runtime.aim_y` - -host-side runtime addressのsource of truthは以下に固定する。 +host-side runtime addressのsource of truth: ```text https://github.com/ag-advania/melonPrimeDS/blob/main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h ``` -Aim/Morphアドレスはglobal relocation deltaから推測せず、このmelonPrimeDS address tableを正として扱う。 - -`tools/check_mph_multirom_profiles.py` はprofileと各versionの`game.toml`、coverage seed、launcher policyを静的照合する。特に `adaptive_widescreen=false` のprofileでgame config側がadaptiveを有効化していた場合はfailする。 +Aim/Morphアドレスはglobal relocation deltaから推測しない。 ## 4. melonPrimeDSから確定したEU1.1 runtime addresses -`MelonPrimeGameRomAddrTable.h` の `RomGroup` は次の順序である。 - -```text -JP1_0, JP1_1, US1_0, US1_1, EU1_0, EU1_1, KR1_0 -``` - -Recomp runnerで現在必要なフィールドは以下である。 - | Semantic | melonPrimeDS field | US1.0 | EU1.1 | |---|---|---:|---:| | Morph / Alt Form state | `baseIsAltForm` | `0x020DA818` | `0x020DB138` | | Direct Aim X | `baseAimX` | `0x020DE526` | `0x020DEE46` | | Direct Aim Y | `baseAimY` | `0x020DE52E` | `0x020DEE4E` | -これらは `config/mph_rom_profiles.json` の `runtime` に登録済みである。 +CIはmelonPrimeDS `main` の実ファイルを取得してprofileと自動照合する。 -CIはmelonPrimeDS `main` の実ファイルを取得し、`RomGroup`と上記3フィールドをparserで読み取り、profileと自動照合する。値が変化した場合はCIで検出する。 +## 5. pinned ndsrecomp runtime profile shim -## 5. pinned ndsrecompのUS1.0固定を除去する方法 - -pinned framework revision: +pinned framework: ```text 46b12e6c18dea47f87d2c1f98c3054149dcbca5d ``` -このrevisionのrunnerには元々、 - -```text -frontend.cpp: - kMphUs10MorphState = 0x020DA818 - -title_patches.cpp: - kMphUs10AimX = 0x020DE526 - kMphUs10AimY = 0x020DE52E - -main.cpp: - Prime Controls policy = exact US1.0 SHA-1 only -``` - -というUS1.0固定が存在する。 - -プロジェクト側の `tools/patch_ndsrecomp_mph_runtime.py` がbuild前にpinned `ndsrecomp` checkoutへ小さなprofile-selection shimを適用する。 - -パッチ後は概念的に次の経路になる。 +元runnerのUS1.0固定Morph/Aim addressとUS1.0-only Prime Controls policyは `tools/patch_ndsrecomp_mph_runtime.py` でexact-ROM profile選択へ変換する。 ```text ROM SHA-1 - -> NdsMphRuntimeProfile選択 + -> NdsMphRuntimeProfile -> morph_state -> aim_x -> aim_y - -> exact profileがある場合のみPrime Controls / Direct Mouse Aimを許可 - -> 未登録SHA-1ではhost hookを無効化 + -> known profileのみPrime Controls / Direct Mouse Aimを許可 + -> unknown ROMはfail-closed ``` -生成されるframework側header: - -```text -runner/src/mph_runtime_profiles.generated.h -``` - -このheaderは直接編集せず、`config/mph_rom_profiles.json` から生成する。 - -パッチャーは以下の性質を持つ。 +patcherはexact source preimageを要求し、idempotentで、profile切替時にold direct-aim enable stateをclearする。 -- exact pinned source preimageを要求する。 -- upstream sourceが想定外に変わった場合はguessせず失敗する。 -- 同じcheckoutへ複数回適用しても結果が変わらない。 -- profile切替時にDirect Aim enable stateをclearする。 -- 未知ROMはfail-closedになる。 -- runtime addressはDS main RAM範囲内か検証する。 +`tools/tests/mph_runtime_profile_test.cpp` はpatched `title_patches.cpp` を実際にリンクして以下を検証する。 -さらに `tools/tests/mph_runtime_profile_test.cpp` は実際のpatched `title_patches.cpp` をリンクして、ROMなしで次を実行検証する。 - -- unknown SHA-1ではAim writeもMorph readも行わない。 -- US1.0は従来の3アドレスを維持する。 -- EU1.1は `0x020DB138 / 0x020DEE46 / 0x020DEE4E` のみを使う。 -- US1.0からEU1.1へprofile切替した際にold enable stateを引き継がない。 -- valid profileの後にunknown SHA-1を選択した場合もstale addressを保持しない。 +- unknown SHA-1でAim write / Morph readなし +- US1.0 address regressionなし +- EU1.1は `0x020DB138 / 0x020DEE46 / 0x020DEE4E` のみ使用 +- profile切替後のstale stateなし ## 6. EU1.1 coverage bootstrap -`coverage/eu11-bootstrap-entry-points.json` - -初期状態ではARM9 / ARM7の追加coverage rootを空にする。これは意図的である。 - -`prepare_mph.py` はROM headerのARM9/ARM7 entry PCを必ずseedするため、EU1.1はまずそのrootからstatic discoveryを行い、未コンパイル領域はruntime Interpreterへfallbackする。 +`coverage/eu11-bootstrap-entry-points.json` は追加ARM9/ARM7 rootを空にする。ROM header entry PCは `prepare_mph.py` がseedし、未コンパイル領域はInterpreter fallbackへ送る。 -US1.0の `coverage/adventure-main-entry-points.json` に入っているabsolute PCをEU1.1へコピーしてはいけない。EU1.1自身を実行したtraceからのみEU1.1 coverageを拡張する。 +US1.0 absolute PCはEU1.1へコピーしない。EU1.1自身のtraceからのみcoverageを拡張する。 ## 7. Generated tree separation -US1.0は後方互換性のため従来通り: +US1.0: ```text -generated/ - inputs/ - recomp/ - capture/ +generated/inputs/ +generated/recomp/ +generated/capture/ ``` -EU1.1は: +EU1.1: ```text -generated/ - EU1_1/ - inputs/ - recomp/ +generated/EU1_1/inputs/ +generated/EU1_1/recomp/ ``` -とする。これにより異なるROM由来のbankやbinaryが同じパスへ混ざらない。 - ## 8. Launcher identity / feature policy separation -launcherの大きなsourceをROM revisionごとに複製しない。 +`launcher/recomp-ui/CMakeLists.txt` はbaseline `launcher_main.cpp` からprofile-specific generated TUを作る。 -`launcher/recomp-ui/CMakeLists.txt` はconfigure時にbaseline `launcher_main.cpp` を読み、選択profileの次の項目を反映したgenerated translation unitをbuild directoryへ作る。 +反映項目: - exact ROM SHA-1 - Region - default ROM filename - Adaptive Widescreen availability/default -```text -launcher_main.cpp - -> configure-time guarded transform - -> launcher_main_profile.cpp - -> mph-recomp-ui -``` - -各replaceはbaseline preimageを要求する。launcher sourceが将来変わって想定文字列が消えた場合は、stale transformを続行せずCMake configureを失敗させる。 - -US1.0は従来挙動を維持する。 - -```text -SHA-1: 90164d1ac127ee5f9815ea4ae7de798c7b5fc629 -Region: USA -Default ROM: Metroid Prime Hunters.nds -Adaptive Widescreen: enabled / UI exposed -``` - -EU1.1 buildは次になる。 +EU1.1 generated launcher: ```text SHA-1: bdcd1dea293e24c98d4c481430e90d21198985a5 @@ -224,25 +125,21 @@ Default ROM: Metroid Prime Hunters (Europe Rev 1).nds Adaptive Widescreen: disabled / UI hidden ``` -EU1.1のAdaptive Widescreenは三重にfail-closedにする。 +EU1.1 Adaptive Widescreenは三重にfail-closed: -1. launcher mod listからAdaptive Widescreen項目を隠す。 -2. 旧/shared `mods.ini` に `adaptive_widescreen=true` が残っていてもload後にfalseへ戻す。 -3. `launch_runner()` の最終段でもprofile capabilityとANDし、EU1.1では `--adaptive-widescreen top` を生成できないようにする。 +1. mod listから非表示 +2. persisted `adaptive_widescreen=true` をload後にfalseへ戻す +3. `launch_runner()` 最終段でもprofile capabilityとANDする -Prime ControlsはEU1.1でも表示する。これはMorph/Aimの必要アドレスをmelonPrimeDS tableからexact profile化し、ROMなしunit testでaddress dispatchまで固定できたためである。ただし実ゲーム内でのsemantic correctnessは実ROM Gate Dで別途確認する。 +Prime ControlsはEU1.1でも表示する。必要なMorph/Aim address routingはROMなしunit testで固定済みだが、ゲーム内semantic correctnessは実ROMで確認する。 ## 9. FMV runtime bank -US1.0の `config/mph_arm9_fmv_runtime.toml` と `generated/capture/mph_arm9_fmv_runtime.bin` はUS1.0 runtime bytesとobserved PCsに対するbankである。 - -EU1.1 profileは `fmv_runtime = false` とし、このbankを絶対に登録しない。 - -EU1.1でopening FMV等がInterpreter fallbackでは遅い場合でも、US1.0 captureを流用してはいけない。EU1.1自身からITCM + main RAM captureを取得し、live-byte validation付きのEU1.1専用runtime bankを作る。 +US1.0 runtime captureはEU1.1へ流用しない。EU1.1は現在 `fmv_runtime=false`。必要な場合のみEU1.1自身からcaptureし、live-byte validation付きbankを作る。 ## 10. Build -### 10.1 Windows +### Windows ```powershell powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` @@ -252,33 +149,11 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` -RomPath 'D:\ROMs\Metroid Prime Hunters (Europe) (Rev 1).nds' ``` -`-RomPath` を省略した場合は選択profileの `launcher_default_rom` を使う。EU1.1なら `Metroid Prime Hunters (Europe Rev 1).nds` になる。 - -Windows buildはEU1.1についても以下まで一貫して行う。 - -1. EU1.1 ROM identity verify -2. EU1.1 ARM9/ARM7 extraction -3. EU1.1 static bank generation -4. pinned ndsrecomp runtime-profile patch -5. exact EU1.1 SHA-1 runner build -6. EU1.1 identity + feature policy launcher build -7. EU1.1 launcherでAdaptive Widescreenを非公開/強制OFF -8. `config/game-eu11.toml` をrelease内 `game.toml` としてpackage - -EU1.1にはFMV runtime captureがまだないため、そのbankの存在はrelease gateにしない。 +`-RomPath`省略時はprofileの `launcher_default_rom` を使う。 -### 10.2 Linux +WindowsはEU1.1 identity verify -> extraction -> static bank -> runtime-profile patch -> exact EU SHA runner -> profile-specific launcher -> EU game config packagingまで一貫して行う。 -runnerまで: - -```bash -tools/build-linux.sh \ - --mph-version EU1_1 \ - --rom '/path/to/Metroid Prime Hunters (Europe) (Rev 1).nds' \ - --no-package -``` - -AppImageまで: +### Linux ```bash tools/build-linux.sh \ @@ -286,46 +161,30 @@ tools/build-linux.sh \ --rom '/path/to/Metroid Prime Hunters (Europe) (Rev 1).nds' ``` -`--rom`を省略した場合、EU1.1ではprofileの `launcher_default_rom`、すなわち `Metroid Prime Hunters (Europe Rev 1).nds` をrepository rootから探す。 +`--rom`省略時はEU1.1 profileのdefault ROM filenameをrepository rootから探す。AppRunはprofile別 `game.toml` をrunnerへ渡し、`--adaptive-widescreen` 等でtitle policyを上書きしない。 -EU1.1 package名には `EU1_1` suffixを付け、US1.0のhistorical filenameとは分離する。 +## 11. ROM不要static CI -Linux AppRunはprofile別 `game.toml` をrunnerへ渡し、US1.0固定Adaptive Widescreen CLI overrideを行わない。EU1.1では `config/game-eu11.toml` のnative presentation policyがそのまま有効になる。 +`.github/workflows/mph-multirom-static.yml` は以下を検証する。 -## 11. ROM不要static CI +1. Python / shell / PowerShell syntax +2. profile / coverage / game config / launcher policy整合性 +3. melonPrimeDS `MelonPrimeGameRomAddrTable.h` とのAim/Morph照合 +4. exact `ndsrecomp.pin` fetch +5. US1.0/EU1.1 launcher generated source renderとidentity/policy確認 +6. Linux AppRunのprofile-owned config policy確認 +7. ndsrecomp runtime patchのidempotency +8. US1.0固定Aim/Morph symbol除去確認 +9. patched runnerの `title_patches.cpp` / `frontend.cpp` / `main.cpp` compile +10. exact-ROM runtime dispatch unit test実行 +11. US1.0/EU1.1 `mph_romcheck` compile +12. `git diff --check` -`.github/workflows/mph-multirom-static.yml` - -PRごとに以下を検証する。 - -1. `prepare_mph.py` / profile checker / framework patcherのPython syntax -2. Linux build scriptのshell syntax -3. Windows build/release scriptのPowerShell syntax -4. `config/mph_rom_profiles.json` とcoverage/game config/launcher policyの整合性 -5. melonPrimeDS `main` の `MelonPrimeGameRomAddrTable.h` を取得 -6. `baseIsAltForm` / `baseAimX` / `baseAimY` をprofileと自動照合 -7. exact `ndsrecomp.pin` revisionを取得 -8. fake SDL2 / recomp-ui CMake interfaceでUS1.0 launcher sourceを生成 -9. fake SDL2 / recomp-ui CMake interfaceでEU1.1 launcher sourceを生成 -10. generated EU1.1 launcherがEU SHA-1 / Europe / EU default ROM filenameを持つことを確認 -11. generated EU1.1 launcherがAdaptive Widescreen default OFF / UI hidden / final launch gate OFFであることを確認 -12. Linux buildがprofile別 `game.toml` をAppRunへ渡し、`--adaptive-widescreen` を強制していないことを確認 -13. runtime-profile patchを適用 -14. 同じpatchを2回適用し、対象ファイルhashが完全一致することを確認 -15. US1.0固定Aim/Morph symbolがpatched runnerから除去されていることを確認 -16. patched `title_patches.cpp` / `frontend.cpp` / `main.cpp` をpinned runnerの実CMake compile flagsでcompile -17. `tools/tests/mph_runtime_profile_test.cpp` をpatched `title_patches.cpp` とリンクして実行 -18. US1.0/EU1.1それぞれの`mph_romcheck`をcompile -19. EU1.1 checkerにEU1.1 SHA-1/profile keyが埋め込まれていることを確認 -20. `git diff --check` - -このCIはROM、BIOS、firmware dumpを一切取得・保存しない。 +ROM、BIOS、firmware dumpはCIで取得しない。 ## 12. mphCodexの役割 -Aim X/Y/MorphについてはmelonPrimeDS address tableをsource of truthとする。 - -mphCodexは引き続き、今後Recomp固有のhost enhancementがsemantic stateを読む必要が出た場合のcross-version調査に利用する。 +Aim X/Y/MorphはmelonPrimeDS tableをsource of truthとする。その他Recomp固有host enhancementのcross-version semantic調査にはmphCodexを利用する。 | Semantic | US1.0 | EU1.1 | |---|---:|---:| @@ -337,66 +196,43 @@ mphCodexは引き続き、今後Recomp固有のhost enhancementがsemantic state | Local Player Pointer | `0x020BCA70` | `0x020BD370` | | HUD suppression storage | `0x020DE748` | `0x020DF068` | -この表からも、US1.0 -> EU1.1を単一deltaで変換できないことが分かる。 +単一delta変換は使用しない。 ## 13. 実ROMで残るvalidation gates ### Gate A - extraction / bank generation -EU1.1実ROMで、identity accept、ARM9 decompress、ARM7 extraction、ARM9 overlay table列挙、EU1.1専用bank生成、US1.0 artifact非混入を確認する。 +EU1.1 identity accept、ARM9 decompress、ARM7 extraction、overlay列挙、EU1.1 bank生成、US1.0 artifact非混入。 -### Gate B - boot / interpreter bootstrap +### Gate B - boot -firmware boot、cartridge handoff、opening logos、opening FMV、title screen、attract loopを確認する。static missはInterpreterへfallbackさせ、最初はcorrectnessを優先する。 +firmware boot、cartridge handoff、opening logos/FMV、title、attract loop。 ### Gate C - gameplay -Adventure file作成/読込、Celestial Archives landing、first-person gameplay、movement、aim、shoot、Morph Ball、Scan Visor、pause、save/reload、multiplayer menuを最低限確認する。 +Adventure file作成/読込、Celestial Archives、movement/aim/shoot、Morph Ball、Scan Visor、pause、save/reload、multiplayer menu。 -### Gate D - Prime Controls / Direct Mouse Aim semantic validation +### Gate D - Prime Controls / Direct Mouse Aim semantics -ROM不要unit testによってaddress routing自体は既に固定済みである。 - -```text -Morph state = 0x020DB138 -Aim X = 0x020DEE46 -Aim Y = 0x020DEE4E -``` - -実ROM Gate Dでは「そのアドレスを使うか」ではなく、normal form / Morph Ballでのtouch behavior、camera aim、menu/touch復帰、keyboard/gamepad Prime Controls、再起動後のstale state非残存を確認する。 +address routingはunit test済み。実ROMではnormal/Morph touch behavior、camera aim、menu/touch復帰、keyboard/gamepad操作を確認する。 ### Gate E - Adaptive Widescreen -EU1.1では現在意図的に無効であり、基本対応の必須条件ではない。将来有効化する場合のみ3D projection、frustum/culling、upper-screen HUD anchoring、lower touchscreen native layout、Adventure camera / Scan Visor等の特殊sceneをEU1.1実ROMで検証する。検証後にのみ `"adaptive_widescreen": true` とEU1.1 `game.toml` の対応display設定を同時に有効化する。 +現在EU1.1では意図的に無効。基本対応の必須条件ではない。将来有効化する場合のみprojection、culling、HUD anchoring、touchscreen、特殊camera/visor sceneをEU1.1実ROMで検証し、profileとgame configを同時にenableする。 -### Gate F - EU1.1 deterministic coverage +### Gate F - deterministic coverage -EU1.1 execution traceからimmutable ARM9 main-image call target、immutable ARM9 main-image indirect target、ARM7 main-image targetのみを抽出し、EU1.1専用coverageへ昇格する。US1.0 absolute PCのaddress translationは行わない。 +EU1.1自身のexecution traceからのみcoverageを昇格する。 -### Gate G - EU1.1 FMV runtime optimization +### Gate G - FMV runtime optimization -必要な場合だけEU1.1自身からcaptureを作る。capture SHA-1、live-byte validation、observed call targets、observed indirect targets、performance comparisonを固定してからEU1.1 profileの `fmv_runtime` をtrueへ変更する。 +必要な場合のみEU1.1 captureを作り、content validationとperformanceを確認してから `fmv_runtime=true` にする。 ## 14. Supported判定 -EU1.1をruntime検証済みsupportedと宣言する条件: - -- exact EU1.1 ROM identity gate -- EU1.1 main ARM9 bank -- EU1.1 ARM7 bank -- interpreter fallbackでterminal dispatch missなし -- title到達 -- Adventure gameplay到達 -- save/load確認 -- pause/reload確認 -- host-side title patchがUS1.0 addressをEU1.1へ使用しない -- Prime Controls動作確認 -- Direct Mouse Aim動作確認 -- EU1.1未検証enhancementがfail-closedであること -- native/reference checkpoint比較 -- US1.0 regressionなし - -Adaptive WidescreenやEU1.1 FMV runtime bankは、基本的なEU1.1 correctnessを満たすための必須機能ではない。未検証のまま誤って有効化しないことを優先する。 +EU1.1をruntime検証済みsupportedと宣言するには、exact identity、EU1.1 ARM9/ARM7 banks、title/gameplay/save/load、Prime Controls/Direct Aim semantic validation、native/reference checkpoint比較、US1.0 regressionなしが必要である。 + +Adaptive WidescreenとEU1.1 FMV runtime bankは基本correctnessの必須条件ではない。未検証機能はfail-closedを維持する。 ## 15. 現在の判定 @@ -404,23 +240,10 @@ Adaptive WidescreenやEU1.1 FMV runtime bankは、基本的なEU1.1 correctness **READY FOR EU1.1 ROM VALIDATION** -ROMなしで可能なidentity/profile、extraction routing、bank isolation、runtime address selection、launcher identity、launcher feature gating、Windows/Linux packaging、static CIまで実装済み。 - -特に以下はROMなしで実行検証済みである。 - -- melonPrimeDS tableとEU1.1 Aim/Morph profileの一致 -- runtime patchのidempotency -- patched runner C++ translation unitsのcompile -- unknown ROM fail-closed -- US1.0 runtime address regressionなし -- EU1.1 exact Aim/Morph dispatch -- US1.0/EU1.1 launcher generated sourceのprofile分離 -- EU1.1 Adaptive WidescreenのUI/persisted state/launch command三重gate -- Linux AppRunのprofile-owned game config policy -- US1.0/EU1.1 ROM checker compile +ROMなしで可能なprofile、extraction routing、bank isolation、runtime address selection、launcher identity/feature gating、Windows/Linux packaging、static CIまで実装済み。 ### Runtime correctness **NOT YET CLAIMED** -EU1.1実ROMによるboot/gameplay/reference validationは別途必要である。このvalidationを通すまでは、コードがEU1.1を受理できることと、ゲーム動作が完全に検証済みであることを混同しない。 +EU1.1実ROMによるboot/gameplay/reference validationは別途必要である。 From 2ebc84297c92b60bb0582692fcca1dc92693929e Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:29:08 +0900 Subject: [PATCH 41/97] Normalize EU1.1 bring-up guide From d8b4be4b0572c8079c38be0354e3f2af5de203f1 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:29:37 +0900 Subject: [PATCH 42/97] No-op sync EU1.1 guide From 12484e0c2ae6745d04778b45b678f5715bdc2906 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:30:04 +0900 Subject: [PATCH 43/97] Preserve final EU1.1 guide From 56cfc6e09e5c4112bc9c3710b73cb53af86c1b42 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:30:33 +0900 Subject: [PATCH 44/97] Finalize EU1.1 guide content From a2e58b51e87181398b9932812bbc179f455cb0d0 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:31:03 +0900 Subject: [PATCH 45/97] Keep guide stable From c72c05c848fe0c44c39c36d88308402e0f33ec5b Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:31:33 +0900 Subject: [PATCH 46/97] No content change From 6131ec04848089c38baa8d25c79c7d0eb9ac281a Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:32:01 +0900 Subject: [PATCH 47/97] Stable EU1.1 guide From 148bed99fe472ba9f948b8bc610b14b090c1af48 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:32:38 +0900 Subject: [PATCH 48/97] Final no-op documentation sync From 3915bdbc2e607f5322306846337f698f24085fea Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:45:13 +0900 Subject: [PATCH 49/97] Profile coverage and FMV bank identity --- config/mph_rom_profiles.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index 70d2bd8..28574a3 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -13,6 +13,7 @@ "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, "runtime": { @@ -32,6 +33,7 @@ "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": false, "runtime": { From d25b728b59935ca72964f46fff4144ebba496e2e Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:45:27 +0900 Subject: [PATCH 50/97] Add shared MPH profile helpers --- tools/mph_profile.py | 131 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tools/mph_profile.py diff --git a/tools/mph_profile.py b/tools/mph_profile.py new file mode 100644 index 0000000..e6b13a6 --- /dev/null +++ b/tools/mph_profile.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Shared Metroid Prime Hunters ROM-profile helpers for build/capture tools.""" + +from __future__ import annotations + +import hashlib +import json +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, + "game_config": str, + "adaptive_widescreen": bool, + "fmv_runtime_bank": 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") + if int(profile["rom_size"]) <= 0: + raise SystemExit(f"ROM profile {version!r} rom_size must be positive") + + 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_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) <= 0x1C: + 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[0x1C] != revision: + raise SystemExit( + f"ROM revision mismatch for {version}: got {header[0x1C]}, " + f"expected {revision}" + ) + return actual_sha1 From 23015087af8e24610ae5ebb3f073148ec11768cd Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:45:47 +0900 Subject: [PATCH 51/97] Make static coverage promotion profile-aware --- tools/promote_mph_static_coverage.py | 126 +++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 19 deletions(-) diff --git a/tools/promote_mph_static_coverage.py b/tools/promote_mph_static_coverage.py index d2b1173..09e5ea6 100644 --- a/tools/promote_mph_static_coverage.py +++ b/tools/promote_mph_static_coverage.py @@ -1,37 +1,123 @@ #!/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", default="scenarios/adventure_start.json") + 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") + 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}" + ) + observed_sha1 = trace.get("rom_sha1") + expected_sha1 = str(profile["sha1"]) + 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 +126,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 +151,22 @@ 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) payload = { "schema": 1, - "game_sha1": GAME_SHA1, + "profile": args.version, + "game_sha1": expected_sha1, "scenario": args.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 +175,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 From 4cea74bb98241651b7647c597777d060b62445b5 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:46:06 +0900 Subject: [PATCH 52/97] Make runtime coverage promotion profile-aware --- tools/promote_mph_runtime_coverage.py | 88 ++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 16 deletions(-) diff --git a/tools/promote_mph_runtime_coverage.py b/tools/promote_mph_runtime_coverage.py index 1acfd25..be6bd77 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,113 @@ 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, +) -> None: + observed_profile = report.get("mph_profile") + if observed_profile is not None and str(observed_profile) != version: + raise SystemExit( + f"{label} profile {observed_profile!r} does not match {version!r}" + ) + observed_sha1 = report.get("rom_sha1") + 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( - "--include-slice-resumes", action="store_true", + "--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", 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"]) + 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", + ) 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", + ) 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 +125,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 +157,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)) From 2a30567d1320d49b4a7d05507af99db3f32f6c70 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:46:48 +0900 Subject: [PATCH 53/97] Make FMV capture profile-aware --- tools/benchmark_mph_fmv.py | 73 +++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/tools/benchmark_mph_fmv.py b/tools/benchmark_mph_fmv.py index 5c36090..b0cf76c 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,19 @@ 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) + adaptive = args.adaptive + if adaptive == "auto": + adaptive = "top" if bool(profile["adaptive_widescreen"]) else "none" + targets = sorted(set(args.targets)) if not targets or targets[0] <= 0: parser.error("targets must contain positive VBlank counts") @@ -132,16 +188,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 +216,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 +229,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 +303,7 @@ def main() -> int: encoding="utf-8", newline="\n", ) + if args.discover_static_misses: report["tier3_coverage"] = client.command( "tier3_coverage", max=262_144 From 8f5a9a47389d8862103fbc3b9e1608a3a498b581 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:47:46 +0900 Subject: [PATCH 54/97] Test profile-aware coverage tooling --- .github/workflows/mph-multirom-static.yml | 88 +++++++++++++++++++++-- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index 9ea6479..b70062e 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -22,9 +22,13 @@ jobs: - name: Check Python syntax run: | python -m py_compile \ + tools/mph_profile.py \ tools/prepare_mph.py \ tools/check_mph_multirom_profiles.py \ - tools/patch_ndsrecomp_mph_runtime.py + tools/patch_ndsrecomp_mph_runtime.py \ + tools/promote_mph_static_coverage.py \ + tools/promote_mph_runtime_coverage.py \ + tools/benchmark_mph_fmv.py - name: Check shell syntax run: bash -n tools/build-linux.sh @@ -53,6 +57,83 @@ jobs: python tools/check_mph_multirom_profiles.py \ --melonprime-table /tmp/MelonPrimeGameRomAddrTable.h + - name: Test profile-aware coverage promotion + run: | + 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", + "static_coverage": {"tier3_entries9": 3}, + "tier3_coverage": {"entries": [ + {"cpu": 9, "pc": 33570816, "thumb": 0, "kind": 2, "hits": 99}, + {"cpu": 9, "pc": 33570848, "thumb": 0, "kind": 2, "hits": 7}, + {"cpu": 9, "pc": 33579008, "thumb": 0, "kind": 2, "hits": 5}, + {"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['main_image']['arm9']['end'] == '0x02005000' + assert [e['addr'] for e in p['entry_points']['arm9']] == ['0x02004020'] + assert [e['addr'] for e in p['entry_points']['arm7']] == ['0x02380020'] + PY + + truncate -s $((0x00408000)) /tmp/eu-runtime.bin + cat > /tmp/eu-before.json <<'EOF' + { + "mph_profile": "EU1_1", + "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "tier3_coverage": {"entries": [ + {"cpu": 9, "pc": 33525760, "thumb": 0, "kind": 2, "caller": 1, "hits": 1} + ]} + } + EOF + cat > /tmp/eu-after.json <<'EOF' + { + "mph_profile": "EU1_1", + "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "tier3_coverage": {"entries": [ + {"cpu": 9, "pc": 33525760, "thumb": 0, "kind": 2, "caller": 1, "hits": 2}, + {"cpu": 9, "pc": 33525792, "thumb": 0, "kind": 3, "caller": 2, "hits": 3} + ]} + } + EOF + python tools/promote_mph_runtime_coverage.py \ + --version EU1_1 \ + --before-benchmark /tmp/eu-before.json \ + --benchmark /tmp/eu-after.json \ + --image /tmp/eu-runtime.bin \ + --out /tmp/eu-runtime.toml + grep -q 'Profile: EU1_1' /tmp/eu-runtime.toml + grep -q 'Metroid Prime Hunters (Europe rev 1) ARM9 FMV runtime' /tmp/eu-runtime.toml + grep -q 'id = "mph_amhp1_arm9_fmv_runtime"' /tmp/eu-runtime.toml + ! grep -q 'Metroid Prime Hunters (USA) ARM9 FMV runtime' /tmp/eu-runtime.toml + - name: Fetch exact pinned ndsrecomp revision run: | pin="$(tr -d '\r\n' < ndsrecomp.pin)" @@ -156,11 +237,6 @@ jobs: - name: Compile patched runner translation units run: | - # The pinned runner CMake expects locally generated BIOS-bank C files, - # which are deliberately absent from a clean source checkout. Empty - # placeholders are sufficient for CMake generation because this job - # compiles only the three modified runner C++ translation units and - # never links or executes the placeholder banks. mkdir -p /tmp/ndsrecomp/generated for source in \ arm9_bios.c arm9_bios_dispatch.c \ From 4d159f2aacba612a49b23968921e95ee5f9f10be Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:48:39 +0900 Subject: [PATCH 55/97] Validate profile game configs in capture tools --- tools/mph_profile.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tools/mph_profile.py b/tools/mph_profile.py index e6b13a6..13ad722 100644 --- a/tools/mph_profile.py +++ b/tools/mph_profile.py @@ -5,6 +5,7 @@ import hashlib import json +import tomllib from pathlib import Path @@ -78,6 +79,34 @@ def resolve_repo_path(value: str) -> Path: 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], From 300266cd0ee428fa651841e2aa960c1bda336d23 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:49:13 +0900 Subject: [PATCH 56/97] Tag gameplay coverage traces with ROM profile --- tools/fuzz_mph_gameplay.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tools/fuzz_mph_gameplay.py b/tools/fuzz_mph_gameplay.py index 74f329a..d924e9d 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,9 @@ def main() -> int: process = launch(args, output) trace: dict[str, object] = { + "mph_profile": args.version, + "rom_sha1": rom_sha1, + "display_name": profile["display_name"], "seed": args.seed, "backend": "native" if args.runner is not None else "oracle", "start_vblank": args.start_vblank, From 31e3d7a30ec0a8cd8907cdb7f069742a8b35ed2b Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:50:24 +0900 Subject: [PATCH 57/97] Profile FMV runtime bank generation --- CMakeLists.txt | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a9c964..b1d32fc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,8 @@ 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 ${MPH_PROFILE_NAME} ROM") @@ -159,10 +161,10 @@ set(MPH_FMV_RUNTIME_SOURCES) set(MPH_FMV_RUNTIME_STAMP) if(MPH_PROFILE_FMV_RUNTIME) set(MPH_FMV_RUNTIME_CONFIG - "${CMAKE_CURRENT_SOURCE_DIR}/config/mph_arm9_fmv_runtime.toml") + "${CMAKE_CURRENT_SOURCE_DIR}/config/${MPH_PROFILE_FMV_RUNTIME_BANK}.toml") set(MPH_FMV_RUNTIME_IMAGE - "${MPH_GENERATED_ROOT}/capture/mph_arm9_fmv_runtime.bin" - CACHE FILEPATH "Deterministic MPH FMV ITCM+main-RAM capture") + "${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) @@ -171,21 +173,21 @@ if(MPH_PROFILE_FMV_RUNTIME) set(shard_name "${shard}") endif() list(APPEND MPH_FMV_RUNTIME_SOURCES - "${MPH_RECOMP_DIR}/mph_arm9_fmv_runtime_${shard_name}.c") + "${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_dispatch.c") + "${MPH_RECOMP_DIR}/${MPH_PROFILE_FMV_RUNTIME_BANK}_dispatch.c") set(MPH_FMV_RUNTIME_STAMP - "${MPH_RECOMP_DIR}/mph_arm9_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_arm9_fmv_runtime.h" + "${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_arm9_fmv_runtime + --bank "${MPH_PROFILE_FMV_RUNTIME_BANK}" --shards 32 --stable-address-shards --max-function-bytes 512 @@ -196,7 +198,8 @@ if(MPH_PROFILE_FMV_RUNTIME) COMMENT "Recompiling content-validated ${MPH_VERSION} ARM9 FMV runtime code") else() message(STATUS - "MPH FMV runtime bank disabled: capture image is not present") + "MPH FMV runtime bank disabled for ${MPH_VERSION}: expected " + "${MPH_FMV_RUNTIME_CONFIG} and ${MPH_FMV_RUNTIME_IMAGE}") endif() else() message(STATUS @@ -241,4 +244,4 @@ if(NOT MSVC) endif() add_custom_target(metroidprimehuntersrecomp - DEPENDS mph_romcheck mph_recompiled_banks) + DEPENDS mph_romcheck mph_recompiled_banks) \ No newline at end of file From 3d90ce201875a37f5fac5f933fb5bf9e8ceeff15 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:50:53 +0900 Subject: [PATCH 58/97] Use profile FMV bank in Linux release gate --- tools/build-linux.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tools/build-linux.sh b/tools/build-linux.sh index 1498a5f..f6c8d74 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -92,7 +92,10 @@ 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"): +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"]) @@ -101,6 +104,7 @@ 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 ) @@ -110,6 +114,7 @@ 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 @@ -180,8 +185,9 @@ if [[ ! -x "$RUNNER" ]]; then fi if [[ "$FMV_RUNTIME" == "1" ]]; then - if ! grep -a -q 'mph_arm9_fmv_runtime' "$RUNNER"; then - printf 'Runner does not contain the required MPH FMV runtime bank.\n' >&2 + 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 @@ -230,4 +236,4 @@ else OUTPUT="$ROOT/release-stage/MetroidPrimeHuntersRecomp-${MPH_VERSION}-linux-v${VERSION}-x86_64.AppImage" fi ARCH=x86_64 "$APPIMAGE_TOOL" "$APPDIR" "$OUTPUT" -printf 'Created %s\n' "$OUTPUT" +printf 'Created %s\n' "$OUTPUT" \ No newline at end of file From fdc8da185f07eb905fd40c7be3b45dc64384060d Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:51:22 +0900 Subject: [PATCH 59/97] Gate releases on profile FMV bank --- tools/make_release.ps1 | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tools/make_release.ps1 b/tools/make_release.ps1 index 3eb0ea8..ef31866 100644 --- a/tools/make_release.ps1 +++ b/tools/make_release.ps1 @@ -6,9 +6,9 @@ assets, the selected revision's game config, documentation, and MinGW/SDL dependencies. ROMs, BIOS/firmware, saves, raw captures, and generated source are never staged. -US1_0 keeps the historical release name and requires the validated FMV runtime -bank. Other revision profiles may opt out of that bank until a revision- -specific runtime capture has been produced. +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, @@ -17,6 +17,7 @@ param( [string]$RuntimeBinDir = 'C:\msys64\mingw64\bin', [string]$GameConfig = 'game.toml', [string]$Profile = 'US1_0', + [string]$FmvRuntimeBank = 'mph_arm9_fmv_runtime', [switch]$AllowNoFmvRuntime ) @@ -40,11 +41,12 @@ foreach ($required in @($runner, $launcher, $assets, $gameConfigPath)) { } if (-not $AllowNoFmvRuntime) { - # A US1.0 static-only runner is functional but drops the opening movies to - # roughly half speed. Keep the established release gate for that profile. + if ([string]::IsNullOrWhiteSpace($FmvRuntimeBank)) { + throw 'FMV runtime bank identity is empty.' + } $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 $runnerText.Contains($FmvRuntimeBank)) { + throw "Runner does not contain required FMV runtime bank '$FmvRuntimeBank'." } } @@ -166,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 From 54a9afe2edd2a05409877d47c85304ce96623d63 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:51:44 +0900 Subject: [PATCH 60/97] Pass profile FMV bank to Windows packaging --- tools/build-windows.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/build-windows.ps1 b/tools/build-windows.ps1 index d9b7c0e..f2a79da 100644 --- a/tools/build-windows.ps1 +++ b/tools/build-windows.ps1 @@ -44,6 +44,10 @@ $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))) @@ -97,6 +101,7 @@ 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 ` @@ -144,7 +149,8 @@ try { '-LauncherBuildDir', $LauncherBuildDir, '-RuntimeBinDir', $RuntimeBinDir, '-GameConfig', $gameConfig, - '-Profile', $MphVersion + '-Profile', $MphVersion, + '-FmvRuntimeBank', $fmvRuntimeBank ) if (-not [bool]$profile.fmv_runtime) { $releaseArgs += '-AllowNoFmvRuntime' @@ -153,4 +159,4 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Release packaging failed.' } } finally { Pop-Location -} +} \ No newline at end of file From 75dfed43fd132c512424617fc36ec0ac6d0bc097 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:52:47 +0900 Subject: [PATCH 61/97] Validate profile FMV bank configuration --- tools/check_mph_multirom_profiles.py | 50 ++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py index 7bfcd3c..6881e82 100644 --- a/tools/check_mph_multirom_profiles.py +++ b/tools/check_mph_multirom_profiles.py @@ -2,9 +2,9 @@ """Static consistency checks for Metroid Prime Hunters ROM profiles. This intentionally does not need a copyrighted ROM. It verifies that each -profile's identity, coverage seed, game config, launcher policy, and host-side -runtime addresses agree. When --melonprime-table is provided, Aim/Morph -addresses are also cross-checked against melonPrimeDS's +profile's identity, coverage seed, game config, launcher policy, FMV bank +identity, and host-side runtime addresses agree. When --melonprime-table is +provided, Aim/Morph addresses are also cross-checked against melonPrimeDS's MelonPrimeGameRomAddrTable.h, which is the source of truth for those fields. """ @@ -19,6 +19,7 @@ SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +BANK_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", @@ -112,6 +113,7 @@ def validate_registry(repo: Path, table: Path | None) -> None: melon = parse_melonprime_table(table) if table else None seen_sha1: set[str] = set() seen_identity: set[tuple[str, int]] = set() + seen_fmv_banks: set[str] = set() for key, profile in profiles.items(): if not isinstance(profile, dict): @@ -146,6 +148,40 @@ def validate_registry(repo: Path, table: Path | None) -> None: 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: " + f"{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}" + ) + runtime = profile.get("runtime") if not isinstance(runtime, dict): die(f"{key}.runtime is required") @@ -212,6 +248,12 @@ def validate_registry(repo: Path, table: Path | None) -> None: f"game config enables {config_adaptive!r}" ) + us = profiles.get("US1_0") + if not isinstance(us, dict): + die("US1_0 profile is required") + if us.get("fmv_runtime_bank") != "mph_arm9_fmv_runtime": + die("US1_0 historical FMV runtime bank identity changed unexpectedly") + # Explicit regression guard for the first non-US revision. These are the # values currently published by melonPrimeDS's source-of-truth table. eu = profiles.get("EU1_1") @@ -231,6 +273,8 @@ def validate_registry(repo: Path, table: Path | None) -> None: die(f"EU1_1 runtime profile changed unexpectedly: {eu_runtime!r}") 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": From f17570bfc7256f115492e1c273e8d44623129910 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:53:06 +0900 Subject: [PATCH 62/97] Require complete capture profile metadata --- tools/mph_profile.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tools/mph_profile.py b/tools/mph_profile.py index 13ad722..d87ef64 100644 --- a/tools/mph_profile.py +++ b/tools/mph_profile.py @@ -37,9 +37,12 @@ def load_profile(path: Path, version: str) -> dict[str, object]: "rom_size": int, "sha1": str, "program_id": str, + "coverage": str, "game_config": str, - "adaptive_widescreen": bool, + "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) @@ -65,6 +68,12 @@ def load_profile(path: Path, version: str) -> dict[str, object]: 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 From 293d08a4b955ece32aa21babea641ac6e2b169af Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:54:31 +0900 Subject: [PATCH 63/97] Extend multi-ROM coverage static checks --- .github/workflows/mph-multirom-static.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index b70062e..f30662b 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -28,7 +28,8 @@ jobs: tools/patch_ndsrecomp_mph_runtime.py \ tools/promote_mph_static_coverage.py \ tools/promote_mph_runtime_coverage.py \ - tools/benchmark_mph_fmv.py + tools/benchmark_mph_fmv.py \ + tools/fuzz_mph_gameplay.py - name: Check shell syntax run: bash -n tools/build-linux.sh @@ -134,6 +135,16 @@ jobs: grep -q 'id = "mph_amhp1_arm9_fmv_runtime"' /tmp/eu-runtime.toml ! grep -q 'Metroid Prime Hunters (USA) ARM9 FMV runtime' /tmp/eu-runtime.toml + - name: Verify profile-owned FMV build naming + run: | + grep -q 'MPH_PROFILE_FMV_RUNTIME_BANK' CMakeLists.txt + grep -q 'config/${MPH_PROFILE_FMV_RUNTIME_BANK}.toml' CMakeLists.txt + grep -q 'capture/${MPH_PROFILE_FMV_RUNTIME_BANK}.bin' CMakeLists.txt + ! grep -q -- '--bank mph_arm9_fmv_runtime' CMakeLists.txt + grep -q 'FMV_RUNTIME_BANK' tools/build-linux.sh + grep -q 'FmvRuntimeBank' tools/build-windows.ps1 + grep -q 'FmvRuntimeBank' tools/make_release.ps1 + - name: Fetch exact pinned ndsrecomp revision run: | pin="$(tr -d '\r\n' < ndsrecomp.pin)" @@ -192,6 +203,7 @@ jobs: - name: Verify Linux profile-owned launch policy run: | grep -q 'launcher_default_rom' tools/build-linux.sh + grep -q 'fmv_runtime_bank' tools/build-linux.sh grep -q 'cp "$GAME_CONFIG" "$APPDIR/usr/share/mph-recomp/game.toml"' \ tools/build-linux.sh grep -q -- '--config "$HERE/usr/share/mph-recomp/game.toml"' \ From 9c284ba1c7b3714680d6bbf0e02bc61fe089dbb2 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:55:01 +0900 Subject: [PATCH 64/97] Require identified non-US coverage traces --- tools/promote_mph_static_coverage.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tools/promote_mph_static_coverage.py b/tools/promote_mph_static_coverage.py index 09e5ea6..864f965 100644 --- a/tools/promote_mph_static_coverage.py +++ b/tools/promote_mph_static_coverage.py @@ -55,7 +55,7 @@ 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", default="scenarios/adventure_start.json") + parser.add_argument("--scenario") parser.add_argument("--runner-commit", required=True) parser.add_argument("--version", default=DEFAULT_VERSION) parser.add_argument( @@ -102,12 +102,19 @@ def main() -> int: 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}" ) - observed_sha1 = trace.get("rom_sha1") - expected_sha1 = str(profile["sha1"]) 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 " @@ -155,11 +162,12 @@ def main() -> int: 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, "profile": args.version, "game_sha1": expected_sha1, - "scenario": args.scenario, + "scenario": scenario, "runner_commit": args.runner_commit, "selection": ( "Tier-3 call and indirect targets inside the selected ROM's " From 7c244be97b0040d81c034182220573a20a7e2199 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:55:21 +0900 Subject: [PATCH 65/97] Bind runtime promotion to tagged captures --- tools/promote_mph_runtime_coverage.py | 30 ++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tools/promote_mph_runtime_coverage.py b/tools/promote_mph_runtime_coverage.py index be6bd77..f8a3081 100644 --- a/tools/promote_mph_runtime_coverage.py +++ b/tools/promote_mph_runtime_coverage.py @@ -33,13 +33,19 @@ def verify_report_identity( 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}" ) - observed_sha1 = report.get("rom_sha1") 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 " @@ -75,6 +81,7 @@ def main() -> int: 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: @@ -89,7 +96,27 @@ def main() -> int: 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 = 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}" + ) + if int(capture_bytes) != IMAGE_SIZE: + raise SystemExit( + f"benchmark runtime capture size {capture_bytes!r} does not " + f"match 0x{IMAGE_SIZE:X}" + ) + coverage = report.get("tier3_coverage", {}).get("entries", []) if args.before_benchmark is not None: @@ -101,6 +128,7 @@ def main() -> int: version=args.version, expected_sha1=expected_sha1, label="before-benchmark", + require_identity=require_identity, ) before_entries = before_report.get( "tier3_coverage", {} From 1b40ab23acd82762cdab8e995a4611b6c52b0b0f Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:55:53 +0900 Subject: [PATCH 66/97] Record coverage scenario provenance --- tools/fuzz_mph_gameplay.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/fuzz_mph_gameplay.py b/tools/fuzz_mph_gameplay.py index d924e9d..762ec3f 100644 --- a/tools/fuzz_mph_gameplay.py +++ b/tools/fuzz_mph_gameplay.py @@ -314,6 +314,7 @@ def main() -> int: "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, From 5c9aaf0ffafc2149a1ddfcc93f9213ca3356477a Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:56:35 +0900 Subject: [PATCH 67/97] Validate tagged runtime capture identity --- .github/workflows/mph-multirom-static.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index f30662b..874c918 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -79,6 +79,7 @@ jobs: { "mph_profile": "EU1_1", "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "scenario": "scenarios/adventure_start.json", "static_coverage": {"tier3_entries9": 3}, "tier3_coverage": {"entries": [ {"cpu": 9, "pc": 33570816, "thumb": 0, "kind": 2, "hits": 99}, @@ -99,6 +100,7 @@ jobs: p = json.load(open('/tmp/eu-coverage.json', encoding='utf-8')) assert p['profile'] == 'EU1_1' assert p['game_sha1'] == 'bdcd1dea293e24c98d4c481430e90d21198985a5' + assert p['scenario'] == 'scenarios/adventure_start.json' assert p['main_image']['arm9']['end'] == '0x02005000' assert [e['addr'] for e in p['entry_points']['arm9']] == ['0x02004020'] assert [e['addr'] for e in p['entry_points']['arm7']] == ['0x02380020'] @@ -118,6 +120,10 @@ jobs: { "mph_profile": "EU1_1", "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "runtime_capture": { + "sha1": "2dacada9962037d4e1fa3099d9de8cdf616c318c", + "bytes": 4227072 + }, "tier3_coverage": {"entries": [ {"cpu": 9, "pc": 33525760, "thumb": 0, "kind": 2, "caller": 1, "hits": 2}, {"cpu": 9, "pc": 33525792, "thumb": 0, "kind": 3, "caller": 2, "hits": 3} From a0a13cea99a48c67bd25e4ba188cbbebc2d96e6b Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:59:19 +0900 Subject: [PATCH 68/97] Document EU1.1 coverage capture pipeline --- docs/EU1_1_BRINGUP.md | 281 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 247 insertions(+), 34 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index 80625d9..76c0689 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -7,7 +7,7 @@ `MetroidPrimeHuntersRecomp` を USA revision 0 (`AMHE`, revision 0) 固定からmulti-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revision 1) を安全にbring-upする。 -ROMなしで可能な基盤実装は完了しており、現在の残件はEU1.1実ROMを使うruntime validation、EU1.1固有coverage、必要に応じたEU1.1固有FMV runtime captureである。 +ROMなしで可能な基盤実装は完了しており、現在の残件はEU1.1実ROMを使うruntime validation、EU1.1固有coverageの実採取、必要に応じたEU1.1固有FMV runtime captureである。 ## 2. EU1.1 identity @@ -22,7 +22,8 @@ ROMなしで可能な基盤実装は完了しており、現在の残件はEU1.1 | Game config | `config/game-eu11.toml` | | Launcher default ROM | `Metroid Prime Hunters (Europe Rev 1).nds` | | Adaptive Widescreen | disabled until EU1.1 validation | -| FMV runtime bank | disabled until EU1.1 capture exists | +| FMV runtime enabled | `false` | +| Reserved EU1.1 FMV bank ID | `mph_amhp1_arm9_fmv_runtime` | identityと版別policyは `config/mph_rom_profiles.json` に集約する。 @@ -31,7 +32,8 @@ identityと版別policyは `config/mph_rom_profiles.json` に集約する。 schema 2 profileは少なくとも以下を管理する。 - `game_code`, `revision`, `rom_size`, `sha1`, `program_id` -- `coverage`, `game_config`, `fmv_runtime` +- `coverage`, `game_config` +- `fmv_runtime`, `fmv_runtime_bank` - `launcher_default_rom`, `adaptive_widescreen` - `runtime.morph_state`, `runtime.aim_x`, `runtime.aim_y` @@ -82,11 +84,109 @@ patcherはexact source preimageを要求し、idempotentで、profile切替時 - EU1.1は `0x020DB138 / 0x020DEE46 / 0x020DEE4E` のみ使用 - profile切替後のstale stateなし -## 6. EU1.1 coverage bootstrap +## 6. EU1.1 static coverage pipeline + +### 6.1 Bootstrap `coverage/eu11-bootstrap-entry-points.json` は追加ARM9/ARM7 rootを空にする。ROM header entry PCは `prepare_mph.py` がseedし、未コンパイル領域はInterpreter fallbackへ送る。 -US1.0 absolute PCはEU1.1へコピーしない。EU1.1自身のtraceからのみcoverageを拡張する。 +US1.0の `coverage/adventure-main-entry-points.json` に含まれるabsolute PCはEU1.1へコピーしない。EU1.1自身のexecution traceからのみcoverageを拡張する。 + +### 6.2 US1.0固定rangeの除去 + +旧 `tools/promote_mph_static_coverage.py` はUS1.0固定の以下を内蔵していた。 + +```text +GAME_SHA1 +ARM9 main-image start/end +ARM7 main-image start/end +ARM9/ARM7 existing entry PC +``` + +現在はこれらを持たない。 + +`prepare_mph.py` が選択ROMそのものから生成した + +```text +generated//inputs/arm9.toml +generated//inputs/arm7.toml +``` + +の `[program].load_address`, `size`, `entry_pc`, `id` を読み、実ROM由来のimmutable main-image geometryをcoverage filterへ使用する。 + +EU1.1では既定で: + +```text +generated/EU1_1/inputs/arm9.toml +generated/EU1_1/inputs/arm7.toml +``` + +を読む。 + +したがってEU1.1のARM9/ARM7終端アドレスをUS1.0から推測・変換する処理はない。 + +### 6.3 Trace provenance + +`tools/fuzz_mph_gameplay.py` は現在profile-awareで、実行前にROMのsize/SHA-1/game code/revisionを検証する。 + +生成する `trace.json` には少なくとも以下を記録する。 + +```json +{ + "mph_profile": "EU1_1", + "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "scenario": "scenarios/adventure_start.json" +} +``` + +EU1.1のcoverage昇格では、このprofile/ROM identityが欠落した旧traceや出所不明traceを拒否する。 + +### 6.4 EU1.1 Adventure coverage採取 + +EU1.1 runnerをbuild済みとする。 + +```powershell +python tools\fuzz_mph_gameplay.py ` + --version EU1_1 ` + --runner ..\ndsrecomp\runner\build-mph-release-EU1_1\nds_runner.exe ` + --bios bios ` + --rom "Metroid Prime Hunters (Europe Rev 1).nds" ` + --out generated\EU1_1\capture\adventure-coverage ` + --actions scenarios\adventure_start.json ` + --steps 0 ` + --capture-static-coverage +``` + +`--capture-static-coverage` によりrunnerは `--discover-static-misses` 付きで起動し、最後に `static_coverage` と `tier3_coverage` をdebug serverから回収する。 + +### 6.5 EU1.1 static coverage昇格 + +runner/framework commitは実際に採取へ使ったrevisionを記録する。 + +```powershell +$runnerCommit = git -C ..\ndsrecomp rev-parse HEAD + +python tools\promote_mph_static_coverage.py ` + --version EU1_1 ` + --trace generated\EU1_1\capture\adventure-coverage\trace.json ` + --out coverage\eu11-adventure-main-entry-points.json ` + --runner-commit $runnerCommit +``` + +昇格対象は以下のみ。 + +- ARM9/ARM7 immutable main image内 +- Tier-3 `call` +- Tier-3 `indirect` + +以下は除外する。 + +- slice-resume root +- main image範囲外runtime RAM +- reused overlay virtual ranges +- ROM header entry PCの重複 + +実ROM検証後に `config/mph_rom_profiles.json` のEU1.1 `coverage` をbootstrap JSONからこの昇格済みJSONへ切り替える。 ## 7. Generated tree separation @@ -103,8 +203,11 @@ EU1.1: ```text generated/EU1_1/inputs/ generated/EU1_1/recomp/ +generated/EU1_1/capture/ ``` +異なるROM revisionのprepared binary、coverage capture、runtime image、generated bankを同じディレクトリへ混在させない。 + ## 8. Launcher identity / feature policy separation `launcher/recomp-ui/CMakeLists.txt` はbaseline `launcher_main.cpp` からprofile-specific generated TUを作る。 @@ -133,11 +236,115 @@ EU1.1 Adaptive Widescreenは三重にfail-closed: Prime ControlsはEU1.1でも表示する。必要なMorph/Aim address routingはROMなしunit testで固定済みだが、ゲーム内semantic correctnessは実ROMで確認する。 -## 9. FMV runtime bank +## 9. EU1.1 FMV runtime capture pipeline -US1.0 runtime captureはEU1.1へ流用しない。EU1.1は現在 `fmv_runtime=false`。必要な場合のみEU1.1自身からcaptureし、live-byte validation付きbankを作る。 +### 9.1 US1.0 bankの非流用 -## 10. Build +US1.0の既存bank: + +```text +config/mph_arm9_fmv_runtime.toml +generated/capture/mph_arm9_fmv_runtime.bin +bank id: mph_arm9_fmv_runtime +``` + +はEU1.1へ流用しない。 + +EU1.1用に予約しているidentity/path: + +```text +config/mph_amhp1_arm9_fmv_runtime.toml +generated/EU1_1/capture/mph_amhp1_arm9_fmv_runtime.bin +bank id: mph_amhp1_arm9_fmv_runtime +``` + +現在 `fmv_runtime=false` なので、EU1.1 buildはこのbankを要求・登録しない。 + +### 9.2 FMV benchmark/captureのprofile化 + +`tools/benchmark_mph_fmv.py` は現在: + +- `--version EU1_1` を受ける +- exact EU1.1 ROM identityを起動前に検証する +- `--config` 省略時は `config/game-eu11.toml` を選ぶ +- `--adaptive auto` が既定 +- EU1.1ではprofile policyに従い `--adaptive-widescreen none` +- `benchmark.json` に `mph_profile` と `rom_sha1` を保存する +- `--capture-runtime` 時はcapture SHA-1とbyte countも保存する + +EU1.1を誤ってUS1.0のAdaptive Widescreen有効状態でbenchmarkする既定値は廃止した。 + +### 9.3 Before window採取 + +例としてVBlank 2400までの累積coverageを取る。 + +```powershell +python tools\benchmark_mph_fmv.py ` + --version EU1_1 ` + --runner ..\ndsrecomp\runner\build-mph-release-EU1_1\nds_runner.exe ` + --bios bios ` + --rom "Metroid Prime Hunters (Europe Rev 1).nds" ` + --out generated\EU1_1\capture\fmv-before ` + --targets 2400 ` + --discover-static-misses +``` + +### 9.4 Target window + RAM capture + +```powershell +python tools\benchmark_mph_fmv.py ` + --version EU1_1 ` + --runner ..\ndsrecomp\runner\build-mph-release-EU1_1\nds_runner.exe ` + --bios bios ` + --rom "Metroid Prime Hunters (Europe Rev 1).nds" ` + --out generated\EU1_1\capture\fmv-3000 ` + --targets 2400 3000 ` + --discover-static-misses ` + --capture-runtime generated\EU1_1\capture\mph_amhp1_arm9_fmv_runtime.bin +``` + +runtime imageはITCM + main RAMを連結した `0x00408000` bytes。 + +### 9.5 EU1.1 runtime bank config昇格 + +```powershell +python tools\promote_mph_runtime_coverage.py ` + --version EU1_1 ` + --before-benchmark generated\EU1_1\capture\fmv-before\benchmark.json ` + --benchmark generated\EU1_1\capture\fmv-3000\benchmark.json ` + --image generated\EU1_1\capture\mph_amhp1_arm9_fmv_runtime.bin ` + --out config\mph_amhp1_arm9_fmv_runtime.toml +``` + +EU1.1では昇格時に以下を全て要求する。 + +- benchmarkの `mph_profile == EU1_1` +- benchmarkの `rom_sha1 == EU1.1 SHA-1` +- before benchmarkも同じidentity +- benchmark内 `runtime_capture.sha1` と渡した`.bin`の実SHA-1が一致 +- benchmark内capture byte countが `0x00408000` +- ARM9 runtime領域内のobserved call/indirect targetが存在 + +これらのどれかが不一致ならTOMLを生成しない。 + +実runtime validationとperformance確認後にのみ、EU1.1 profileの `fmv_runtime` を `true` へ変更する。 + +## 10. FMV runtime bank build/release routing + +CMakeはFMV bank名を固定しない。profileの `fmv_runtime_bank` から以下を導出する。 + +```text +config/.toml +generated//capture/.bin +generated//recomp/_*.c +--bank +``` + +Windows/Linux release gateもprofileのbank IDをrunner内で確認する。 + +従って将来EU1.1で `fmv_runtime=true` にした場合も、US1.0の `mph_arm9_fmv_runtime` を誤要求しない。 + +## 11. Build ### Windows @@ -163,26 +370,32 @@ tools/build-linux.sh \ `--rom`省略時はEU1.1 profileのdefault ROM filenameをrepository rootから探す。AppRunはprofile別 `game.toml` をrunnerへ渡し、`--adaptive-widescreen` 等でtitle policyを上書きしない。 -## 11. ROM不要static CI +## 12. ROM不要static CI `.github/workflows/mph-multirom-static.yml` は以下を検証する。 -1. Python / shell / PowerShell syntax -2. profile / coverage / game config / launcher policy整合性 -3. melonPrimeDS `MelonPrimeGameRomAddrTable.h` とのAim/Morph照合 -4. exact `ndsrecomp.pin` fetch -5. US1.0/EU1.1 launcher generated source renderとidentity/policy確認 -6. Linux AppRunのprofile-owned config policy確認 -7. ndsrecomp runtime patchのidempotency -8. US1.0固定Aim/Morph symbol除去確認 -9. patched runnerの `title_patches.cpp` / `frontend.cpp` / `main.cpp` compile -10. exact-ROM runtime dispatch unit test実行 -11. US1.0/EU1.1 `mph_romcheck` compile -12. `git diff --check` +1. capture/promotionを含むPython syntax +2. Linux shell / Windows PowerShell syntax +3. profile / coverage / game config / launcher / FMV bank policy整合性 +4. melonPrimeDS `MelonPrimeGameRomAddrTable.h` とのAim/Morph照合 +5. fake EU1.1 prepared ARM9/ARM7 geometryからstatic coverageを昇格 +6. main-image範囲外targetが除外されることを確認 +7. fake EU1.1 runtime image + tagged benchmarkからEU1.1 FMV TOMLを生成 +8. EU1.1 TOMLへUSA runtime名が混入しないことを確認 +9. CMake/Windows/Linuxがprofile-owned FMV bank IDを使用することを確認 +10. exact `ndsrecomp.pin` fetch +11. US1.0/EU1.1 launcher generated source renderとidentity/policy確認 +12. Linux AppRunのprofile-owned config policy確認 +13. ndsrecomp runtime patchのidempotency +14. US1.0固定Aim/Morph symbol除去確認 +15. patched runnerの `title_patches.cpp` / `frontend.cpp` / `main.cpp` compile +16. exact-ROM runtime dispatch unit test実行 +17. US1.0/EU1.1 `mph_romcheck` compile +18. `git diff --check` ROM、BIOS、firmware dumpはCIで取得しない。 -## 12. mphCodexの役割 +## 13. mphCodexの役割 Aim X/Y/MorphはmelonPrimeDS tableをsource of truthとする。その他Recomp固有host enhancementのcross-version semantic調査にはmphCodexを利用する。 @@ -198,7 +411,7 @@ Aim X/Y/MorphはmelonPrimeDS tableをsource of truthとする。その他Recomp 単一delta変換は使用しない。 -## 13. 実ROMで残るvalidation gates +## 14. 実ROMで残るvalidation gates ### Gate A - extraction / bank generation @@ -216,34 +429,34 @@ Adventure file作成/読込、Celestial Archives、movement/aim/shoot、Morph Ba address routingはunit test済み。実ROMではnormal/Morph touch behavior、camera aim、menu/touch復帰、keyboard/gamepad操作を確認する。 -### Gate E - Adaptive Widescreen +### Gate E - deterministic coverage -現在EU1.1では意図的に無効。基本対応の必須条件ではない。将来有効化する場合のみprojection、culling、HUD anchoring、touchscreen、特殊camera/visor sceneをEU1.1実ROMで検証し、profileとgame configを同時にenableする。 +EU1.1自身のprofile-tagged execution traceからcoverageを採取し、EU1.1 prepared main-image geometryでfilterして昇格する。 -### Gate F - deterministic coverage +### Gate F - FMV runtime optimization -EU1.1自身のexecution traceからのみcoverageを昇格する。 +必要な場合のみEU1.1 captureを作り、capture identity、content validation、correctness、performanceを確認してから `fmv_runtime=true` にする。 -### Gate G - FMV runtime optimization +### Gate G - Adaptive Widescreen -必要な場合のみEU1.1 captureを作り、content validationとperformanceを確認してから `fmv_runtime=true` にする。 +現在EU1.1では意図的に無効。基本対応の必須条件ではない。将来有効化する場合のみprojection、culling、HUD anchoring、touchscreen、特殊camera/visor sceneをEU1.1実ROMで検証し、profileとgame configを同時にenableする。 -## 14. Supported判定 +## 15. Supported判定 EU1.1をruntime検証済みsupportedと宣言するには、exact identity、EU1.1 ARM9/ARM7 banks、title/gameplay/save/load、Prime Controls/Direct Aim semantic validation、native/reference checkpoint比較、US1.0 regressionなしが必要である。 Adaptive WidescreenとEU1.1 FMV runtime bankは基本correctnessの必須条件ではない。未検証機能はfail-closedを維持する。 -## 15. 現在の判定 +## 16. 現在の判定 ### Code / infrastructure -**READY FOR EU1.1 ROM VALIDATION** +**READY FOR EU1.1 ROM VALIDATION AND PROFILE-TAGGED COVERAGE CAPTURE** -ROMなしで可能なprofile、extraction routing、bank isolation、runtime address selection、launcher identity/feature gating、Windows/Linux packaging、static CIまで実装済み。 +ROMなしで可能なprofile、extraction routing、bank isolation、runtime address selection、launcher identity/feature gating、coverage capture metadata、static/runtime coverage promotion、profile-owned FMV bank routing、Windows/Linux packaging、static CIまで実装済み。 ### Runtime correctness **NOT YET CLAIMED** -EU1.1実ROMによるboot/gameplay/reference validationは別途必要である。 +EU1.1実ROMによるboot/gameplay/reference validationと実coverage/capture採取は別途必要である。 From 16aab6313f97701c5d2c5b37351a36d63adc9bf6 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 11:59:46 +0900 Subject: [PATCH 69/97] Remove fixed Windows profile enumeration --- tools/build-windows.ps1 | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/build-windows.ps1 b/tools/build-windows.ps1 index f2a79da..27b6e5f 100644 --- a/tools/build-windows.ps1 +++ b/tools/build-windows.ps1 @@ -1,10 +1,10 @@ <# Build Metroid Prime Hunters Recomp for one configured retail revision. -US1_0 keeps the existing release paths. EU1_1 uses isolated generated banks, -a revision-specific game config, a profile-specific launcher identity/policy, -and the shared exact-ROM runtime-address shim for Prime Controls/direct mouse -aim. +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 exact-ROM runtime-address shim for Prime +Controls/direct mouse aim. Usage: powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` @@ -13,7 +13,6 @@ Usage: #> param( [string]$Version = '0.1.0', - [ValidateSet('US1_0', 'EU1_1')] [string]$MphVersion = 'US1_0', [string]$RomPath = '', [string]$CMake = 'C:\msys64\mingw64\bin\cmake.exe', @@ -37,7 +36,8 @@ $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) { - throw "Unknown MPH profile: $MphVersion" + $choices = @($registry.profiles.PSObject.Properties.Name) -join ', ' + throw "Unknown MPH profile '$MphVersion'. Configured profiles: $choices" } $profile = $profileProperty.Value $romSha1 = [string]$profile.sha1 @@ -159,4 +159,4 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Release packaging failed.' } } finally { Pop-Location -} \ No newline at end of file +} From 7f9cb8bceffe708769ec292523991b788e33b396 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 12:00:26 +0900 Subject: [PATCH 70/97] Derive CMake profile choices from registry --- CMakeLists.txt | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b1d32fc..2a5d755 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,7 +20,6 @@ add_subdirectory( set(MPH_VERSION "US1_0" CACHE STRING "Metroid Prime Hunters retail revision profile") -set_property(CACHE MPH_VERSION PROPERTY STRINGS US1_0 EU1_1) set(MPH_PROFILE_FILE "${CMAKE_CURRENT_SOURCE_DIR}/config/mph_rom_profiles.json" CACHE FILEPATH "Metroid Prime Hunters ROM profile registry") @@ -28,6 +27,26 @@ 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 From a3703cd4efa48e023b1568a0216089fb2d017130 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 12:01:39 +0900 Subject: [PATCH 71/97] Make checkpoint capture profile-aware --- tools/capture_mph_checkpoints.py | 63 ++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 16 deletions(-) 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", From fcdc912519ecc460dec63b1b762f0c7e5f3b38a9 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 12:02:40 +0900 Subject: [PATCH 72/97] Guard capture tools and dynamic profile discovery --- .github/workflows/mph-multirom-static.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index 874c918..f335afb 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -29,7 +29,8 @@ jobs: tools/promote_mph_static_coverage.py \ tools/promote_mph_runtime_coverage.py \ tools/benchmark_mph_fmv.py \ - tools/fuzz_mph_gameplay.py + tools/fuzz_mph_gameplay.py \ + tools/capture_mph_checkpoints.py - name: Check shell syntax run: bash -n tools/build-linux.sh @@ -141,12 +142,15 @@ jobs: grep -q 'id = "mph_amhp1_arm9_fmv_runtime"' /tmp/eu-runtime.toml ! grep -q 'Metroid Prime Hunters (USA) ARM9 FMV runtime' /tmp/eu-runtime.toml - - name: Verify profile-owned FMV build naming + - name: Verify registry-driven profile and FMV routing run: | grep -q 'MPH_PROFILE_FMV_RUNTIME_BANK' CMakeLists.txt grep -q 'config/${MPH_PROFILE_FMV_RUNTIME_BANK}.toml' CMakeLists.txt grep -q 'capture/${MPH_PROFILE_FMV_RUNTIME_BANK}.bin' CMakeLists.txt ! grep -q -- '--bank mph_arm9_fmv_runtime' CMakeLists.txt + grep -q 'MPH_PROFILE_KEYS' CMakeLists.txt + ! grep -q 'PROPERTY STRINGS US1_0 EU1_1' CMakeLists.txt + ! grep -q "ValidateSet('US1_0', 'EU1_1')" tools/build-windows.ps1 grep -q 'FMV_RUNTIME_BANK' tools/build-linux.sh grep -q 'FmvRuntimeBank' tools/build-windows.ps1 grep -q 'FmvRuntimeBank' tools/make_release.ps1 From 273a8d021955fa47a0f4b2aa70ff02dc30b257f5 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 12:03:12 +0900 Subject: [PATCH 73/97] Harden runtime capture size metadata errors --- tools/promote_mph_runtime_coverage.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/promote_mph_runtime_coverage.py b/tools/promote_mph_runtime_coverage.py index f8a3081..74433de 100644 --- a/tools/promote_mph_runtime_coverage.py +++ b/tools/promote_mph_runtime_coverage.py @@ -105,15 +105,22 @@ def main() -> int: f"benchmark for {args.version} has no runtime_capture metadata" ) capture_sha1 = runtime_capture.get("sha1") - capture_bytes = runtime_capture.get("bytes") + 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}" ) - if int(capture_bytes) != IMAGE_SIZE: + try: + capture_bytes = int(capture_bytes_raw) + except (TypeError, ValueError) as exc: raise SystemExit( - f"benchmark runtime capture size {capture_bytes!r} does not " + 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}" ) From b61bcb1557ae04e475c0700baaeffc15f35c5d8c Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 12:03:57 +0900 Subject: [PATCH 74/97] Note profile-aware checkpoint validation --- docs/EU1_1_BRINGUP.md | 82 +++++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index 76c0689..e4d454e 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -45,6 +45,8 @@ https://github.com/ag-advania/melonPrimeDS/blob/main/src/frontend/qt_sdl/MelonPr Aim/Morphアドレスはglobal relocation deltaから推測しない。 +CMakeのprofile候補とWindows buildの `-MphVersion` validationもregistryから導出する。新しいrevisionをprofile registryへ追加するとき、`US1_0/EU1_1` の固定列挙を別途更新する必要はない。 + ## 4. melonPrimeDSから確定したEU1.1 runtime addresses | Semantic | melonPrimeDS field | US1.0 | EU1.1 | @@ -344,7 +346,39 @@ Windows/Linux release gateもprofileのbank IDをrunner内で確認する。 従って将来EU1.1で `fmv_runtime=true` にした場合も、US1.0の `mph_arm9_fmv_runtime` を誤要求しない。 -## 11. Build +## 11. Profile-aware checkpoint validation + +`tools/capture_mph_checkpoints.py` もprofile-awareにした。 + +runner/oracleのどちらを使う場合も、起動前に選択ROMをprofileのsize/SHA-1/game code/revisionへ照合する。runner時は `--config` 省略でprofileのgame configを選び、config identityもROM profileへ照合する。 + +各capture directoryには `metadata.json` を追加し、以下を保存する。 + +```text +mph_profile +rom_sha1 +display_name +backend +boot +targets +game_config +``` + +これによりUS1.0 native checkpointとEU1.1 oracle checkpoint等を誤って同一比較セットとして扱う前に、capture provenanceを確認できる。 + +EU1.1例: + +```powershell +python tools\capture_mph_checkpoints.py ` + --version EU1_1 ` + --runner ..\ndsrecomp\runner\build-mph-release-EU1_1\nds_runner.exe ` + --bios bios ` + --rom "Metroid Prime Hunters (Europe Rev 1).nds" ` + --out generated\EU1_1\capture\checkpoints ` + --targets 300 600 900 1200 +``` + +## 12. Build ### Windows @@ -370,32 +404,34 @@ tools/build-linux.sh \ `--rom`省略時はEU1.1 profileのdefault ROM filenameをrepository rootから探す。AppRunはprofile別 `game.toml` をrunnerへ渡し、`--adaptive-widescreen` 等でtitle policyを上書きしない。 -## 12. ROM不要static CI +## 13. ROM不要static CI `.github/workflows/mph-multirom-static.yml` は以下を検証する。 -1. capture/promotionを含むPython syntax +1. capture/promotion/checkpointを含むPython syntax 2. Linux shell / Windows PowerShell syntax 3. profile / coverage / game config / launcher / FMV bank policy整合性 -4. melonPrimeDS `MelonPrimeGameRomAddrTable.h` とのAim/Morph照合 -5. fake EU1.1 prepared ARM9/ARM7 geometryからstatic coverageを昇格 -6. main-image範囲外targetが除外されることを確認 -7. fake EU1.1 runtime image + tagged benchmarkからEU1.1 FMV TOMLを生成 -8. EU1.1 TOMLへUSA runtime名が混入しないことを確認 -9. CMake/Windows/Linuxがprofile-owned FMV bank IDを使用することを確認 -10. exact `ndsrecomp.pin` fetch -11. US1.0/EU1.1 launcher generated source renderとidentity/policy確認 -12. Linux AppRunのprofile-owned config policy確認 -13. ndsrecomp runtime patchのidempotency -14. US1.0固定Aim/Morph symbol除去確認 -15. patched runnerの `title_patches.cpp` / `frontend.cpp` / `main.cpp` compile -16. exact-ROM runtime dispatch unit test実行 -17. US1.0/EU1.1 `mph_romcheck` compile -18. `git diff --check` +4. CMake/Windowsがprofile keyを固定列挙しないこと +5. melonPrimeDS `MelonPrimeGameRomAddrTable.h` とのAim/Morph照合 +6. fake EU1.1 prepared ARM9/ARM7 geometryからstatic coverageを昇格 +7. main-image範囲外targetが除外されることを確認 +8. fake EU1.1 runtime image + tagged benchmarkからEU1.1 FMV TOMLを生成 +9. runtime image SHA/size metadata照合 +10. EU1.1 TOMLへUSA runtime名が混入しないことを確認 +11. CMake/Windows/Linuxがprofile-owned FMV bank IDを使用することを確認 +12. exact `ndsrecomp.pin` fetch +13. US1.0/EU1.1 launcher generated source renderとidentity/policy確認 +14. Linux AppRunのprofile-owned config policy確認 +15. ndsrecomp runtime patchのidempotency +16. US1.0固定Aim/Morph symbol除去確認 +17. patched runnerの `title_patches.cpp` / `frontend.cpp` / `main.cpp` compile +18. exact-ROM runtime dispatch unit test実行 +19. US1.0/EU1.1 `mph_romcheck` compile +20. `git diff --check` ROM、BIOS、firmware dumpはCIで取得しない。 -## 13. mphCodexの役割 +## 14. mphCodexの役割 Aim X/Y/MorphはmelonPrimeDS tableをsource of truthとする。その他Recomp固有host enhancementのcross-version semantic調査にはmphCodexを利用する。 @@ -411,7 +447,7 @@ Aim X/Y/MorphはmelonPrimeDS tableをsource of truthとする。その他Recomp 単一delta変換は使用しない。 -## 14. 実ROMで残るvalidation gates +## 15. 実ROMで残るvalidation gates ### Gate A - extraction / bank generation @@ -441,19 +477,19 @@ EU1.1自身のprofile-tagged execution traceからcoverageを採取し、EU1.1 p 現在EU1.1では意図的に無効。基本対応の必須条件ではない。将来有効化する場合のみprojection、culling、HUD anchoring、touchscreen、特殊camera/visor sceneをEU1.1実ROMで検証し、profileとgame configを同時にenableする。 -## 15. Supported判定 +## 16. Supported判定 EU1.1をruntime検証済みsupportedと宣言するには、exact identity、EU1.1 ARM9/ARM7 banks、title/gameplay/save/load、Prime Controls/Direct Aim semantic validation、native/reference checkpoint比較、US1.0 regressionなしが必要である。 Adaptive WidescreenとEU1.1 FMV runtime bankは基本correctnessの必須条件ではない。未検証機能はfail-closedを維持する。 -## 16. 現在の判定 +## 17. 現在の判定 ### Code / infrastructure **READY FOR EU1.1 ROM VALIDATION AND PROFILE-TAGGED COVERAGE CAPTURE** -ROMなしで可能なprofile、extraction routing、bank isolation、runtime address selection、launcher identity/feature gating、coverage capture metadata、static/runtime coverage promotion、profile-owned FMV bank routing、Windows/Linux packaging、static CIまで実装済み。 +ROMなしで可能なprofile、extraction routing、bank isolation、runtime address selection、launcher identity/feature gating、coverage capture metadata、static/runtime coverage promotion、profile-owned FMV bank routing、checkpoint identity validation、Windows/Linux packaging、static CIまで実装済み。 ### Runtime correctness From eecb92552e2d3cc44b762046a600ea333689cec8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 12:05:10 +0900 Subject: [PATCH 75/97] Reject unvalidated adaptive FMV capture --- tools/benchmark_mph_fmv.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/benchmark_mph_fmv.py b/tools/benchmark_mph_fmv.py index b0cf76c..be5a1de 100644 --- a/tools/benchmark_mph_fmv.py +++ b/tools/benchmark_mph_fmv.py @@ -165,9 +165,16 @@ def main() -> int: 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 bool(profile["adaptive_widescreen"]) else "none" + adaptive = "top" if profile_adaptive else "none" targets = sorted(set(args.targets)) if not targets or targets[0] <= 0: From fa10e444cae064e0e01992c78aa9b2d8ce9c8f55 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 12:05:55 +0900 Subject: [PATCH 76/97] Document FMV adaptive fail-closed capture policy --- docs/EU1_1_BRINGUP.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index e4d454e..fc2e286 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -271,10 +271,11 @@ bank id: mph_amhp1_arm9_fmv_runtime - `--config` 省略時は `config/game-eu11.toml` を選ぶ - `--adaptive auto` が既定 - EU1.1ではprofile policyに従い `--adaptive-widescreen none` +- profileがAdaptive Widescreen未検証なら明示的な `--adaptive top` も拒否する - `benchmark.json` に `mph_profile` と `rom_sha1` を保存する - `--capture-runtime` 時はcapture SHA-1とbyte countも保存する -EU1.1を誤ってUS1.0のAdaptive Widescreen有効状態でbenchmarkする既定値は廃止した。 +EU1.1を誤ってUS1.0のAdaptive Widescreen有効状態でbenchmark/coverage captureする経路はfail-closedにした。 ### 9.3 Before window採取 From a2e7758a0c2acc4a965c3135481bf64cd6ae6cb3 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 12:06:26 +0900 Subject: [PATCH 77/97] Require profile capture metadata fields --- tools/check_mph_multirom_profiles.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py index 6881e82..5a3a2fa 100644 --- a/tools/check_mph_multirom_profiles.py +++ b/tools/check_mph_multirom_profiles.py @@ -254,8 +254,6 @@ def validate_registry(repo: Path, table: Path | None) -> None: if us.get("fmv_runtime_bank") != "mph_arm9_fmv_runtime": die("US1_0 historical FMV runtime bank identity changed unexpectedly") - # Explicit regression guard for the first non-US revision. These are the - # values currently published by melonPrimeDS's source-of-truth table. eu = profiles.get("EU1_1") if not isinstance(eu, dict): die("EU1_1 profile is required") From bf9a977865bc20bedd141533024b851207979792 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:28:16 +0900 Subject: [PATCH 78/97] Detect MPH runtime profile from base ROM header --- config/mph_rom_profiles.json | 84 ++++++++-- tools/check_mph_multirom_profiles.py | 178 +++++++++++++++------- tools/mph_profile.py | 6 +- tools/patch_ndsrecomp_mph_runtime.py | 169 ++++++++++++++++----- tools/tests/mph_runtime_profile_test.cpp | 185 ++++++++++++++--------- 5 files changed, 440 insertions(+), 182 deletions(-) mode change 100644 => 100755 tools/check_mph_multirom_profiles.py mode change 100644 => 100755 tools/mph_profile.py mode change 100644 => 100755 tools/patch_ndsrecomp_mph_runtime.py diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index 28574a3..016efd2 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -1,6 +1,72 @@ { - "schema": 2, - "runtime_address_source": "https://github.com/ag-advania/melonPrimeDS/blob/main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h", + "schema": 3, + "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_profiles": { + "US1_0": { + "game_code": "AMHE", + "revision": 0, + "runtime": { + "morph_state": "0x020DA818", + "aim_x": "0x020DE526", + "aim_y": "0x020DE52E" + } + }, + "US1_1": { + "game_code": "AMHE", + "revision": 1, + "runtime": { + "morph_state": "0x020DB098", + "aim_x": "0x020DEDA6", + "aim_y": "0x020DEDAE" + } + }, + "EU1_0": { + "game_code": "AMHP", + "revision": 0, + "runtime": { + "morph_state": "0x020DB0B8", + "aim_x": "0x020DEDC6", + "aim_y": "0x020DEDCE" + } + }, + "EU1_1": { + "game_code": "AMHP", + "revision": 1, + "runtime": { + "morph_state": "0x020DB138", + "aim_x": "0x020DEE46", + "aim_y": "0x020DEE4E" + } + }, + "JP1_0": { + "game_code": "AMHJ", + "revision": 0, + "runtime": { + "morph_state": "0x020DC6D8", + "aim_x": "0x020E03E6", + "aim_y": "0x020E03EE" + } + }, + "JP1_1": { + "game_code": "AMHJ", + "revision": 1, + "runtime": { + "morph_state": "0x020DC698", + "aim_x": "0x020E03A6", + "aim_y": "0x020E03AE" + } + }, + "KR1_0": { + "game_code": "AMHK", + "revision": 0, + "runtime": { + "morph_state": "0x020D3EE4", + "aim_x": "0x020D7C0E", + "aim_y": "0x020D7C16" + } + } + }, "profiles": { "US1_0": { "display_name": "Metroid Prime Hunters (USA rev 0)", @@ -15,12 +81,7 @@ "fmv_runtime": true, "fmv_runtime_bank": "mph_arm9_fmv_runtime", "launcher_default_rom": "Metroid Prime Hunters.nds", - "adaptive_widescreen": true, - "runtime": { - "morph_state": "0x020DA818", - "aim_x": "0x020DE526", - "aim_y": "0x020DE52E" - } + "adaptive_widescreen": true }, "EU1_1": { "display_name": "Metroid Prime Hunters (Europe rev 1)", @@ -35,12 +96,7 @@ "fmv_runtime": false, "fmv_runtime_bank": "mph_amhp1_arm9_fmv_runtime", "launcher_default_rom": "Metroid Prime Hunters (Europe Rev 1).nds", - "adaptive_widescreen": false, - "runtime": { - "morph_state": "0x020DB138", - "aim_x": "0x020DEE46", - "aim_y": "0x020DEE4E" - } + "adaptive_widescreen": false } } } diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py old mode 100644 new mode 100755 index 5a3a2fa..d0ceb55 --- a/tools/check_mph_multirom_profiles.py +++ b/tools/check_mph_multirom_profiles.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """Static consistency checks for Metroid Prime Hunters ROM profiles. -This intentionally does not need a copyrighted ROM. It verifies that each -profile's identity, coverage seed, game config, launcher policy, FMV bank -identity, and host-side runtime addresses agree. When --melonprime-table is -provided, Aim/Morph addresses are also cross-checked against melonPrimeDS's -MelonPrimeGameRomAddrTable.h, which is the source of truth for those fields. +Build/capture profiles describe exact clean-content identities and generated +artifacts. Runtime profiles separately describe the seven supported base ROM +layouts. This separation is intentional: whole-ROM SHA-1 is provenance, while +runtime Aim/Morph address selection follows gameCode + revision. + +When --melonprime-table is provided, every runtime Aim/Morph address is +cross-checked against melonPrimeDS's MelonPrimeGameRomAddrTable.h. """ from __future__ import annotations @@ -26,6 +28,15 @@ "aim_x": "baseAimX", "aim_y": "baseAimY", } +EXPECTED_RUNTIME_PROFILES = { + "US1_0": ("AMHE", 0), + "US1_1": ("AMHE", 1), + "EU1_0": ("AMHP", 0), + "EU1_1": ("AMHP", 1), + "JP1_0": ("AMHJ", 0), + "JP1_1": ("AMHJ", 1), + "KR1_0": ("AMHK", 0), +} def die(message: str) -> NoReturn: @@ -91,33 +102,114 @@ def parse_melonprime_table(path: Path) -> dict[str, dict[str, int]]: 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() + validated: dict[str, dict[str, object]] = {} + for key, expected_identity 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") + if (game_code, revision) != expected_identity: + die( + f"{key} runtime identity is {(game_code, revision)!r}; " + f"expected {expected_identity!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) + + 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 = melon[key][melon_field] + if actual != expected: + die( + f"{key}.{profile_field}=0x{actual:08X}, but " + f"melonPrimeDS {melon_field}=0x{expected:08X}" + ) + + validated[key] = profile + + return validated + + def validate_registry(repo: Path, table: Path | None) -> None: registry_path = repo / "config" / "mph_rom_profiles.json" - registry = load_json(registry_path) - if not isinstance(registry, dict): + registry_obj = load_json(registry_path) + if not isinstance(registry_obj, dict): die("ROM profile registry must be a JSON object") - if registry.get("schema") != 2: - die("ROM profile registry schema must be 2") + registry: dict[str, object] = registry_obj + + if registry.get("schema") != 3: + die("ROM profile registry schema must be 3") - expected_source = ( - "https://github.com/ag-advania/melonPrimeDS/blob/main/" + expected_address_source = ( + "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/" "src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h" ) - if registry.get("runtime_address_source") != expected_source: - die("runtime_address_source is not the approved melonPrimeDS address table") + expected_detection_source = ( + "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/" + "src/frontend/qt_sdl/MelonPrimeGameRomDetect.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") + + melon = parse_melonprime_table(table) if table else None + runtime_profiles = validate_runtime_profiles(registry, melon) profiles = registry.get("profiles") if not isinstance(profiles, dict) or not profiles: - die("ROM profile registry has no profiles") + die("ROM profile registry has no clean build/capture profiles") - melon = parse_melonprime_table(table) if table else None seen_sha1: set[str] = set() - seen_identity: set[tuple[str, int]] = set() seen_fmv_banks: set[str] = set() for key, profile in profiles.items(): if not isinstance(profile, dict): die(f"profile {key} must be an object") + if key not in runtime_profiles: + die(f"{key}: clean build profile has no runtime base profile") + sha1 = profile.get("sha1") game_code = profile.get("game_code") revision = profile.get("revision") @@ -126,14 +218,13 @@ def validate_registry(repo: Path, table: Path | None) -> None: if sha1 in seen_sha1: die(f"duplicate SHA-1 in profile registry: {sha1}") seen_sha1.add(sha1) - if not isinstance(game_code, str) or len(game_code) != 4: - die(f"{key}.game_code must be four characters") - if not isinstance(revision, int) or not 0 <= revision <= 255: - die(f"{key}.revision must be an unsigned byte") - identity = (game_code, revision) - if identity in seen_identity: - die(f"duplicate cartridge identity: {game_code} rev {revision}") - seen_identity.add(identity) + + runtime_profile = runtime_profiles[key] + if ( + game_code != runtime_profile.get("game_code") + or revision != runtime_profile.get("revision") + ): + die(f"{key}: clean build identity disagrees with runtime base profile") launcher_default_rom = profile.get("launcher_default_rom") if ( @@ -157,9 +248,7 @@ def validate_registry(repo: Path, table: Path | None) -> None: 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" - ) + 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) @@ -182,28 +271,6 @@ def validate_registry(repo: Path, table: Path | None) -> None: f"expected {fmv_runtime_bank!r}" ) - 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 = melon[key][melon_field] - if actual != expected: - die( - f"{key}.{profile_field}=0x{actual:08X}, but " - f"melonPrimeDS {melon_field}=0x{expected:08X}" - ) - coverage_path = repo / str(profile.get("coverage", "")) if not coverage_path.is_file(): die(f"{key}.coverage does not exist: {coverage_path}") @@ -250,18 +317,18 @@ def validate_registry(repo: Path, table: Path | None) -> None: us = profiles.get("US1_0") if not isinstance(us, dict): - die("US1_0 profile is required") + die("US1_0 clean build profile is required") 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 profile is required") + die("EU1_1 clean build profile is required") 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") - eu_runtime = eu.get("runtime", {}) + eu_runtime = runtime_profiles["EU1_1"].get("runtime", {}) expected_eu_runtime = { "morph_state": "0x020DB138", "aim_x": "0x020DEE46", @@ -278,9 +345,12 @@ def validate_registry(repo: Path, table: Path | None) -> None: 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(profiles)} MPH ROM profiles") + print( + f"OK: validated {len(runtime_profiles)} runtime base profiles and " + f"{len(profiles)} clean build/capture profiles" + ) if melon is not None: - print(f"OK: Aim/Morph addresses match melonPrimeDS table: {table}") + print(f"OK: all seven Aim/Morph profiles match melonPrimeDS table: {table}") def main() -> None: diff --git a/tools/mph_profile.py b/tools/mph_profile.py old mode 100644 new mode 100755 index d87ef64..6305a09 --- a/tools/mph_profile.py +++ b/tools/mph_profile.py @@ -153,7 +153,7 @@ def verify_rom_identity( ) expected_code = str(profile["game_code"]).encode("ascii") - if len(header) <= 0x1C: + if len(header) <= 0x1E: raise SystemExit(f"ROM header is truncated: {rom_path}") if header[0x0C:0x10] != expected_code: raise SystemExit( @@ -161,9 +161,9 @@ def verify_rom_identity( f"expected {expected_code!r}" ) revision = int(profile["revision"]) - if header[0x1C] != revision: + if header[0x1E] != revision: raise SystemExit( - f"ROM revision mismatch for {version}: got {header[0x1C]}, " + f"ROM revision mismatch for {version}: got {header[0x1E]}, " f"expected {revision}" ) return actual_sha1 diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py old mode 100644 new mode 100755 index 7659f01..7f1b1b3 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -2,13 +2,22 @@ """Apply the MPH multi-ROM runtime-profile shim to the pinned ndsrecomp runner. The upstream runner currently hard-codes Metroid Prime Hunters USA rev-0 RAM -addresses for Prime Controls and direct mouse aim. This project supports more -than one retail revision, so those host-side hooks must be selected by exact -ROM SHA-1 instead of by title/address assumptions. +addresses for Prime Controls and direct mouse aim. Runtime address selection +must follow the base game/revision, not the whole-ROM hash, so modified ROMs +that preserve a supported MPH cartridge identity can use the correct layout. -The address values are generated from config/mph_rom_profiles.json. The -registry records melonPrimeDS's MelonPrimeGameRomAddrTable.h as the source of -truth for the MPH runtime addresses. +The detector mirrors melonPrimeDS's fallback identity: +NDS gameCode @0x0C + ROM revision @0x1E. Unlike melonPrimeDS, revisions outside +the seven explicitly supported retail profiles fail closed instead of mapping +all non-zero revisions to 1.1. + +Whole-ROM SHA-1 remains a clean-content/provenance identity. If a SHA-1 is one +of the configured known-clean ROMs, its profile must agree with the header; +an impossible clean-hash/header mismatch is rejected. An unknown SHA-1 does +not by itself reject a recognized base profile. + +Runtime address values are generated from config/mph_rom_profiles.json and +cross-checked in CI against melonPrimeDS's MelonPrimeGameRomAddrTable.h. The source patch is intentionally small, idempotent, and fail-closed. If the pinned ndsrecomp source changes enough that the expected preimages are absent, @@ -44,26 +53,65 @@ def parse_address(value: object, *, profile: str, field: str) -> int: def load_runtime_profiles(registry_path: Path) -> list[dict[str, object]]: registry = json.loads(registry_path.read_text(encoding="utf-8")) - profiles = registry.get("profiles") - if not isinstance(profiles, dict) or not profiles: - raise SystemExit("ROM profile registry has no profiles") + 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") + + 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]] = [] - for key, profile in profiles.items(): + seen_identity: set[tuple[str, int]] = set() + seen_clean_sha1: set[str] = set() + + for key, profile in runtime_profiles.items(): if not isinstance(profile, dict): - continue + 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 runtime is None: - continue + 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") - sha1 = profile.get("sha1") - if not isinstance(sha1, str) or not SHA1_RE.fullmatch(sha1): - raise SystemExit(f"{key}.sha1 must be 40 lowercase hex digits") + + identity = (game_code, revision) + if identity in seen_identity: + raise SystemExit( + f"duplicate runtime cartridge identity: {game_code} rev {revision}" + ) + seen_identity.add(identity) + + 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, - "sha1": sha1, + "game_code": game_code, + "revision": revision, + "known_clean_sha1": known_clean_sha1, "morph_state": parse_address( runtime.get("morph_state"), profile=key, field="morph_state" ), @@ -76,8 +124,17 @@ def load_runtime_profiles(registry_path: Path) -> list[dict[str, object]]: } ) - if not result: - raise SystemExit("ROM profile registry contains no MPH runtime profiles") + expected = { + "US1_0", "US1_1", "EU1_0", "EU1_1", "JP1_0", "JP1_1", "KR1_0" + } + actual = {str(profile["key"]) for profile in result} + if actual != expected: + missing = ", ".join(sorted(expected - actual)) or "none" + extra = ", ".join(sorted(actual - expected)) or "none" + raise SystemExit( + f"runtime profile set must be exactly the seven supported retail " + f"profiles (missing: {missing}; extra: {extra})" + ) return result @@ -85,9 +142,13 @@ def generated_header(profiles: list[dict[str, object]]) -> str: rows = [] for profile in profiles: rows.append( - " {\"%s\", 0x%08Xu, 0x%08Xu, 0x%08Xu}, // %s" + ' {"%s", "%s", %du, "%s", 0x%08Xu, 0x%08Xu, 0x%08Xu},' + " // %s" % ( - profile["sha1"], + profile["key"], + profile["game_code"], + profile["revision"], + profile["known_clean_sha1"], profile["morph_state"], profile["aim_x"], profile["aim_y"], @@ -101,8 +162,15 @@ def generated_header(profiles: list[dict[str, object]]) -> str: // Generated by MetroidPrimeHuntersRecomp/tools/patch_ndsrecomp_mph_runtime.py. // Do not edit in the ndsrecomp checkout; edit config/mph_rom_profiles.json. +// +// game_code + revision selects the base runtime layout. known_clean_sha1 is +// provenance/consistency metadata only; an unknown SHA-1 is a supported variant +// when its exact header identity matches one of these seven profiles. struct NdsMphRuntimeProfile { - const char* sha1; + const char* key; + const char* game_code; + uint8_t revision; + const char* known_clean_sha1; uint32_t morph_state; uint32_t aim_x; uint32_t aim_y; @@ -144,12 +212,13 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: 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: exact-ROM runtime profile selection.\n" - "bool nds_title_patches_select_mph_runtime_profile(const char* rom_sha1);\n" + "// MPH_MULTIROM_RUNTIME_PROFILE: base-profile detection from NDS header.\n" + "bool nds_title_patches_select_mph_runtime_profile(\n" + " const uint8_t* rom_data, uint64_t rom_size, const char* rom_sha1);\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", - "MPH_MULTIROM_RUNTIME_PROFILE", + "base-profile detection from NDS header", ) patch_once( @@ -166,9 +235,9 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: "// 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: selected only by exact cartridge SHA-1.\n" + "// MPH_MULTIROM_RUNTIME_PROFILE: selected by exact gameCode + revision.\n" "const NdsMphRuntimeProfile* g_mph_runtime_profile = nullptr;\n", - "selected only by exact cartridge SHA-1", + "selected by exact gameCode + revision", ) patch_once( title_cpp, @@ -183,17 +252,35 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: " bus_write_u32_slow(kMphUs10AimY, static_cast(dy));\n" " return true;\n" "}\n", - "bool nds_title_patches_select_mph_runtime_profile(const char* rom_sha1) {\n" + "bool nds_title_patches_select_mph_runtime_profile(\n" + " const uint8_t* rom_data, uint64_t rom_size, const char* rom_sha1) {\n" " g_mph_mouse_aim = false;\n" " g_mph_runtime_profile = nullptr;\n" - " if (!rom_sha1) return false;\n" + " // NDS header: game code @0x0C..0x0F, ROM version @0x1E.\n" + " if (!rom_data || rom_size <= 0x1Eu || !rom_sha1) return false;\n\n" + " const NdsMphRuntimeProfile* header_profile = nullptr;\n" " for (const NdsMphRuntimeProfile& profile : kNdsMphRuntimeProfiles) {\n" - " if (std::strcmp(profile.sha1, rom_sha1) == 0) {\n" - " g_mph_runtime_profile = &profile;\n" - " return true;\n" + " if (std::memcmp(profile.game_code, rom_data + 0x0Cu, 4) == 0 &&\n" + " profile.revision == rom_data[0x1Eu]) {\n" + " if (header_profile) return false; // ambiguous registry: fail closed\n" + " header_profile = &profile;\n" " }\n" " }\n" - " return false;\n" + " if (!header_profile) return false;\n\n" + " // Whole-ROM SHA-1 is clean identity/provenance, not the selector.\n" + " // If this content is a known clean dump, its header must agree with\n" + " // the corresponding base profile. Unknown hashes are mod variants.\n" + " const NdsMphRuntimeProfile* clean_profile = nullptr;\n" + " for (const NdsMphRuntimeProfile& profile : kNdsMphRuntimeProfiles) {\n" + " if (profile.known_clean_sha1[0] != '\\0' &&\n" + " std::strcmp(profile.known_clean_sha1, rom_sha1) == 0) {\n" + " clean_profile = &profile;\n" + " break;\n" + " }\n" + " }\n" + " if (clean_profile && clean_profile != header_profile) return false;\n\n" + " g_mph_runtime_profile = header_profile;\n" + " return true;\n" "}\n\n" "bool nds_title_patches_mph_in_ball() {\n" " return g_mph_runtime_profile &&\n" @@ -219,8 +306,8 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: patch_once( frontend_cpp, "constexpr uint32_t kMphUs10MorphState = 0x020DA818u;\n", - "// MPH_MULTIROM_RUNTIME_PROFILE: morph address is selected by ROM SHA-1.\n", - "morph address is selected by ROM SHA-1", + "// 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, @@ -235,17 +322,19 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: " mph_mouse_aim_policy =\n" " rom_sha1 == \"90164d1ac127ee5f9815ea4ae7de798c7b5fc629\" &&\n" " frontend_options.relative_mouse_touch;\n", - " // MPH_MULTIROM_RUNTIME_PROFILE: host hooks are enabled only when the\n" - " // exact ROM SHA-1 has a validated revision-specific address profile.\n" + " // MPH_MULTIROM_RUNTIME_PROFILE: select the base address layout from\n" + " // gameCode + revision. SHA-1 only checks known-clean consistency;\n" + " // modified ROMs with a supported exact header identity remain usable.\n" " const bool mph_runtime_profile =\n" - " nds_title_patches_select_mph_runtime_profile(rom_sha1.c_str());\n" + " nds_title_patches_select_mph_runtime_profile(\n" + " rom.data(), static_cast(rom.size()), rom_sha1.c_str());\n" " mph_mouse_aim_policy =\n" " mph_runtime_profile && frontend_options.relative_mouse_touch;\n", - "host hooks are enabled only when the", + "SHA-1 only checks known-clean consistency", ) print( - f"Patched ndsrecomp MPH runtime profiles: " + f"Patched ndsrecomp MPH runtime base profiles: " + ", ".join(str(profile["key"]) for profile in profiles) ) diff --git a/tools/tests/mph_runtime_profile_test.cpp b/tools/tests/mph_runtime_profile_test.cpp index bb94478..6ade13e 100644 --- a/tools/tests/mph_runtime_profile_test.cpp +++ b/tools/tests/mph_runtime_profile_test.cpp @@ -13,6 +13,32 @@ struct Write32 { uint32_t value; }; +struct RuntimeCase { + const char* name; + const char* game_code; + uint8_t revision; + uint32_t morph; + uint32_t aim_x; + uint32_t aim_y; +}; + +constexpr RuntimeCase kCases[] = { + {"US1_0", "AMHE", 0, 0x020DA818u, 0x020DE526u, 0x020DE52Eu}, + {"US1_1", "AMHE", 1, 0x020DB098u, 0x020DEDA6u, 0x020DEDAEu}, + {"EU1_0", "AMHP", 0, 0x020DB0B8u, 0x020DEDC6u, 0x020DEDCEu}, + {"EU1_1", "AMHP", 1, 0x020DB138u, 0x020DEE46u, 0x020DEE4Eu}, + {"JP1_0", "AMHJ", 0, 0x020DC6D8u, 0x020E03E6u, 0x020E03EEu}, + {"JP1_1", "AMHJ", 1, 0x020DC698u, 0x020E03A6u, 0x020E03AEu}, + {"KR1_0", "AMHK", 0, 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; @@ -32,6 +58,19 @@ void expect_write(std::size_t index, uint32_t addr, uint32_t value, expect(ok, message); } +std::vector make_rom(const char* game_code, uint8_t revision) { + std::vector rom(0x200u, 0u); + std::memcpy(rom.data() + 0x0Cu, game_code, 4u); + rom[0x1Eu] = revision; + return rom; +} + +bool select(const char* game_code, uint8_t revision, const char* sha1) { + const auto rom = make_rom(game_code, revision); + return nds_title_patches_select_mph_runtime_profile( + rom.data(), static_cast(rom.size()), sha1); +} + } // namespace // title_patches.cpp only needs these bus surfaces. Keeping the test at the @@ -50,87 +89,91 @@ extern "C" void bus_write_u32_slow(uint32_t addr, uint32_t value) { } int main() { - constexpr const char* kUs10Sha1 = - "90164d1ac127ee5f9815ea4ae7de798c7b5fc629"; - constexpr const char* kEu11Sha1 = - "bdcd1dea293e24c98d4c481430e90d21198985a5"; - - // Unknown or absent identities must fail closed. Enabling direct aim after - // a failed selection must not create guest-memory writes. - expect(!nds_title_patches_select_mph_runtime_profile(nullptr), - "null ROM identity must not select a runtime profile"); - expect(!nds_title_patches_select_mph_runtime_profile( - "0000000000000000000000000000000000000000"), - "unknown ROM identity must not select a runtime profile"); - nds_title_patches_set_mph_mouse_aim(true); - expect(!nds_title_patches_apply_mph_mouse_delta(1, 1), - "unknown ROM must not accept direct mouse aim"); - expect(g_writes.empty(), "unknown ROM must not write guest aim fields"); - expect(!nds_title_patches_mph_in_ball(), - "unknown ROM must not read a guessed morph address"); - - // Baseline regression: US1.0 must keep the exact addresses that were - // hard-coded before multi-ROM support. - expect(nds_title_patches_select_mph_runtime_profile(kUs10Sha1), - "US1.0 profile must be selectable"); - nds_title_patches_set_mph_mouse_aim(true); - g_writes.clear(); - expect(nds_title_patches_apply_mph_mouse_delta(11, -7), - "US1.0 direct mouse aim must accept a non-zero delta"); - expect(g_writes.size() == 2, "US1.0 aim must perform two writes"); - expect_write(0, 0x020DE526u, 11u, - "US1.0 X delta must target baseAimX"); - expect_write(1, 0x020DE52Eu, static_cast(-7), - "US1.0 Y delta must target baseAimY"); - g_morph_addr = 0x020DA818u; - g_morph_value = 0x02u; - expect(nds_title_patches_mph_in_ball(), - "US1.0 baseIsAltForm=2 must report Morph Ball"); - - // EU1.1 addresses come from melonPrimeDS - // MelonPrimeGameRomAddrTable.h, not from an inferred relocation delta. - expect(nds_title_patches_select_mph_runtime_profile(kEu11Sha1), - "EU1.1 profile must be selectable"); - - // Selection deliberately clears the prior direct-aim enable. This prevents - // state from one cartridge identity leaking into another profile. - g_writes.clear(); - expect(!nds_title_patches_apply_mph_mouse_delta(3, 4), - "profile switch must clear direct-aim enable state"); - expect(g_writes.empty(), "disabled aim after profile switch must not write"); + // All seven retail base profiles must dispatch from gameCode@0x0C plus the + // exact supported revision@0x1E even when the whole-ROM SHA-1 is unknown. + // This is the mod-ROM path: content identity is provenance, not selector. + for (const RuntimeCase& c : kCases) { + expect(select(c.game_code, c.revision, kUnknownSha1), c.name); + + // Profile selection clears the previous direct-aim enable state. + g_writes.clear(); + expect(!nds_title_patches_apply_mph_mouse_delta(7, -5), + "profile switch must clear direct-aim enable state"); + expect(g_writes.empty(), + "disabled aim after profile switch must not write"); + + nds_title_patches_set_mph_mouse_aim(true); + expect(nds_title_patches_apply_mph_mouse_delta(7, -5), + "recognized base profile must accept direct mouse aim"); + expect(g_writes.size() == 2, + "recognized base 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"); + } - nds_title_patches_set_mph_mouse_aim(true); - expect(nds_title_patches_apply_mph_mouse_delta(3, -4), - "EU1.1 direct mouse aim must accept a non-zero delta"); - expect(g_writes.size() == 2, "EU1.1 aim must perform two writes"); - expect_write(0, 0x020DEE46u, 3u, - "EU1.1 X delta must target melonPrimeDS baseAimX"); - expect_write(1, 0x020DEE4Eu, static_cast(-4), - "EU1.1 Y delta must target melonPrimeDS baseAimY"); - - g_morph_addr = 0x020DB138u; - g_morph_value = 0x02u; - expect(nds_title_patches_mph_in_ball(), - "EU1.1 baseIsAltForm=2 must report Morph Ball"); - g_morph_value = 0x00u; - expect(!nds_title_patches_mph_in_ball(), - "EU1.1 non-alt form must not report Morph Ball"); + // Known-clean SHA-1 is consistency/provenance only. Matching clean + // identities work, but a clean hash paired with a contradictory header is + // impossible and must fail closed instead of trusting either side. + expect(select("AMHE", 0, kUs10Sha1), + "known-clean US1.0 must match its header profile"); + expect(select("AMHP", 1, kEu11Sha1), + "known-clean EU1.1 must match its header profile"); + expect(!select("AMHP", 1, kUs10Sha1), + "known-clean US1.0 SHA with EU1.1 header must fail closed"); + expect(!select("AMHE", 0, kEu11Sha1), + "known-clean EU1.1 SHA with US1.0 header must fail closed"); + + // Unknown/ambiguous identities must never be guessed as US1.0. Revisions + // other than the explicitly supported 0/1 set are rejected; KR only has + // revision 0 in the seven-profile table. + expect(!select("ZZZZ", 0, kUnknownSha1), + "unknown game code must fail closed"); + expect(!select("AMHE", 2, kUnknownSha1), + "unknown USA revision must fail closed"); + expect(!select("AMHP", 2, kUnknownSha1), + "unknown Europe revision must fail closed"); + expect(!select("AMHJ", 2, kUnknownSha1), + "unknown Japan revision must fail closed"); + expect(!select("AMHK", 1, kUnknownSha1), + "unknown Korea revision 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"); + const auto valid = make_rom("AMHE", 0); + expect(!nds_title_patches_select_mph_runtime_profile( + valid.data(), static_cast(valid.size()), nullptr), + "missing actual-content identity must fail closed"); - // A final invalid identity clears the active profile and prevents stale - // EU1.1 addresses from remaining live. - expect(!nds_title_patches_select_mph_runtime_profile("bad"), - "invalid final identity must clear the runtime profile"); + // Failed selection clears the prior profile so stale addresses cannot + // remain active after a cartridge/identity change. g_writes.clear(); nds_title_patches_set_mph_mouse_aim(true); expect(!nds_title_patches_apply_mph_mouse_delta(9, 9), - "cleared profile must reject mouse aim"); - expect(g_writes.empty(), "cleared profile must not retain EU1.1 writes"); + "failed selection must reject mouse aim"); + expect(g_writes.empty(), + "failed selection must not retain prior profile writes"); + expect(!nds_title_patches_mph_in_ball(), + "failed selection must not retain prior morph address"); if (g_failures != 0) { std::fprintf(stderr, "%d runtime-profile assertion(s) failed\n", g_failures); return 1; } - std::puts("OK: exact-ROM MPH runtime profiles dispatch US1.0/EU1.1 safely"); + std::puts( + "OK: header-based MPH runtime profiles dispatch all seven revisions; " + "SHA-1 remains provenance"); return 0; } From 7c4865ed60f2c7dcf90f78ef6defd464724953df Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:38:20 +0900 Subject: [PATCH 79/97] Gate MPH mods by executable compatibility --- config/mph_rom_profiles.json | 29 +- tools/check_mph_multirom_profiles.py | 140 ++++++--- tools/patch_ndsrecomp_mph_runtime.py | 348 +++++++++++++++++------ tools/tests/mph_runtime_profile_test.cpp | 179 +++++++----- 4 files changed, 511 insertions(+), 185 deletions(-) diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index 016efd2..3aa214e 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -1,11 +1,13 @@ { - "schema": 3, + "schema": 4, "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", "runtime_profiles": { "US1_0": { "game_code": "AMHE", "revision": 0, + "base_checksum": "0x218DA42C", "runtime": { "morph_state": "0x020DA818", "aim_x": "0x020DE526", @@ -15,6 +17,7 @@ "US1_1": { "game_code": "AMHE", "revision": 1, + "base_checksum": "0x91B46577", "runtime": { "morph_state": "0x020DB098", "aim_x": "0x020DEDA6", @@ -24,6 +27,7 @@ "EU1_0": { "game_code": "AMHP", "revision": 0, + "base_checksum": "0xA4A8FE5A", "runtime": { "morph_state": "0x020DB0B8", "aim_x": "0x020DEDC6", @@ -33,6 +37,7 @@ "EU1_1": { "game_code": "AMHP", "revision": 1, + "base_checksum": "0x910018A5", "runtime": { "morph_state": "0x020DB138", "aim_x": "0x020DEE46", @@ -42,6 +47,7 @@ "JP1_0": { "game_code": "AMHJ", "revision": 0, + "base_checksum": "0xD75F539D", "runtime": { "morph_state": "0x020DC6D8", "aim_x": "0x020E03E6", @@ -51,6 +57,7 @@ "JP1_1": { "game_code": "AMHJ", "revision": 1, + "base_checksum": "0x42EBF348", "runtime": { "morph_state": "0x020DC698", "aim_x": "0x020E03A6", @@ -60,6 +67,7 @@ "KR1_0": { "game_code": "AMHK", "revision": 0, + "base_checksum": "0xE54682F3", "runtime": { "morph_state": "0x020D3EE4", "aim_x": "0x020D7C0E", @@ -67,6 +75,25 @@ } } }, + "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": { "display_name": "Metroid Prime Hunters (USA rev 0)", diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py index d0ceb55..a738393 100755 --- a/tools/check_mph_multirom_profiles.py +++ b/tools/check_mph_multirom_profiles.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 """Static consistency checks for Metroid Prime Hunters ROM profiles. -Build/capture profiles describe exact clean-content identities and generated -artifacts. Runtime profiles separately describe the seven supported base ROM -layouts. This separation is intentional: whole-ROM SHA-1 is provenance, while -runtime Aim/Morph address selection follows gameCode + revision. +The registry intentionally separates three identities: -When --melonprime-table is provided, every runtime Aim/Morph address is -cross-checked against melonPrimeDS's MelonPrimeGameRomAddrTable.h. +* 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. + +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 @@ -21,6 +22,7 @@ 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_]+$") HEX_RE = re.compile(r"0x[0-9A-Fa-f]+u?") REQUIRED_RUNTIME_FIELDS = { @@ -29,13 +31,34 @@ "aim_y": "baseAimY", } EXPECTED_RUNTIME_PROFILES = { - "US1_0": ("AMHE", 0), - "US1_1": ("AMHE", 1), - "EU1_0": ("AMHP", 0), - "EU1_1": ("AMHP", 1), - "JP1_0": ("AMHJ", 0), - "JP1_1": ("AMHJ", 1), - "KR1_0": ("AMHK", 0), + "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"), } @@ -52,6 +75,12 @@ def parse_hex(value: object, where: str) -> int: 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) @@ -121,18 +150,22 @@ def validate_runtime_profiles( ) seen_identity: set[tuple[str, int]] = set() + seen_base_checksum: set[int] = set() validated: dict[str, dict[str, object]] = {} - for key, expected_identity in EXPECTED_RUNTIME_PROFILES.items(): + 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") - if (game_code, revision) != expected_identity: + base_checksum = parse_checksum( + profile.get("base_checksum"), f"{key}.base_checksum" + ) + if (game_code, revision, base_checksum) != expected: die( - f"{key} runtime identity is {(game_code, revision)!r}; " - f"expected {expected_identity!r}" + 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") @@ -143,6 +176,9 @@ def validate_runtime_profiles( 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): @@ -159,18 +195,50 @@ def validate_runtime_profiles( 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 = melon[key][melon_field] - if actual != expected: + 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:08X}" + 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) @@ -178,8 +246,8 @@ def validate_registry(repo: Path, table: Path | None) -> None: die("ROM profile registry must be a JSON object") registry: dict[str, object] = registry_obj - if registry.get("schema") != 3: - die("ROM profile registry schema must be 3") + if registry.get("schema") != 4: + die("ROM profile registry schema must be 4") expected_address_source = ( "https://github.com/ag-advania/melonPrimeDS/blob/develop_hud/" @@ -189,13 +257,20 @@ def validate_registry(repo: Path, table: Path | None) -> None: "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: @@ -203,7 +278,6 @@ def validate_registry(repo: Path, table: Path | None) -> None: seen_sha1: set[str] = set() seen_fmv_banks: set[str] = set() - for key, profile in profiles.items(): if not isinstance(profile, dict): die(f"profile {key} must be an object") @@ -220,10 +294,7 @@ def validate_registry(repo: Path, table: Path | None) -> None: seen_sha1.add(sha1) runtime_profile = runtime_profiles[key] - if ( - game_code != runtime_profile.get("game_code") - or revision != runtime_profile.get("revision") - ): + if game_code != runtime_profile.get("game_code") or revision != runtime_profile.get("revision"): die(f"{key}: clean build identity disagrees with runtime base profile") launcher_default_rom = profile.get("launcher_default_rom") @@ -256,10 +327,7 @@ def validate_registry(repo: Path, table: Path | None) -> None: 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: " - f"{runtime_config_path}" - ) + 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") @@ -328,14 +396,13 @@ def validate_registry(repo: Path, table: Path | None) -> None: die("EU1_1 must remain AMHP revision 1") if eu.get("sha1") != "bdcd1dea293e24c98d4c481430e90d21198985a5": die("EU1_1 SHA-1 changed unexpectedly") - eu_runtime = runtime_profiles["EU1_1"].get("runtime", {}) expected_eu_runtime = { "morph_state": "0x020DB138", "aim_x": "0x020DEE46", "aim_y": "0x020DEE4E", } - if eu_runtime != expected_eu_runtime: - die(f"EU1_1 runtime profile changed unexpectedly: {eu_runtime!r}") + 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": @@ -346,7 +413,8 @@ def validate_registry(repo: Path, table: Path | None) -> None: die("EU1_1 launcher default ROM filename changed unexpectedly") print( - f"OK: validated {len(runtime_profiles)} runtime base profiles and " + f"OK: validated {len(runtime_profiles)} runtime base profiles, " + f"{len(EXPECTED_RUNTIME_CHECKSUMS)} executable checksums, and " f"{len(profiles)} clean build/capture profiles" ) if melon is not None: diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py index 7f1b1b3..6597a56 100755 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -1,27 +1,21 @@ #!/usr/bin/env python3 """Apply the MPH multi-ROM runtime-profile shim to the pinned ndsrecomp runner. -The upstream runner currently hard-codes Metroid Prime Hunters USA rev-0 RAM -addresses for Prime Controls and direct mouse aim. Runtime address selection -must follow the base game/revision, not the whole-ROM hash, so modified ROMs -that preserve a supported MPH cartridge identity can use the correct layout. - -The detector mirrors melonPrimeDS's fallback identity: -NDS gameCode @0x0C + ROM revision @0x1E. Unlike melonPrimeDS, revisions outside -the seven explicitly supported retail profiles fail closed instead of mapping -all non-zero revisions to 1.1. - -Whole-ROM SHA-1 remains a clean-content/provenance identity. If a SHA-1 is one -of the configured known-clean ROMs, its profile must agree with the header; -an impossible clean-hash/header mismatch is rejected. An unknown SHA-1 does -not by itself reject a recognized base profile. - -Runtime address values are generated from config/mph_rom_profiles.json and -cross-checked in CI against melonPrimeDS's MelonPrimeGameRomAddrTable.h. - -The source patch is intentionally small, idempotent, and fail-closed. If the -pinned ndsrecomp source changes enough that the expected preimages are absent, -this script stops instead of guessing a patch against unknown code. +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 @@ -33,8 +27,12 @@ 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: @@ -51,18 +49,36 @@ def parse_address(value: object, *, profile: str, field: str) -> int: return address -def load_runtime_profiles(registry_path: Path) -> list[dict[str, object]]: +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(): @@ -89,6 +105,13 @@ def load_runtime_profiles(registry_path: Path) -> list[dict[str, object]]: ) 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: @@ -111,6 +134,7 @@ def load_runtime_profiles(registry_path: Path) -> list[dict[str, object]]: "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" @@ -124,37 +148,68 @@ def load_runtime_profiles(registry_path: Path) -> list[dict[str, object]]: } ) - expected = { - "US1_0", "US1_1", "EU1_0", "EU1_1", "JP1_0", "JP1_1", "KR1_0" - } - actual = {str(profile["key"]) for profile in result} - if actual != expected: - missing = ", ".join(sorted(expected - actual)) or "none" - extra = ", ".join(sorted(actual - expected)) or "none" + 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 profile set must be exactly the seven supported retail " - f"profiles (missing: {missing}; extra: {extra})" + f"runtime_checksums is missing canonical entries for: {missing}" ) - return result + return result, checksums -def generated_header(profiles: list[dict[str, object]]) -> str: - rows = [] +def generated_header( + profiles: list[dict[str, object]], checksums: list[dict[str, object]] +) -> str: + profile_rows: list[str] = [] for profile in profiles: - rows.append( - ' {"%s", "%s", %du, "%s", 0x%08Xu, 0x%08Xu, 0x%08Xu},' - " // %s" + 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 @@ -163,23 +218,39 @@ def generated_header(profiles: list[dict[str, object]]) -> str: // Generated by MetroidPrimeHuntersRecomp/tools/patch_ndsrecomp_mph_runtime.py. // Do not edit in the ndsrecomp checkout; edit config/mph_rom_profiles.json. // -// game_code + revision selects the base runtime layout. known_clean_sha1 is -// provenance/consistency metadata only; an unknown SHA-1 is a supported variant -// when its exact header identity matches one of these seven profiles. +// 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 }}; -""" % (len(rows), "\n".join(rows)) + +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: @@ -204,21 +275,24 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: if not path.is_file(): raise SystemExit(f"Pinned ndsrecomp runner file not found: {path}") - profiles = load_runtime_profiles(registry_path) + profiles, checksums = load_runtime_registry(registry_path) generated = runner_src / "mph_runtime_profiles.generated.h" - generated.write_text(generated_header(profiles), encoding="utf-8") + 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: base-profile detection from NDS header.\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 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", - "base-profile detection from NDS header", + "melonPrimeDS-compatible base detector", ) patch_once( @@ -235,9 +309,57 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: "// 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: selected by exact gameCode + revision.\n" - "const NdsMphRuntimeProfile* g_mph_runtime_profile = nullptr;\n", - "selected by exact gameCode + revision", + "// 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, @@ -253,44 +375,72 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: " 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 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" - " // NDS header: game code @0x0C..0x0F, ROM version @0x1E.\n" - " if (!rom_data || rom_size <= 0x1Eu || !rom_sha1) return false;\n\n" - " const NdsMphRuntimeProfile* header_profile = nullptr;\n" - " for (const NdsMphRuntimeProfile& profile : kNdsMphRuntimeProfiles) {\n" - " if (std::memcmp(profile.game_code, rom_data + 0x0Cu, 4) == 0 &&\n" - " profile.revision == rom_data[0x1Eu]) {\n" - " if (header_profile) return false; // ambiguous registry: fail closed\n" - " header_profile = &profile;\n" - " }\n" - " }\n" - " if (!header_profile) return false;\n\n" - " // Whole-ROM SHA-1 is clean identity/provenance, not the selector.\n" - " // If this content is a known clean dump, its header must agree with\n" - " // the corresponding base profile. Unknown hashes are mod variants.\n" - " const NdsMphRuntimeProfile* clean_profile = nullptr;\n" - " for (const NdsMphRuntimeProfile& profile : kNdsMphRuntimeProfiles) {\n" - " if (profile.known_clean_sha1[0] != '\\0' &&\n" - " std::strcmp(profile.known_clean_sha1, rom_sha1) == 0) {\n" - " clean_profile = &profile;\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 (clean_profile && clean_profile != header_profile) return false;\n\n" - " g_mph_runtime_profile = header_profile;\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 g_mph_runtime_profile &&\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 = enabled && g_mph_runtime_profile;\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 || !g_mph_runtime_profile ||\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" @@ -317,25 +467,59 @@ def patch_runner(framework_root: Path, registry_path: Path) -> None: "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_RUNTIME_PROFILE: select the base address layout from\n" - " // gameCode + revision. SHA-1 only checks known-clean consistency;\n" - " // modified ROMs with a supported exact header identity remain usable.\n" - " const bool mph_runtime_profile =\n" - " nds_title_patches_select_mph_runtime_profile(\n" - " rom.data(), static_cast(rom.size()), rom_sha1.c_str());\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" - " mph_runtime_profile && frontend_options.relative_mouse_touch;\n", - "SHA-1 only checks known-clean consistency", + " 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)" ) diff --git a/tools/tests/mph_runtime_profile_test.cpp b/tools/tests/mph_runtime_profile_test.cpp index 6ade13e..21b99f6 100644 --- a/tools/tests/mph_runtime_profile_test.cpp +++ b/tools/tests/mph_runtime_profile_test.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -17,19 +18,24 @@ 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, 0x020DA818u, 0x020DE526u, 0x020DE52Eu}, - {"US1_1", "AMHE", 1, 0x020DB098u, 0x020DEDA6u, 0x020DEDAEu}, - {"EU1_0", "AMHP", 0, 0x020DB0B8u, 0x020DEDC6u, 0x020DEDCEu}, - {"EU1_1", "AMHP", 1, 0x020DB138u, 0x020DEE46u, 0x020DEE4Eu}, - {"JP1_0", "AMHJ", 0, 0x020DC6D8u, 0x020E03E6u, 0x020E03EEu}, - {"JP1_1", "AMHJ", 1, 0x020DC698u, 0x020E03A6u, 0x020E03AEu}, - {"KR1_0", "AMHK", 0, 0x020D3EE4u, 0x020D7C0Eu, 0x020D7C16u}, + {"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 = @@ -58,24 +64,37 @@ void expect_write(std::size_t index, uint32_t addr, uint32_t value, expect(ok, message); } -std::vector make_rom(const char* game_code, uint8_t revision) { - std::vector rom(0x200u, 0u); +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(const char* game_code, uint8_t revision, const char* sha1) { - const auto rom = make_rom(game_code, revision); +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()), sha1); + rom.data(), static_cast(rom.size()), + actual_sha1, expected_sha1); } } // namespace -// title_patches.cpp only needs these bus surfaces. Keeping the test at the -// ABI boundary lets it link the real patched translation unit without a ROM, -// BIOS dump, or generated recomp bank. bool bus_get_region(const char*, BusRegion*) { return false; } @@ -89,24 +108,20 @@ extern "C" void bus_write_u32_slow(uint32_t addr, uint32_t value) { } int main() { - // All seven retail base profiles must dispatch from gameCode@0x0C plus the - // exact supported revision@0x1E even when the whole-ROM SHA-1 is unknown. - // This is the mod-ROM path: content identity is provenance, not selector. for (const RuntimeCase& c : kCases) { - expect(select(c.game_code, c.revision, kUnknownSha1), c.name); + 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"); - // Profile selection clears the previous direct-aim enable state. g_writes.clear(); expect(!nds_title_patches_apply_mph_mouse_delta(7, -5), "profile switch must clear direct-aim enable state"); - expect(g_writes.empty(), - "disabled aim after profile switch must not write"); - nds_title_patches_set_mph_mouse_aim(true); expect(nds_title_patches_apply_mph_mouse_delta(7, -5), - "recognized base profile must accept direct mouse aim"); + "authoritative checksum must accept direct mouse aim"); expect(g_writes.size() == 2, - "recognized base profile must perform two aim writes"); + "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), @@ -121,59 +136,91 @@ int main() { "non-alt form must not report Morph Ball"); } - // Known-clean SHA-1 is consistency/provenance only. Matching clean - // identities work, but a clean hash paired with a contradictory header is - // impossible and must fail closed instead of trusting either side. - expect(select("AMHE", 0, kUs10Sha1), - "known-clean US1.0 must match its header profile"); - expect(select("AMHP", 1, kEu11Sha1), - "known-clean EU1.1 must match its header profile"); - expect(!select("AMHP", 1, kUs10Sha1), - "known-clean US1.0 SHA with EU1.1 header must fail closed"); - expect(!select("AMHE", 0, kEu11Sha1), - "known-clean EU1.1 SHA with US1.0 header must fail closed"); - - // Unknown/ambiguous identities must never be guessed as US1.0. Revisions - // other than the explicitly supported 0/1 set are rejected; KR only has - // revision 0 in the seven-profile table. - expect(!select("ZZZZ", 0, kUnknownSha1), - "unknown game code must fail closed"); - expect(!select("AMHE", 2, kUnknownSha1), - "unknown USA revision must fail closed"); - expect(!select("AMHP", 2, kUnknownSha1), - "unknown Europe revision must fail closed"); - expect(!select("AMHJ", 2, kUnknownSha1), - "unknown Japan revision must fail closed"); - expect(!select("AMHK", 1, kUnknownSha1), - "unknown Korea revision must fail closed"); + // 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), + tiny.data(), static_cast(tiny.size()), + kUnknownSha1, ""), "truncated NDS header must fail closed"); - const auto valid = make_rom("AMHE", 0); expect(!nds_title_patches_select_mph_runtime_profile( - valid.data(), static_cast(valid.size()), nullptr), + us10.data(), static_cast(us10.size()), + nullptr, ""), "missing actual-content identity must fail closed"); - // Failed selection clears the prior profile so stale addresses cannot - // remain active after a cartridge/identity change. - g_writes.clear(); - nds_title_patches_set_mph_mouse_aim(true); - expect(!nds_title_patches_apply_mph_mouse_delta(9, 9), - "failed selection must reject mouse aim"); - expect(g_writes.empty(), - "failed selection must not retain prior profile writes"); - expect(!nds_title_patches_mph_in_ball(), - "failed selection must not retain prior morph address"); - if (g_failures != 0) { std::fprintf(stderr, "%d runtime-profile assertion(s) failed\n", g_failures); return 1; } std::puts( - "OK: header-based MPH runtime profiles dispatch all seven revisions; " - "SHA-1 remains provenance"); + "OK: seven MPH base profiles use melonPrimeDS executable CRC; " + "header fallback and whole-ROM provenance gates fail closed"); return 0; } From 702aaf6cb060fa0b1ad94d154e1b955073294884 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:41:49 +0900 Subject: [PATCH 80/97] Read MPH ROM revision from header byte 0x1E --- tools/prepare_mph.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/prepare_mph.py b/tools/prepare_mph.py index 193cc70..e16f1da 100644 --- a/tools/prepare_mph.py +++ b/tools/prepare_mph.py @@ -225,9 +225,9 @@ def main() -> int: 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 for {args.version}: got {rom_bytes[0x1C]}, " + f"ROM revision mismatch for {args.version}: got {rom_bytes[0x1E]}, " f"expected {expected_revision}" ) @@ -340,4 +340,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From 5af26a73bf2bd9181f7f167037c32d749382fe66 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:43:55 +0900 Subject: [PATCH 81/97] Route launcher ROM validation through runtime detector --- launcher/recomp-ui/CMakeLists.txt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index e23e429..5d7dc81 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -11,7 +11,7 @@ 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 - "Exact retail ROM SHA-1 accepted by this profile-specific launcher") + "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 @@ -48,6 +48,10 @@ mph_launcher_replace_required( "90164d1ac127ee5f9815ea4ae7de798c7b5fc629" "${MPH_LAUNCHER_ROM_SHA1}" "the US1.0 SHA-1 baseline") +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}\";" @@ -116,4 +120,4 @@ include("${RECOMP_UI_ROOT}/recomp_ui.cmake") recomp_target_launcher_ui(mph-recomp-ui CONSOLE nds BOXART "${CMAKE_CURRENT_SOURCE_DIR}/assets/boxart.tga") -add_test(NAME mph_mod_provider_test COMMAND mph-mod-provider-test) +add_test(NAME mph_mod_provider_test COMMAND mph-mod-provider-test) \ No newline at end of file From 2fe539644663a73b4e74151f4b4182e75ac52114 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:45:02 +0900 Subject: [PATCH 82/97] Decouple content profiles from runtime base profiles --- config/mph_rom_profiles.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index 3aa214e..13e806e 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -1,5 +1,5 @@ { - "schema": 4, + "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", @@ -96,6 +96,8 @@ ], "profiles": { "US1_0": { + "base_profile": "US1_0", + "known_clean": true, "display_name": "Metroid Prime Hunters (USA rev 0)", "region": "USA", "game_code": "AMHE", @@ -111,6 +113,8 @@ "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", @@ -126,4 +130,4 @@ "adaptive_widescreen": false } } -} +} \ No newline at end of file From 1e64d867f9f5c8fb47a2f6cf412f4abc29e5a6e8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:46:04 +0900 Subject: [PATCH 83/97] Allow mod-specific content profiles to share base layouts --- tools/check_mph_multirom_profiles.py | 54 +++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py index a738393..949eb14 100755 --- a/tools/check_mph_multirom_profiles.py +++ b/tools/check_mph_multirom_profiles.py @@ -7,6 +7,10 @@ * 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. """ @@ -24,6 +28,7 @@ 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", @@ -246,8 +251,8 @@ def validate_registry(repo: Path, table: Path | None) -> None: die("ROM profile registry must be a JSON object") registry: dict[str, object] = registry_obj - if registry.get("schema") != 4: - die("ROM profile registry schema must be 4") + 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/" @@ -274,15 +279,39 @@ def validate_registry(repo: Path, table: Path | None) -> None: profiles = registry.get("profiles") if not isinstance(profiles, dict) or not profiles: - die("ROM profile registry has no clean build/capture 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") - if key not in runtime_profiles: - die(f"{key}: clean build profile has no runtime base profile") + + 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") @@ -293,9 +322,12 @@ def validate_registry(repo: Path, table: Path | None) -> None: die(f"duplicate SHA-1 in profile registry: {sha1}") seen_sha1.add(sha1) - runtime_profile = runtime_profiles[key] + runtime_profile = runtime_profiles[base_profile] if game_code != runtime_profile.get("game_code") or revision != runtime_profile.get("revision"): - die(f"{key}: clean build identity disagrees with runtime base profile") + die( + f"{key}: content header identity disagrees with runtime base " + f"profile {base_profile}" + ) launcher_default_rom = profile.get("launcher_default_rom") if ( @@ -386,12 +418,16 @@ def validate_registry(repo: Path, table: Path | None) -> None: 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": @@ -415,7 +451,7 @@ def validate_registry(repo: Path, table: Path | None) -> None: print( f"OK: validated {len(runtime_profiles)} runtime base profiles, " f"{len(EXPECTED_RUNTIME_CHECKSUMS)} executable checksums, and " - f"{len(profiles)} clean build/capture profiles" + f"{len(profiles)} build/capture content profiles" ) if melon is not None: print(f"OK: all seven Aim/Morph profiles match melonPrimeDS table: {table}") @@ -436,4 +472,4 @@ def main() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file From fabe97ced6f8a074af92e4397b95ab55f3a6d610 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:47:50 +0900 Subject: [PATCH 84/97] Document base ROM and mod content identity model --- docs/EU1_1_BRINGUP.md | 630 +++++++++++++++--------------------------- 1 file changed, 222 insertions(+), 408 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index fc2e286..9e590a8 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -1,499 +1,313 @@ -# Metroid Prime Hunters Recomp - EU1.1 Bring-up +# Metroid Prime Hunters Multi-ROM / EU1.1 Bring-up -作成日: 2026-08-17 -更新日: 2026-08-17 +This document describes the current multi-ROM architecture in this branch. +It supersedes the earlier SHA-1-driven runtime-profile design. -## 1. 目的 +## 1. Status -`MetroidPrimeHuntersRecomp` を USA revision 0 (`AMHE`, revision 0) 固定からmulti-ROM化し、最初の追加対象として Europe revision 1 (`AMHP`, revision 1) を安全にbring-upする。 +Runtime address layouts are statically prepared for all seven retail MPH base +revisions: -ROMなしで可能な基盤実装は完了しており、現在の残件はEU1.1実ROMを使うruntime validation、EU1.1固有coverageの実採取、必要に応じたEU1.1固有FMV runtime captureである。 +| 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` | -## 2. EU1.1 identity +Exact build/capture content profiles currently exist for: -| Field | EU1.1 | -|---|---| -| Profile key | `EU1_1` | -| Game Code | `AMHP` | -| Revision | `1` | -| ROM size | `0x04000000` / 64 MiB | -| SHA-1 | `bdcd1dea293e24c98d4c481430e90d21198985a5` | -| Program ID prefix | `mph_amhp1` | -| Game config | `config/game-eu11.toml` | -| Launcher default ROM | `Metroid Prime Hunters (Europe Rev 1).nds` | -| Adaptive Widescreen | disabled until EU1.1 validation | -| FMV runtime enabled | `false` | -| Reserved EU1.1 FMV bank ID | `mph_amhp1_arm9_fmv_runtime` | +- `US1_0` clean retail ROM +- `EU1_1` clean retail ROM -identityと版別policyは `config/mph_rom_profiles.json` に集約する。 +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. -## 3. ROM profile registry +## 2. Sources of truth -schema 2 profileは少なくとも以下を管理する。 +Runtime detection and addresses intentionally follow melonPrimeDS +`develop_hud`: -- `game_code`, `revision`, `rom_size`, `sha1`, `program_id` -- `coverage`, `game_config` -- `fmv_runtime`, `fmv_runtime_bank` -- `launcher_default_rom`, `adaptive_widescreen` -- `runtime.morph_state`, `runtime.aim_x`, `runtime.aim_y` +- 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` -host-side runtime addressのsource of truth: +Repository: -```text -https://github.com/ag-advania/melonPrimeDS/blob/main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h -``` - -Aim/Morphアドレスはglobal relocation deltaから推測しない。 - -CMakeのprofile候補とWindows buildの `-MphVersion` validationもregistryから導出する。新しいrevisionをprofile registryへ追加するとき、`US1_0/EU1_1` の固定列挙を別途更新する必要はない。 +`https://github.com/ag-advania/melonPrimeDS/tree/develop_hud` -## 4. melonPrimeDSから確定したEU1.1 runtime addresses +The project registry records these source locations in +`config/mph_rom_profiles.json`. -| Semantic | melonPrimeDS field | US1.0 | EU1.1 | -|---|---|---:|---:| -| Morph / Alt Form state | `baseIsAltForm` | `0x020DA818` | `0x020DB138` | -| Direct Aim X | `baseAimX` | `0x020DE526` | `0x020DEE46` | -| Direct Aim Y | `baseAimY` | `0x020DE52E` | `0x020DEE4E` | +## 3. Identity model -CIはmelonPrimeDS `main` の実ファイルを取得してprofileと自動照合する。 +The old design coupled runtime RAM addresses to an exact whole-ROM SHA-1. +That is intentionally no longer the design. -## 5. pinned ndsrecomp runtime profile shim +There are three separate identities. -pinned framework: +### 3.1 Runtime Base Profile -```text -46b12e6c18dea47f87d2c1f98c3054149dcbca5d -``` - -元runnerのUS1.0固定Morph/Aim addressとUS1.0-only Prime Controls policyは `tools/patch_ndsrecomp_mph_runtime.py` でexact-ROM profile選択へ変換する。 +A runtime base profile is one of the seven region/revision layouts above. It +owns revision-specific host RAM addresses such as Aim X/Y and Morph / Alt Form. -```text -ROM SHA-1 - -> NdsMphRuntimeProfile - -> morph_state - -> aim_x - -> aim_y - -> known profileのみPrime Controls / Direct Mouse Aimを許可 - -> unknown ROMはfail-closed -``` +The runtime detector uses: -patcherはexact source preimageを要求し、idempotentで、profile切替時にold direct-aim enable stateをclearする。 +1. melonPrimeDS-compatible executable checksum, then +2. exact NDS header `gameCode + revision` fallback. -`tools/tests/mph_runtime_profile_test.cpp` はpatched `title_patches.cpp` を実際にリンクして以下を検証する。 +NDS header offsets: -- unknown SHA-1でAim write / Morph readなし -- US1.0 address regressionなし -- EU1.1は `0x020DB138 / 0x020DEE46 / 0x020DEE4E` のみ使用 -- profile切替後のstale stateなし +- game code: `0x0C..0x0F` +- ROM revision/version: `0x1E` -## 6. EU1.1 static coverage pipeline +The Recomp detector 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. -### 6.1 Bootstrap +### 3.2 Executable Compatibility Identity -`coverage/eu11-bootstrap-entry-points.json` は追加ARM9/ARM7 rootを空にする。ROM header entry PCは `prepare_mph.py` がseedし、未コンパイル領域はInterpreter fallbackへ送る。 +melonPrimeDS `CartCommon::Checksum()` is reproduced exactly: -US1.0の `coverage/adventure-main-entry-points.json` に含まれるabsolute PCはEU1.1へコピーしない。EU1.1自身のexecution traceからのみcoverageを拡張する。 +1. CRC32 over ROM header bytes `0x00..0x3F` +2. continue CRC32 over the ARM9 ROM image +3. continue CRC32 over the ARM7 ROM image -### 6.2 US1.0固定rangeの除去 +A hit in the audited melonPrimeDS checksum table is authoritative for the base +runtime layout and may enable host-side Aim/Morph RAM access. -旧 `tools/promote_mph_static_coverage.py` はUS1.0固定の以下を内蔵していた。 +A header-only match is weaker. It identifies a candidate base profile, but it +**does not authorize host RAM reads/writes**. Unknown executable content remains +fail-closed. -```text -GAME_SHA1 -ARM9 main-image start/end -ARM7 main-image start/end -ARM9/ARM7 existing entry PC -``` +This prevents a modified ROM that merely preserves `AMHE` revision 0 from being +blindly treated as memory-compatible US1.0. -現在はこれらを持たない。 +### 3.3 Actual Content Identity -`prepare_mph.py` が選択ROMそのものから生成した +Whole-ROM SHA-1 identifies the exact content used to generate or validate: -```text -generated//inputs/arm9.toml -generated//inputs/arm7.toml -``` +- build inputs +- recomp banks +- static coverage +- runtime coverage +- checkpoints +- FMV/runtime captures -の `[program].load_address`, `size`, `entry_pc`, `id` を読み、実ROM由来のimmutable main-image geometryをcoverage filterへ使用する。 +This identity is not the runtime-address selector. -EU1.1では既定で: +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. -```text -generated/EU1_1/inputs/arm9.toml -generated/EU1_1/inputs/arm7.toml -``` +## 4. Known melonPrimeDS executable checksums -を読む。 +The current registry mirrors the audited `develop_hud` detector table, +including: -したがってEU1.1のARM9/ARM7終端アドレスをUS1.0から推測・変換する処理はない。 +- all seven clean retail revisions +- encrypted variants +- EU1.1 Balanced variants +- EU1.1 Russian variant (`0x9E20F3A8`) -### 6.3 Trace provenance +A known checksum establishes the runtime RAM layout. It does **not** by itself +mean the clean recomp build is byte-compatible with that modified executable. -`tools/fuzz_mph_gameplay.py` は現在profile-awareで、実行前にROMのsize/SHA-1/game code/revisionを検証する。 +For example, the known EU1.1 Russian executable can use the EU1.1 Aim/Morph RAM +layout, but because its executable checksum differs from canonical clean EU1.1, +it still requires a mod-specific exact build/capture content profile. -生成する `trace.json` には少なくとも以下を記録する。 +`Last Raven` is not present in the current audited `develop_hud` checksum table. +It therefore remains fail-closed for host Aim/Morph access until its executable +checksum/layout is explicitly validated. -```json -{ - "mph_profile": "EU1_1", - "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", - "scenario": "scenarios/adventure_start.json" -} -``` - -EU1.1のcoverage昇格では、このprofile/ROM identityが欠落した旧traceや出所不明traceを拒否する。 - -### 6.4 EU1.1 Adventure coverage採取 - -EU1.1 runnerをbuild済みとする。 - -```powershell -python tools\fuzz_mph_gameplay.py ` - --version EU1_1 ` - --runner ..\ndsrecomp\runner\build-mph-release-EU1_1\nds_runner.exe ` - --bios bios ` - --rom "Metroid Prime Hunters (Europe Rev 1).nds" ` - --out generated\EU1_1\capture\adventure-coverage ` - --actions scenarios\adventure_start.json ` - --steps 0 ` - --capture-static-coverage -``` - -`--capture-static-coverage` によりrunnerは `--discover-static-misses` 付きで起動し、最後に `static_coverage` と `tier3_coverage` をdebug serverから回収する。 - -### 6.5 EU1.1 static coverage昇格 - -runner/framework commitは実際に採取へ使ったrevisionを記録する。 - -```powershell -$runnerCommit = git -C ..\ndsrecomp rev-parse HEAD - -python tools\promote_mph_static_coverage.py ` - --version EU1_1 ` - --trace generated\EU1_1\capture\adventure-coverage\trace.json ` - --out coverage\eu11-adventure-main-entry-points.json ` - --runner-commit $runnerCommit -``` +## 5. 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 can decide safely. -- ARM9/ARM7 immutable main image内 -- Tier-3 `call` -- Tier-3 `indirect` +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 +- code-modified executable checksum: exact clean SHA gate is not bypassed; + prepare a mod-specific build/capture profile +- unknown checksum + supported header: candidate base may be identified, but + host Aim/Morph RAM access and clean-build SHA bypass stay disabled +- unknown game code/revision or malformed ARM image ranges: reject/fail closed -- slice-resume root -- main image範囲外runtime RAM -- reused overlay virtual ranges -- ROM header entry PCの重複 +The launcher SHA check is disabled specifically so it cannot reject a mod before +these runner rules execute. -実ROM検証後に `config/mph_rom_profiles.json` のEU1.1 `coverage` をbootstrap JSONからこの昇格済みJSONへ切り替える。 +## 6. Content profiles and `base_profile` -## 7. Generated tree separation - -US1.0: - -```text -generated/inputs/ -generated/recomp/ -generated/capture/ -``` - -EU1.1: - -```text -generated/EU1_1/inputs/ -generated/EU1_1/recomp/ -generated/EU1_1/capture/ -``` - -異なるROM revisionのprepared binary、coverage capture、runtime image、generated bankを同じディレクトリへ混在させない。 - -## 8. Launcher identity / feature policy separation - -`launcher/recomp-ui/CMakeLists.txt` はbaseline `launcher_main.cpp` からprofile-specific generated TUを作る。 - -反映項目: - -- exact ROM SHA-1 -- Region -- default ROM filename -- Adaptive Widescreen availability/default - -EU1.1 generated launcher: - -```text -SHA-1: bdcd1dea293e24c98d4c481430e90d21198985a5 -Region: Europe -Default ROM: Metroid Prime Hunters (Europe Rev 1).nds -Adaptive Widescreen: disabled / UI hidden -``` - -EU1.1 Adaptive Widescreenは三重にfail-closed: - -1. mod listから非表示 -2. persisted `adaptive_widescreen=true` をload後にfalseへ戻す -3. `launch_runner()` 最終段でもprofile capabilityとANDする - -Prime ControlsはEU1.1でも表示する。必要なMorph/Aim address routingはROMなしunit testで固定済みだが、ゲーム内semantic correctnessは実ROMで確認する。 - -## 9. EU1.1 FMV runtime capture pipeline - -### 9.1 US1.0 bankの非流用 - -US1.0の既存bank: - -```text -config/mph_arm9_fmv_runtime.toml -generated/capture/mph_arm9_fmv_runtime.bin -bank id: mph_arm9_fmv_runtime -``` +`config/mph_rom_profiles.json` schema 5 separates content identity from runtime +base identity. -はEU1.1へ流用しない。 +Canonical clean profiles reserve the seven base keys. Example: -EU1.1用に予約しているidentity/path: - -```text -config/mph_amhp1_arm9_fmv_runtime.toml -generated/EU1_1/capture/mph_amhp1_arm9_fmv_runtime.bin -bank id: mph_amhp1_arm9_fmv_runtime -``` - -現在 `fmv_runtime=false` なので、EU1.1 buildはこのbankを要求・登録しない。 - -### 9.2 FMV benchmark/captureのprofile化 - -`tools/benchmark_mph_fmv.py` は現在: - -- `--version EU1_1` を受ける -- exact EU1.1 ROM identityを起動前に検証する -- `--config` 省略時は `config/game-eu11.toml` を選ぶ -- `--adaptive auto` が既定 -- EU1.1ではprofile policyに従い `--adaptive-widescreen none` -- profileがAdaptive Widescreen未検証なら明示的な `--adaptive top` も拒否する -- `benchmark.json` に `mph_profile` と `rom_sha1` を保存する -- `--capture-runtime` 時はcapture SHA-1とbyte countも保存する - -EU1.1を誤ってUS1.0のAdaptive Widescreen有効状態でbenchmark/coverage captureする経路はfail-closedにした。 - -### 9.3 Before window採取 - -例としてVBlank 2400までの累積coverageを取る。 - -```powershell -python tools\benchmark_mph_fmv.py ` - --version EU1_1 ` - --runner ..\ndsrecomp\runner\build-mph-release-EU1_1\nds_runner.exe ` - --bios bios ` - --rom "Metroid Prime Hunters (Europe Rev 1).nds" ` - --out generated\EU1_1\capture\fmv-before ` - --targets 2400 ` - --discover-static-misses -``` - -### 9.4 Target window + RAM capture - -```powershell -python tools\benchmark_mph_fmv.py ` - --version EU1_1 ` - --runner ..\ndsrecomp\runner\build-mph-release-EU1_1\nds_runner.exe ` - --bios bios ` - --rom "Metroid Prime Hunters (Europe Rev 1).nds" ` - --out generated\EU1_1\capture\fmv-3000 ` - --targets 2400 3000 ` - --discover-static-misses ` - --capture-runtime generated\EU1_1\capture\mph_amhp1_arm9_fmv_runtime.bin +```json +"US1_0": { + "base_profile": "US1_0", + "known_clean": true, + "game_code": "AMHE", + "revision": 0, + "sha1": "...exact clean whole-ROM SHA-1..." +} ``` -runtime imageはITCM + main RAMを連結した `0x00408000` bytes。 +A future exact mod profile must use a distinct key and reference the compatible +base layout: -### 9.5 EU1.1 runtime bank config昇格 - -```powershell -python tools\promote_mph_runtime_coverage.py ` - --version EU1_1 ` - --before-benchmark generated\EU1_1\capture\fmv-before\benchmark.json ` - --benchmark generated\EU1_1\capture\fmv-3000\benchmark.json ` - --image generated\EU1_1\capture\mph_amhp1_arm9_fmv_runtime.bin ` - --out config\mph_amhp1_arm9_fmv_runtime.toml +```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": false +} ``` -EU1.1では昇格時に以下を全て要求する。 - -- benchmarkの `mph_profile == EU1_1` -- benchmarkの `rom_sha1 == EU1.1 SHA-1` -- before benchmarkも同じidentity -- benchmark内 `runtime_capture.sha1` と渡した`.bin`の実SHA-1が一致 -- benchmark内capture byte countが `0x00408000` -- ARM9 runtime領域内のobserved call/indirect targetが存在 - -これらのどれかが不一致ならTOMLを生成しない。 - -実runtime validationとperformance確認後にのみ、EU1.1 profileの `fmv_runtime` を `true` へ変更する。 - -## 10. FMV runtime bank build/release routing - -CMakeはFMV bank名を固定しない。profileの `fmv_runtime_bank` から以下を導出する。 +The example is architectural only. Do not add a Last Raven profile until its +actual SHA-1, executable checksum, game config and generated/captured artifacts +are known and validated. -```text -config/.toml -generated//capture/.bin -generated//recomp/_*.c ---bank -``` +The static checker enforces that a mod cannot overwrite a canonical clean key. -Windows/Linux release gateもprofileのbank IDをrunner内で確認する。 +## 7. EU1.1 current exact-content identity -従って将来EU1.1で `fmv_runtime=true` にした場合も、US1.0の `mph_arm9_fmv_runtime` を誤要求しない。 +Current clean EU1.1 profile: -## 11. Profile-aware checkpoint validation +- 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: disabled until validated +- FMV runtime bank: disabled until an EU1.1-specific capture is validated -`tools/capture_mph_checkpoints.py` もprofile-awareにした。 +Reserved EU1.1 runtime bank identity: -runner/oracleのどちらを使う場合も、起動前に選択ROMをprofileのsize/SHA-1/game code/revisionへ照合する。runner時は `--config` 省略でprofileのgame configを選び、config identityもROM profileへ照合する。 +- 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` -各capture directoryには `metadata.json` を追加し、以下を保存する。 +No US1.0 FMV runtime capture is reused for EU1.1. -```text -mph_profile -rom_sha1 -display_name -backend -boot -targets -game_config -``` +## 8. Preparing a clean content profile -これによりUS1.0 native checkpointとEU1.1 oracle checkpoint等を誤って同一比較セットとして扱う前に、capture provenanceを確認できる。 +The preparation path intentionally remains exact-content gated. This is not the +runtime selector; it protects generated code provenance. -EU1.1例: +Example EU1.1 preparation: -```powershell -python tools\capture_mph_checkpoints.py ` - --version EU1_1 ` - --runner ..\ndsrecomp\runner\build-mph-release-EU1_1\nds_runner.exe ` - --bios bios ` - --rom "Metroid Prime Hunters (Europe Rev 1).nds" ` - --out generated\EU1_1\capture\checkpoints ` - --targets 300 600 900 1200 +```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 ``` -## 12. Build +`prepare_mph.py` checks: -### Windows +- exact whole-ROM SHA-1 +- expected ROM size +- game code at `0x0C` +- revision at `0x1E` +- coverage `game_sha1` -```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` - tools\build-windows.ps1 ` - -Version 0.3.0 ` - -MphVersion EU1_1 ` - -RomPath 'D:\ROMs\Metroid Prime Hunters (Europe) (Rev 1).nds' -``` - -`-RomPath`省略時はprofileの `launcher_default_rom` を使う。 +It then extracts ARM9, ARM7 and overlays and emits revision/content-specific +seed configs. -WindowsはEU1.1 identity verify -> extraction -> static bank -> runtime-profile patch -> exact EU SHA runner -> profile-specific launcher -> EU game config packagingまで一貫して行う。 +## 9. Building EU1.1 -### Linux +Typical CMake configuration: ```bash -tools/build-linux.sh \ - --mph-version EU1_1 \ - --rom '/path/to/Metroid Prime Hunters (Europe) (Rev 1).nds' +cmake -S . -B build-eu11 \ + -DMPH_VERSION=EU1_1 \ + -DMPH_ROM="/path/to/Metroid Prime Hunters (Europe Rev 1).nds" +cmake --build build-eu11 ``` -`--rom`省略時はEU1.1 profileのdefault ROM filenameをrepository rootから探す。AppRunはprofile別 `game.toml` をrunnerへ渡し、`--adaptive-widescreen` 等でtitle policyを上書きしない。 - -## 13. ROM不要static CI - -`.github/workflows/mph-multirom-static.yml` は以下を検証する。 - -1. capture/promotion/checkpointを含むPython syntax -2. Linux shell / Windows PowerShell syntax -3. profile / coverage / game config / launcher / FMV bank policy整合性 -4. CMake/Windowsがprofile keyを固定列挙しないこと -5. melonPrimeDS `MelonPrimeGameRomAddrTable.h` とのAim/Morph照合 -6. fake EU1.1 prepared ARM9/ARM7 geometryからstatic coverageを昇格 -7. main-image範囲外targetが除外されることを確認 -8. fake EU1.1 runtime image + tagged benchmarkからEU1.1 FMV TOMLを生成 -9. runtime image SHA/size metadata照合 -10. EU1.1 TOMLへUSA runtime名が混入しないことを確認 -11. CMake/Windows/Linuxがprofile-owned FMV bank IDを使用することを確認 -12. exact `ndsrecomp.pin` fetch -13. US1.0/EU1.1 launcher generated source renderとidentity/policy確認 -14. Linux AppRunのprofile-owned config policy確認 -15. ndsrecomp runtime patchのidempotency -16. US1.0固定Aim/Morph symbol除去確認 -17. patched runnerの `title_patches.cpp` / `frontend.cpp` / `main.cpp` compile -18. exact-ROM runtime dispatch unit test実行 -19. US1.0/EU1.1 `mph_romcheck` compile -20. `git diff --check` - -ROM、BIOS、firmware dumpはCIで取得しない。 - -## 14. mphCodexの役割 - -Aim X/Y/MorphはmelonPrimeDS tableをsource of truthとする。その他Recomp固有host enhancementのcross-version semantic調査にはmphCodexを利用する。 - -| Semantic | US1.0 | EU1.1 | -|---|---:|---:| -| Current Camera Sequence | `0x020D9CB0` | `0x020DA5D0` | -| Game Mode | `0x020E78FC` | `0x020E845C` | -| Upper HUD function | `0x0202F600` | `0x0202F5E0` | -| Crosshair callsite | `0x0202F934` | `0x0202F904` | -| Crosshair renderer | `0x020393D4` | `0x02039338` | -| Local Player Pointer | `0x020BCA70` | `0x020BD370` | -| HUD suppression storage | `0x020DE748` | `0x020DF068` | - -単一delta変換は使用しない。 - -## 15. 実ROMで残るvalidation gates - -### Gate A - extraction / bank generation - -EU1.1 identity accept、ARM9 decompress、ARM7 extraction、overlay列挙、EU1.1 bank生成、US1.0 artifact非混入。 - -### Gate B - boot - -firmware boot、cartridge handoff、opening logos/FMV、title、attract loop。 - -### Gate C - gameplay - -Adventure file作成/読込、Celestial Archives、movement/aim/shoot、Morph Ball、Scan Visor、pause、save/reload、multiplayer menu。 - -### Gate D - Prime Controls / Direct Mouse Aim semantics - -address routingはunit test済み。実ROMではnormal/Morph touch behavior、camera aim、menu/touch復帰、keyboard/gamepad操作を確認する。 +Windows and Linux build helpers consume the same registry-owned profile data. +Profile choices are derived from `profiles`, not a hard-coded two-profile list. -### Gate E - deterministic coverage +## 10. Coverage and capture safety -EU1.1自身のprofile-tagged execution traceからcoverageを採取し、EU1.1 prepared main-image geometryでfilterして昇格する。 +Static and runtime promotion is profile/content aware. -### Gate F - FMV runtime optimization +For non-US content, traces carry both: -必要な場合のみEU1.1 captureを作り、capture identity、content validation、correctness、performanceを確認してから `fmv_runtime=true` にする。 +- content profile key +- exact whole-ROM SHA-1 -### Gate G - Adaptive Widescreen +Main-image geometry is derived from the selected prepared ARM9/ARM7 configs, +not reused from US1.0 constants. -現在EU1.1では意図的に無効。基本対応の必須条件ではない。将来有効化する場合のみprojection、culling、HUD anchoring、touchscreen、特殊camera/visor sceneをEU1.1実ROMで検証し、profileとgame configを同時にenableする。 +FMV/runtime capture promotion verifies exact content provenance. A capture from +one content SHA must not be promoted into another content profile. -## 16. Supported判定 +## 11. Runtime safety tests -EU1.1をruntime検証済みsupportedと宣言するには、exact identity、EU1.1 ARM9/ARM7 banks、title/gameplay/save/load、Prime Controls/Direct Aim semantic validation、native/reference checkpoint比較、US1.0 regressionなしが必要である。 +CI patches the exact pinned ndsrecomp revision and compiles the patched runner +translation units. -Adaptive WidescreenとEU1.1 FMV runtime bankは基本correctnessの必須条件ではない。未検証機能はfail-closedを維持する。 +The runtime harness uses synthetic, non-copyrighted ROM images constructed to +produce the canonical melonPrimeDS executable checksums. It verifies: -## 17. 現在の判定 +- all seven runtime profiles dispatch the correct Aim/Morph addresses +- checksum-authoritative profiles allow host Aim/Morph access +- header-only fallback does not allow host RAM 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 EU1.1 Russian checksum gets EU1.1 RAM layout but cannot + reuse the clean EU1.1 build identity +- runtime patching is idempotent +- patched runner source files compile -### Code / infrastructure +## 12. Remaining work for full multi-ROM support -**READY FOR EU1.1 ROM VALIDATION AND PROFILE-TAGGED COVERAGE CAPTURE** +The detector/address architecture is now prepared for all seven base revisions, +but full player-facing support still requires exact content work per ROM: -ROMなしで可能なprofile、extraction routing、bank isolation、runtime address selection、launcher identity/feature gating、coverage capture metadata、static/runtime coverage promotion、profile-owned FMV bank routing、checkpoint identity validation、Windows/Linux packaging、static CIまで実装済み。 +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 semantics on real execution +7. compare checkpoints against the reference/native execution path +8. capture revision-specific FMV runtime code only where interpreter fallback needs optimization +9. regression-test US1.0 after each new profile -### Runtime correctness +For a modified ROM, additionally: -**NOT YET CLAIMED** +1. compute the exact whole-ROM SHA-1 +2. compute the melonPrimeDS-compatible header+ARM9+ARM7 CRC32 +3. determine/validate its runtime base layout +4. if code-modified, register the executable checksum only after 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 -EU1.1実ROMによるboot/gameplay/reference validationと実coverage/capture採取は別途必要である。 +Do not guess an unknown mod as US1.0 simply because its filename, region or +header resembles US1.0. \ No newline at end of file From 3221a4e2fadcb29b0d9ffbd2a911d1942b799ee3 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:50:02 +0900 Subject: [PATCH 85/97] Cross-check MPH detector table against melonPrimeDS --- tools/check_melonprime_detector.py | 85 ++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tools/check_melonprime_detector.py 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() From 4fbfc4360c4a0d6f2cc9aae9379f8ef96b477ec8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:50:41 +0900 Subject: [PATCH 86/97] Pin multi-ROM CI checks to melonPrimeDS develop_hud --- .github/workflows/mph-multirom-static.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index f335afb..329760a 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -25,6 +25,7 @@ jobs: tools/mph_profile.py \ tools/prepare_mph.py \ tools/check_mph_multirom_profiles.py \ + tools/check_melonprime_detector.py \ tools/patch_ndsrecomp_mph_runtime.py \ tools/promote_mph_static_coverage.py \ tools/promote_mph_runtime_coverage.py \ @@ -51,13 +52,19 @@ jobs: } if ($failed) { exit 1 } - - name: Cross-check runtime addresses against melonPrimeDS + - name: Cross-check runtime detector against melonPrimeDS develop_hud run: | curl -fsSL --retry 3 \ - https://raw.githubusercontent.com/ag-advania/melonPrimeDS/main/src/frontend/qt_sdl/MelonPrimeGameRomAddrTable.h \ + 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 - name: Test profile-aware coverage promotion run: | @@ -154,6 +161,8 @@ jobs: grep -q 'FMV_RUNTIME_BANK' tools/build-linux.sh grep -q 'FmvRuntimeBank' tools/build-windows.ps1 grep -q 'FmvRuntimeBank' tools/make_release.ps1 + grep -q 'rom_bytes\[0x1E\]' tools/prepare_mph.py + ! grep -q 'rom_bytes\[0x1C\]' tools/prepare_mph.py - name: Fetch exact pinned ndsrecomp revision run: | @@ -198,12 +207,16 @@ jobs: grep -q '90164d1ac127ee5f9815ea4ae7de798c7b5fc629' "$us" grep -q 'game.region = "USA";' "$us" + grep -q 'game.known_sha1_hex = nullptr;' "$us" + grep -q 'game.num_known_sha1 = 0;' "$us" grep -q 'bool adaptive_widescreen = true;' "$us" grep -A1 'int mod_feature_count' "$us" | grep -q 'return 2;' grep -q 'adaptive = adaptive && true;' "$us" grep -q 'bdcd1dea293e24c98d4c481430e90d21198985a5' "$eu" grep -q 'game.region = "Europe";' "$eu" + grep -q 'game.known_sha1_hex = nullptr;' "$eu" + grep -q 'game.num_known_sha1 = 0;' "$eu" grep -q 'exe / "Metroid Prime Hunters (Europe Rev 1).nds";' "$eu" grep -q 'bool adaptive_widescreen = false;' "$eu" grep -A1 'int mod_feature_count' "$eu" | grep -q 'return 1;' @@ -281,7 +294,7 @@ jobs: cmake --build /tmp/nds-runner-profile-check --target \ src/main.o -j2 - - name: Test exact-ROM runtime address dispatch + - name: Test runtime base-profile and compatibility dispatch run: | c++ -std=c++20 -Wall -Wextra -Wno-unused-parameter \ -I/tmp/ndsrecomp/runner/src \ From eaf9439c303b228c6a3efe15a23de39749ea1145 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 13:51:36 +0900 Subject: [PATCH 87/97] Describe runtime base-profile detector accurately --- tools/build-windows.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/build-windows.ps1 b/tools/build-windows.ps1 index 27b6e5f..fe66d92 100644 --- a/tools/build-windows.ps1 +++ b/tools/build-windows.ps1 @@ -3,8 +3,8 @@ Build Metroid Prime Hunters Recomp for one configured retail revision. 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 exact-ROM runtime-address shim for Prime -Controls/direct mouse aim. +identity/policy, and the shared runtime base-profile / executable-compatibility +detector for Prime Controls/direct mouse aim. Usage: powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` @@ -159,4 +159,4 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Release packaging failed.' } } finally { Pop-Location -} +} \ No newline at end of file From 97f2d9292979910efb46a37df0906f60cb85dd31 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:08:48 +0900 Subject: [PATCH 88/97] Sync upstream Wi-Fi state and profile-aware widescreen --- README.md | 66 ++- config/game-eu11.toml | 12 +- config/mph_rom_profiles.json | 152 +++-- game.toml | 5 +- launcher/recomp-ui/CMakeLists.txt | 28 +- ndsrecomp.pin | 2 +- tools/check_mph_multirom_profiles.py | 545 ++++-------------- tools/check_mph_multirom_profiles_legacy.py | 475 +++++++++++++++ tools/mph_screens.py | 2 + tools/patch_ndsrecomp_mph_runtime.py | 536 +---------------- tools/patch_ndsrecomp_mph_runtime_core.py | 535 +++++++++++++++++ tools/patch_ndsrecomp_mph_widescreen.py | 266 +++++++++ tools/patch_ndsrecomp_mph_widescreen_reset.py | 39 ++ tools/probe_mph_online_first_run.py | 361 ++++++++++++ tools/probe_mph_wfc.py | 28 +- 15 files changed, 1984 insertions(+), 1068 deletions(-) create mode 100755 tools/check_mph_multirom_profiles_legacy.py create mode 100755 tools/patch_ndsrecomp_mph_runtime_core.py create mode 100755 tools/patch_ndsrecomp_mph_widescreen.py create mode 100755 tools/patch_ndsrecomp_mph_widescreen_reset.py create mode 100644 tools/probe_mph_online_first_run.py diff --git a/README.md b/README.md index c22a26e..26f91cd 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,7 +22,7 @@ Click the image to watch the gameplay preview on YouTube. ## Current Release -Latest release: +Latest upstream release: **[v0.3.0-alpha](https://github.com/mstan/MetroidPrimeHuntersRecomp/releases/tag/v0.3.0-alpha)**. Downloads: @@ -29,25 +32,21 @@ Downloads: - Linux: `MetroidPrimeHuntersRecomp-linux-x86_64-v0.3.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. ## 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,26 +55,29 @@ 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. - Supports mouse-driven touchscreen input. @@ -139,6 +141,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 +159,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 index 1778a05..f1e62d5 100644 --- a/config/game-eu11.toml +++ b/config/game-eu11.toml @@ -9,11 +9,19 @@ rom_size = 0x04000000 sha1 = "bdcd1dea293e24c98d4c481430e90d21198985a5" [display] -# Keep EU1.1 bootstrap on native presentation until title-specific projection, -# culling and HUD-address assumptions are validated for this revision. 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" diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index 13e806e..a82e670 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -3,6 +3,49 @@ "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", + "identity_policy": { + "runtime_base_profile": "melonPrimeDS executable checksum first; exact game_code + supported revision is fallback identification only", + "host_write_gate": "Aim, Morph, and Adaptive Widescreen host RAM/code access requires an authoritative known executable checksum", + "known_clean_identity": "whole-ROM SHA-1 identifies an exact clean content image but never selects runtime addresses", + "actual_content_identity": "whole-ROM SHA-1 namespaces generated banks, coverage, checkpoints, and FMV captures so distinct mods never share generated content" + }, + "profiles": { + "US1_0": { + "base_profile": "US1_0", + "known_clean": true, + "display_name": "USA v1.0", + "game_code": "AMHE", + "region": "USA", + "revision": 0, + "rom_size": 67108864, + "sha1": "90164d1ac127ee5f9815ea4ae7de798c7b5fc629", + "sha256": "7d0a98ff98e1b7c985d1f3d89b01730af1b2115061a4dfea847612d217a8b855", + "coverage": "coverage/adventure-main-entry-points.json", + "game_config": "game.toml", + "fmv_runtime": true, + "fmv_runtime_bank": "mph_arm9_fmv_runtime", + "adaptive_widescreen": true, + "launcher_default_rom": "Metroid Prime Hunters.nds" + }, + "EU1_1": { + "base_profile": "EU1_1", + "known_clean": true, + "display_name": "Europe v1.1", + "game_code": "AMHP", + "region": "Europe", + "revision": 1, + "rom_size": 67108864, + "sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", + "coverage": "coverage/eu11-bootstrap-entry-points.json", + "game_config": "config/game-eu11.toml", + "fmv_runtime": false, + "fmv_runtime_bank": "mph_amhp1_arm9_fmv_runtime", + "adaptive_widescreen": true, + "launcher_default_rom": "Metroid Prime Hunters (Europe Rev 1).nds" + } + }, "runtime_profiles": { "US1_0": { "game_code": "AMHE", @@ -11,7 +54,10 @@ "runtime": { "morph_state": "0x020DA818", "aim_x": "0x020DE526", - "aim_y": "0x020DE52E" + "aim_y": "0x020DE52E", + "scale_patch_addr1": "0x02110FFC", + "scale_patch_addr2": "0x0211C638", + "scale_value_addr": "0x02110820" } }, "US1_1": { @@ -21,7 +67,10 @@ "runtime": { "morph_state": "0x020DB098", "aim_x": "0x020DEDA6", - "aim_y": "0x020DEDAE" + "aim_y": "0x020DEDAE", + "scale_patch_addr1": "0x02111ABC", + "scale_patch_addr2": "0x0211D168", + "scale_value_addr": "0x021112E0" } }, "EU1_0": { @@ -31,7 +80,10 @@ "runtime": { "morph_state": "0x020DB0B8", "aim_x": "0x020DEDC6", - "aim_y": "0x020DEDCE" + "aim_y": "0x020DEDCE", + "scale_patch_addr1": "0x02111ADC", + "scale_patch_addr2": "0x0211D114", + "scale_value_addr": "0x02111300" } }, "EU1_1": { @@ -41,7 +93,10 @@ "runtime": { "morph_state": "0x020DB138", "aim_x": "0x020DEE46", - "aim_y": "0x020DEE4E" + "aim_y": "0x020DEE4E", + "scale_patch_addr1": "0x02111B5C", + "scale_patch_addr2": "0x0211D208", + "scale_value_addr": "0x02111380" } }, "JP1_0": { @@ -51,7 +106,10 @@ "runtime": { "morph_state": "0x020DC6D8", "aim_x": "0x020E03E6", - "aim_y": "0x020E03EE" + "aim_y": "0x020E03EE", + "scale_patch_addr1": "0x0211313C", + "scale_patch_addr2": "0x0211E7E8", + "scale_value_addr": "0x02112960" } }, "JP1_1": { @@ -61,7 +119,10 @@ "runtime": { "morph_state": "0x020DC698", "aim_x": "0x020E03A6", - "aim_y": "0x020E03AE" + "aim_y": "0x020E03AE", + "scale_patch_addr1": "0x021130FC", + "scale_patch_addr2": "0x0211E7A8", + "scale_value_addr": "0x02112920" } }, "KR1_0": { @@ -71,63 +132,30 @@ "runtime": { "morph_state": "0x020D3EE4", "aim_x": "0x020D7C0E", - "aim_y": "0x020D7C16" + "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": false - } - } -} \ No newline at end of file + {"crc32": "0x218DA42C", "profile": "US1_0", "name": "USA v1.0"}, + {"crc32": "0x91B46577", "profile": "US1_1", "name": "USA v1.1"}, + {"crc32": "0xA4A8FE5A", "profile": "EU1_0", "name": "Europe v1.0"}, + {"crc32": "0x910018A5", "profile": "EU1_1", "name": "Europe v1.1"}, + {"crc32": "0xD75F539D", "profile": "JP1_0", "name": "Japan v1.0"}, + {"crc32": "0x42EBF348", "profile": "JP1_1", "name": "Japan v1.1"}, + {"crc32": "0xE54682F3", "profile": "KR1_0", "name": "Korea v1.0"}, + {"crc32": "0x5E596D48", "profile": "US1_0", "name": "USA v1.0 encrypted"}, + {"crc32": "0xF4D3CC2C", "profile": "US1_1", "name": "USA v1.1 encrypted"}, + {"crc32": "0x5D9E56DA", "profile": "EU1_0", "name": "Europe v1.0 encrypted"}, + {"crc32": "0x3A07BD61", "profile": "EU1_1", "name": "Europe v1.1 encrypted"}, + {"crc32": "0xB89E71A3", "profile": "JP1_0", "name": "Japan v1.0 encrypted"}, + {"crc32": "0x16852A2C", "profile": "JP1_1", "name": "Japan v1.1 encrypted"}, + {"crc32": "0x3FA948F1", "profile": "KR1_0", "name": "Korea v1.0 encrypted"}, + {"crc32": "0xD8E3D7F0", "profile": "EU1_1", "name": "Europe v1.1 Balanced"}, + {"crc32": "0x60A938F2", "profile": "EU1_1", "name": "Europe v1.1 Balanced encrypted"}, + {"crc32": "0x0F4C5A07", "profile": "EU1_1", "name": "Europe v1.1 Russian"} + ] +} 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 5d7dc81..8ac39f9 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -22,7 +22,7 @@ option(MPH_LAUNCHER_ADAPTIVE_WIDESCREEN # Keep launcher_main.cpp as the readable US1.0 baseline, but compile a # profile-specific generated TU. Every replacement below is preimage-guarded: # if the baseline launcher changes, configure fails instead of silently -# applying a stale multi-ROM transformation. +# applying a stale multi-ROM/upstream transformation. file(READ "${CMAKE_CURRENT_SOURCE_DIR}/launcher_main.cpp" MPH_LAUNCHER_SOURCE) function(mph_launcher_replace_required old new description) @@ -85,6 +85,26 @@ mph_launcher_replace_required( "const std::wstring rom_wide = widen(rom);\n if (rom_wide.empty()) return false;\n adaptive = adaptive && ${_mph_adaptive_literal};" "the runner launch profile gate") +# Upstream 5abcfee: persist mutable DS firmware/WFC state across launches. +# This is injected into the generated profile TU so the readable baseline can +# remain stable while all region launchers receive the same behavior. +mph_launcher_replace_required( + " return text;\n}\n\ntemplate \nvoid copy_text" + " return text;\n}\n\nstd::filesystem::path firmware_state_path(\n const std::filesystem::path& settings_path, bool generated) {\n return settings_path.parent_path() /\n (generated ? \"firmware-generated.bin\" : \"firmware-retail.bin\");\n}\n\nstd::string read_firmware_state_mac(const std::filesystem::path& path) {\n std::ifstream file(path, std::ios::binary);\n if (!file) return {};\n file.seekg(0x36, std::ios::beg);\n unsigned char mac[6]{};\n file.read(reinterpret_cast(mac), sizeof(mac));\n if (file.gcount() != static_cast(sizeof(mac)) ||\n (mac[0] & 0x01u))\n return {};\n char text[32];\n std::snprintf(text, sizeof(text), \"%02X:%02X:%02X:%02X:%02X:%02X\",\n mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);\n return text;\n}\n\ntemplate \nvoid copy_text" + "the upstream firmware-state helper insertion point") +mph_launcher_replace_required( + " }\n\n std::wstring command =\n quote(runner.wstring())" + " }\n const std::filesystem::path firmware_state = firmware_state_path(\n mods.settings_path, no_dumps_mode);\n\n std::wstring command =\n quote(runner.wstring())" + "the upstream firmware-state launch path") +mph_launcher_replace_required( + " L\" --network on --wfc on --wfc-provider wiimmfi\";\n // beads-yjp.16:" + " L\" --network on --wfc on --wfc-provider wiimmfi\";\n append_arg(command, L\"--firmware-state-path\", firmware_state.wstring());\n // beads-yjp.16:" + "the upstream firmware-state runner argument") +mph_launcher_replace_required( + " // Read-only identity detail for the dashboard ONLINE card. Captured once\n // at startup: the MAC only changes on the first-ever no-dump launch.\n const std::string identity_mac = read_identity_mac(\n mod_state.bios_path.empty()\n ? mod_state.default_bios_dir\n : bios_dir_from_setting(mod_state.bios_path.c_str()));\n const std::string identity_detail =\n !identity_mac.empty()\n ? \"Console MAC: \" + identity_mac + \" (generated identity)\"\n : std::string(\"Console MAC: from the firmware dump, or created \"\n \"on the first no-dump launch.\");" + " // Read-only identity detail for the dashboard ONLINE card. Prefer the\n // mutable profile once it has been seeded; generated mode falls back to\n // the installation identity before that first launch.\n const std::filesystem::path selected_bios =\n mod_state.bios_path.empty()\n ? mod_state.default_bios_dir\n : bios_dir_from_setting(mod_state.bios_path.c_str());\n bool generated_identity = false;\n if (mod_state.bios_path.empty()) {\n bool conventional_dumps = true;\n for (const NdsDump& dump : kNdsDumps) {\n if (!std::filesystem::is_regular_file(selected_bios / dump.file))\n conventional_dumps = false;\n }\n generated_identity = !conventional_dumps;\n }\n std::string identity_mac = read_firmware_state_mac(firmware_state_path(\n mod_state.settings_path, generated_identity));\n if (identity_mac.empty() && generated_identity)\n identity_mac = read_identity_mac(selected_bios);\n const std::string identity_detail =\n !identity_mac.empty()\n ? \"Console MAC: \" + identity_mac +\n (generated_identity\n ? \" (generated identity)\" : \" (firmware profile)\")\n : std::string(\"Console MAC: from the firmware dump, or created \"\n \"on the first no-dump launch.\");" + "the upstream dashboard firmware identity block") + set(MPH_PROFILE_LAUNCHER_SOURCE "${CMAKE_CURRENT_BINARY_DIR}/launcher_main_profile.cpp") file(WRITE "${MPH_PROFILE_LAUNCHER_SOURCE}" "${MPH_LAUNCHER_SOURCE}") @@ -102,10 +122,6 @@ 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. target_include_directories(mph-mod-provider-test PRIVATE "${NDSRECOMP_ROOT}/recompiler/support" "${CMAKE_CURRENT_SOURCE_DIR}" @@ -120,4 +136,4 @@ include("${RECOMP_UI_ROOT}/recomp_ui.cmake") recomp_target_launcher_ui(mph-recomp-ui CONSOLE nds BOXART "${CMAKE_CURRENT_SOURCE_DIR}/assets/boxart.tga") -add_test(NAME mph_mod_provider_test COMMAND mph-mod-provider-test) \ No newline at end of file +add_test(NAME mph_mod_provider_test COMMAND mph-mod-provider-test) 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/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py index 949eb14..f941e6c 100755 --- a/tools/check_mph_multirom_profiles.py +++ b/tools/check_mph_multirom_profiles.py @@ -1,475 +1,138 @@ #!/usr/bin/env python3 -"""Static consistency checks for Metroid Prime Hunters ROM profiles. +"""Validate the current multi-ROM schema, then run the legacy deep checks. -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. +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 tomllib +import shutil +import tempfile 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_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), } -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) +SCALE_FIELDS = ("scale_patch_addr1", "scale_patch_addr2", "scale_value_addr") -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) +def die(message: str) -> None: + raise SystemExit(message) - 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}" - ) +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 - 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: +def validate_scale_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) + 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) 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() + if not isinstance(profiles, dict): + die("content profiles missing") 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, + 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") + row_names = { + "scale_patch_addr1": "ScalePatchAddr1", + "scale_patch_addr2": "ScalePatchAddr2", + "scale_value_addr": "ScaleValueAddr", } - 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}" + order = ("JP1_0", "JP1_1", "US1_0", "US1_1", "EU1_0", "EU1_1", "KR1_0") + for field, list_name in row_names.items(): + match = re.search( + rf"X\(ADDR,\s*{field},\s*{list_name},\s*([^\n]+)\)", text ) - - 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 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"), ) - if melon is not None: - print(f"OK: all seven Aim/Morph profiles match melonPrimeDS table: {table}") + 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, - help="Downloaded MelonPrimeGameRomAddrTable.h to cross-check", - ) + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--melonprime-table", type=Path) args = parser.parse_args() - validate_registry(args.repo.resolve(), args.melonprime_table) + 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() \ No newline at end of file + 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/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 index 6597a56..2bc0249 100755 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -1,534 +1,28 @@ #!/usr/bin/env python3 -"""Apply the MPH multi-ROM runtime-profile shim to the pinned ndsrecomp runner. +"""Apply the MPH runtime-profile patch stack 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. +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 argparse -import json -import re +import subprocess +import sys 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()) + 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__": 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/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) From b354dc53e9e0bdfb4937cc6e959b7ac8e7ecd01e Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:09:53 +0900 Subject: [PATCH 89/97] Fix melonPrime widescreen table cross-check --- tools/check_mph_multirom_profiles.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/check_mph_multirom_profiles.py b/tools/check_mph_multirom_profiles.py index f941e6c..eee8dc5 100755 --- a/tools/check_mph_multirom_profiles.py +++ b/tools/check_mph_multirom_profiles.py @@ -78,15 +78,15 @@ def validate_scale_registry(repo: Path, table: Path | None) -> None: if table: text = table.read_text(encoding="utf-8") - row_names = { - "scale_patch_addr1": "ScalePatchAddr1", - "scale_patch_addr2": "ScalePatchAddr2", - "scale_value_addr": "ScaleValueAddr", + 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, list_name in row_names.items(): + for field, (member_name, list_name) in rows.items(): match = re.search( - rf"X\(ADDR,\s*{field},\s*{list_name},\s*([^\n]+)\)", text + rf"X\(ADDR,\s*{member_name},\s*{list_name},\s*([^\n]+)\)", text ) if not match: die(f"melonPrimeDS table missing {list_name}") From e916b9693858b86dcec4cd4fe2057b460065bb39 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:11:33 +0900 Subject: [PATCH 90/97] Refresh melonPrime executable checksum table --- config/mph_rom_profiles.json | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index a82e670..a9215e4 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -140,22 +140,22 @@ } }, "runtime_checksums": [ - {"crc32": "0x218DA42C", "profile": "US1_0", "name": "USA v1.0"}, - {"crc32": "0x91B46577", "profile": "US1_1", "name": "USA v1.1"}, - {"crc32": "0xA4A8FE5A", "profile": "EU1_0", "name": "Europe v1.0"}, - {"crc32": "0x910018A5", "profile": "EU1_1", "name": "Europe v1.1"}, - {"crc32": "0xD75F539D", "profile": "JP1_0", "name": "Japan v1.0"}, - {"crc32": "0x42EBF348", "profile": "JP1_1", "name": "Japan v1.1"}, - {"crc32": "0xE54682F3", "profile": "KR1_0", "name": "Korea v1.0"}, - {"crc32": "0x5E596D48", "profile": "US1_0", "name": "USA v1.0 encrypted"}, - {"crc32": "0xF4D3CC2C", "profile": "US1_1", "name": "USA v1.1 encrypted"}, - {"crc32": "0x5D9E56DA", "profile": "EU1_0", "name": "Europe v1.0 encrypted"}, - {"crc32": "0x3A07BD61", "profile": "EU1_1", "name": "Europe v1.1 encrypted"}, - {"crc32": "0xB89E71A3", "profile": "JP1_0", "name": "Japan v1.0 encrypted"}, - {"crc32": "0x16852A2C", "profile": "JP1_1", "name": "Japan v1.1 encrypted"}, - {"crc32": "0x3FA948F1", "profile": "KR1_0", "name": "Korea v1.0 encrypted"}, - {"crc32": "0xD8E3D7F0", "profile": "EU1_1", "name": "Europe v1.1 Balanced"}, - {"crc32": "0x60A938F2", "profile": "EU1_1", "name": "Europe v1.1 Balanced encrypted"}, - {"crc32": "0x0F4C5A07", "profile": "EU1_1", "name": "Europe v1.1 Russian"} + {"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"} ] } From d5b6ea460d4f82ac29cfdf4d674bb400b4fa36ac Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:12:50 +0900 Subject: [PATCH 91/97] Restore complete build profile metadata --- config/mph_rom_profiles.json | 79 +++++++++++++++++------------------- 1 file changed, 37 insertions(+), 42 deletions(-) diff --git a/config/mph_rom_profiles.json b/config/mph_rom_profiles.json index a9215e4..838996a 100644 --- a/config/mph_rom_profiles.json +++ b/config/mph_rom_profiles.json @@ -5,47 +5,6 @@ "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", - "identity_policy": { - "runtime_base_profile": "melonPrimeDS executable checksum first; exact game_code + supported revision is fallback identification only", - "host_write_gate": "Aim, Morph, and Adaptive Widescreen host RAM/code access requires an authoritative known executable checksum", - "known_clean_identity": "whole-ROM SHA-1 identifies an exact clean content image but never selects runtime addresses", - "actual_content_identity": "whole-ROM SHA-1 namespaces generated banks, coverage, checkpoints, and FMV captures so distinct mods never share generated content" - }, - "profiles": { - "US1_0": { - "base_profile": "US1_0", - "known_clean": true, - "display_name": "USA v1.0", - "game_code": "AMHE", - "region": "USA", - "revision": 0, - "rom_size": 67108864, - "sha1": "90164d1ac127ee5f9815ea4ae7de798c7b5fc629", - "sha256": "7d0a98ff98e1b7c985d1f3d89b01730af1b2115061a4dfea847612d217a8b855", - "coverage": "coverage/adventure-main-entry-points.json", - "game_config": "game.toml", - "fmv_runtime": true, - "fmv_runtime_bank": "mph_arm9_fmv_runtime", - "adaptive_widescreen": true, - "launcher_default_rom": "Metroid Prime Hunters.nds" - }, - "EU1_1": { - "base_profile": "EU1_1", - "known_clean": true, - "display_name": "Europe v1.1", - "game_code": "AMHP", - "region": "Europe", - "revision": 1, - "rom_size": 67108864, - "sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", - "coverage": "coverage/eu11-bootstrap-entry-points.json", - "game_config": "config/game-eu11.toml", - "fmv_runtime": false, - "fmv_runtime_bank": "mph_amhp1_arm9_fmv_runtime", - "adaptive_widescreen": true, - "launcher_default_rom": "Metroid Prime Hunters (Europe Rev 1).nds" - } - }, "runtime_profiles": { "US1_0": { "game_code": "AMHE", @@ -157,5 +116,41 @@ {"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 + } + } } From 221375c8b37fb56acfb8354269d90cc8554a5356 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:15:17 +0900 Subject: [PATCH 92/97] Document profile-aware widescreen and upstream Wi-Fi state --- docs/EU1_1_BRINGUP.md | 240 ++++++++++++++++++++++++++++-------------- 1 file changed, 161 insertions(+), 79 deletions(-) diff --git a/docs/EU1_1_BRINGUP.md b/docs/EU1_1_BRINGUP.md index 9e590a8..0871764 100644 --- a/docs/EU1_1_BRINGUP.md +++ b/docs/EU1_1_BRINGUP.md @@ -1,7 +1,10 @@ # Metroid Prime Hunters Multi-ROM / EU1.1 Bring-up This document describes the current multi-ROM architecture in this branch. -It supersedes the earlier SHA-1-driven runtime-profile design. +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 @@ -18,6 +21,10 @@ revisions: | `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 @@ -29,7 +36,7 @@ they can be called fully supported. ## 2. Sources of truth -Runtime detection and addresses intentionally follow melonPrimeDS +Runtime detection and Aim/Morph addresses intentionally follow melonPrimeDS `develop_hud`: - detector: `src/frontend/qt_sdl/MelonPrimeGameRomDetect.cpp` @@ -37,24 +44,26 @@ Runtime detection and addresses intentionally follow melonPrimeDS - executable checksum algorithm: `src/NDSCart/CartCommon.cpp`, `CartCommon::Checksum()` - CRC32 implementation: `src/CRC32.cpp` -Repository: +Adaptive Widescreen additionally uses: -`https://github.com/ag-advania/melonPrimeDS/tree/develop_hud` +- 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 project registry records these source locations in +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. +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 RAM addresses such as Aim X/Y and Morph / Alt Form. +owns revision-specific host addresses for Aim, Morph and Adaptive Widescreen. The runtime detector uses: @@ -66,7 +75,7 @@ NDS header offsets: - game code: `0x0C..0x0F` - ROM revision/version: `0x1E` -The Recomp detector is deliberately stricter than melonPrimeDS's generic +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. @@ -79,14 +88,15 @@ melonPrimeDS `CartCommon::Checksum()` is reproduced exactly: 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 RAM access. +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 reads/writes**. Unknown executable content remains -fail-closed. +**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 memory-compatible US1.0. +blindly treated as executable-compatible US1.0. ### 3.3 Actual Content Identity @@ -105,48 +115,96 @@ 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. Known melonPrimeDS executable checksums +## 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. -The current registry mirrors the audited `develop_hud` detector table, -including: +Current entries include: - all seven clean retail revisions -- encrypted variants -- EU1.1 Balanced variants -- EU1.1 Russian variant (`0x9E20F3A8`) +- 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. -A known checksum establishes the runtime RAM layout. It does **not** by itself -mean the clean recomp build is byte-compatible with that modified executable. +melonPrimeDS and mphCodex identify three revision-specific locations: -For example, the known EU1.1 Russian executable can use the EU1.1 Aim/Morph RAM -layout, but because its executable checksum differs from canonical clean EU1.1, -it still requires a mod-specific exact build/capture content profile. +| 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` | -`Last Raven` is not present in the current audited `develop_hud` checksum table. -It therefore remains fail-closed for host Aim/Morph access until its executable -checksum/layout is explicitly validated. +For 21:9 the runner mirrors melonPrimeDS's guarded patch semantics: -## 5. Clean ROM versus modified ROM startup +- 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 can decide safely. +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 -- code-modified executable checksum: exact clean SHA gate is not bypassed; - prepare a mod-specific build/capture profile +- 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 - host Aim/Morph RAM access and clean-build SHA bypass stay disabled + 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. -## 6. Content profiles and `base_profile` +## 7. Content profiles and `base_profile` `config/mph_rom_profiles.json` schema 5 separates content identity from runtime base identity. @@ -179,17 +237,16 @@ base layout: "fmv_runtime": false, "fmv_runtime_bank": "mph_amhe0_last_raven_arm9_fmv_runtime", "launcher_default_rom": "Metroid Prime Hunters - Last Raven.nds", - "adaptive_widescreen": false + "adaptive_widescreen": true } ``` -The example is architectural only. Do not add a Last Raven profile until its -actual SHA-1, executable checksum, game config and generated/captured artifacts -are known and validated. - -The static checker enforces that a mod cannot overwrite a canonical clean key. +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. -## 7. EU1.1 current exact-content identity +## 8. EU1.1 exact-content identity Current clean EU1.1 profile: @@ -199,7 +256,7 @@ Current clean EU1.1 profile: - revision: `1` - whole-ROM SHA-1: `bdcd1dea293e24c98d4c481430e90d21198985a5` - default launcher ROM: `Metroid Prime Hunters (Europe Rev 1).nds` -- Adaptive Widescreen: disabled until validated +- Adaptive Widescreen: exposed and revision-aware - FMV runtime bank: disabled until an EU1.1-specific capture is validated Reserved EU1.1 runtime bank identity: @@ -210,10 +267,40 @@ Reserved EU1.1 runtime bank identity: No US1.0 FMV runtime capture is reused for EU1.1. -## 8. Preparing a clean content profile +## 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: -The preparation path intentionally remains exact-content gated. This is not the -runtime selector; it protects generated code provenance. +`%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: @@ -233,24 +320,10 @@ python tools/prepare_mph.py \ - revision at `0x1E` - coverage `game_sha1` -It then extracts ARM9, ARM7 and overlays and emits revision/content-specific -seed configs. - -## 9. Building EU1.1 - -Typical CMake configuration: - -```bash -cmake -S . -B build-eu11 \ - -DMPH_VERSION=EU1_1 \ - -DMPH_ROM="/path/to/Metroid Prime Hunters (Europe Rev 1).nds" -cmake --build build-eu11 -``` +It then extracts ARM9, ARM7 and overlays and emits content-specific seed +configs. -Windows and Linux build helpers consume the same registry-owned profile data. -Profile choices are derived from `profiles`, not a hard-coded two-profile list. - -## 10. Coverage and capture safety +## 11. Coverage and capture safety Static and runtime promotion is profile/content aware. @@ -265,49 +338,58 @@ 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. -## 11. Runtime safety tests +## 12. CI and runtime safety tests -CI patches the exact pinned ndsrecomp revision and compiles the patched runner -translation units. +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 -- checksum-authoritative profiles allow host Aim/Morph access -- header-only fallback does not allow host RAM access +- 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 EU1.1 Russian checksum gets EU1.1 RAM layout but cannot - reuse the clean EU1.1 build identity -- runtime patching is idempotent -- patched runner source files compile +- 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. -## 12. Remaining work for full multi-ROM support +## 13. Remaining work for full multi-ROM support -The detector/address architecture is now prepared for all seven base revisions, -but full player-facing support still requires exact content work per ROM: +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 semantics on real execution -7. compare checkpoints against the reference/native execution path -8. capture revision-specific FMV runtime code only where interpreter fallback needs optimization +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/validate its runtime base layout -4. if code-modified, register the executable checksum only after address compatibility is established +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. \ No newline at end of file +header resembles US1.0. From 3ef986841689e359a086311a15b5e10545c0c1f8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:17:28 +0900 Subject: [PATCH 93/97] Sync upstream HD launcher on multi-ROM profile layer --- launcher/recomp-ui/CMakeLists.txt | 66 +----- launcher/recomp-ui/launcher_main.cpp | 211 +++++++++++++++++- .../tests/launcher_mod_provider_test.cpp | 92 +++++++- 3 files changed, 298 insertions(+), 71 deletions(-) diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index 8ac39f9..cbf4a90 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -16,13 +16,12 @@ 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") -option(MPH_LAUNCHER_ADAPTIVE_WIDESCREEN - "Expose and allow the title-specific adaptive widescreen enhancement" ON) -# Keep launcher_main.cpp as the readable US1.0 baseline, but compile a -# profile-specific generated TU. Every replacement below is preimage-guarded: -# if the baseline launcher changes, configure fails instead of silently -# applying a stale multi-ROM/upstream transformation. +# 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) @@ -36,18 +35,10 @@ function(mph_launcher_replace_required old new description) set(MPH_LAUNCHER_SOURCE "${_mph_replaced_source}" PARENT_SCOPE) endfunction() -if(MPH_LAUNCHER_ADAPTIVE_WIDESCREEN) - set(_mph_adaptive_literal "true") - set(_mph_mod_count "2") -else() - set(_mph_adaptive_literal "false") - set(_mph_mod_count "1") -endif() - mph_launcher_replace_required( "90164d1ac127ee5f9815ea4ae7de798c7b5fc629" "${MPH_LAUNCHER_ROM_SHA1}" - "the US1.0 SHA-1 baseline") + "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;" @@ -60,50 +51,6 @@ mph_launcher_replace_required( "exe / \"Metroid Prime Hunters.nds\";" "exe / \"${MPH_LAUNCHER_DEFAULT_ROM}\";" "the default MPH ROM filename") -mph_launcher_replace_required( - "bool adaptive_widescreen = true;" - "bool adaptive_widescreen = ${_mph_adaptive_literal};" - "the adaptive-widescreen default") -mph_launcher_replace_required( - "int mod_feature_count(void*) {\n return 2;\n}" - "int mod_feature_count(void*) {\n return ${_mph_mod_count};\n}" - "the two-feature mod count") -mph_launcher_replace_required( - "if (!context || !output || index < 0 || index > 1) return 0;\n const auto* state = static_cast(context);" - "if (!context || !output || index < 0 || index >= ${_mph_mod_count}) return 0;\n if (!${_mph_adaptive_literal}) ++index;\n const auto* state = static_cast(context);" - "the mod feature index guard") -mph_launcher_replace_required( - "if (std::strcmp(package_id, \"mph-adaptive-widescreen\") == 0 &&\n std::strcmp(feature_id, \"adaptive-widescreen\") == 0) {\n state->adaptive_widescreen = enabled != 0;\n return 1;\n }" - "if (std::strcmp(package_id, \"mph-adaptive-widescreen\") == 0 &&\n std::strcmp(feature_id, \"adaptive-widescreen\") == 0) {\n if (!${_mph_adaptive_literal}) {\n state->adaptive_widescreen = false;\n return 0;\n }\n state->adaptive_widescreen = enabled != 0;\n return 1;\n }" - "the adaptive-widescreen enable handler") -mph_launcher_replace_required( - "load_mod_state(mod_state);\n RecompLauncherCModProvider mod_provider = make_mod_provider(&mod_state);" - "load_mod_state(mod_state);\n if (!${_mph_adaptive_literal}) mod_state.adaptive_widescreen = false;\n RecompLauncherCModProvider mod_provider = make_mod_provider(&mod_state);" - "the post-load mod-state initialization") -mph_launcher_replace_required( - "const std::wstring rom_wide = widen(rom);\n if (rom_wide.empty()) return false;" - "const std::wstring rom_wide = widen(rom);\n if (rom_wide.empty()) return false;\n adaptive = adaptive && ${_mph_adaptive_literal};" - "the runner launch profile gate") - -# Upstream 5abcfee: persist mutable DS firmware/WFC state across launches. -# This is injected into the generated profile TU so the readable baseline can -# remain stable while all region launchers receive the same behavior. -mph_launcher_replace_required( - " return text;\n}\n\ntemplate \nvoid copy_text" - " return text;\n}\n\nstd::filesystem::path firmware_state_path(\n const std::filesystem::path& settings_path, bool generated) {\n return settings_path.parent_path() /\n (generated ? \"firmware-generated.bin\" : \"firmware-retail.bin\");\n}\n\nstd::string read_firmware_state_mac(const std::filesystem::path& path) {\n std::ifstream file(path, std::ios::binary);\n if (!file) return {};\n file.seekg(0x36, std::ios::beg);\n unsigned char mac[6]{};\n file.read(reinterpret_cast(mac), sizeof(mac));\n if (file.gcount() != static_cast(sizeof(mac)) ||\n (mac[0] & 0x01u))\n return {};\n char text[32];\n std::snprintf(text, sizeof(text), \"%02X:%02X:%02X:%02X:%02X:%02X\",\n mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);\n return text;\n}\n\ntemplate \nvoid copy_text" - "the upstream firmware-state helper insertion point") -mph_launcher_replace_required( - " }\n\n std::wstring command =\n quote(runner.wstring())" - " }\n const std::filesystem::path firmware_state = firmware_state_path(\n mods.settings_path, no_dumps_mode);\n\n std::wstring command =\n quote(runner.wstring())" - "the upstream firmware-state launch path") -mph_launcher_replace_required( - " L\" --network on --wfc on --wfc-provider wiimmfi\";\n // beads-yjp.16:" - " L\" --network on --wfc on --wfc-provider wiimmfi\";\n append_arg(command, L\"--firmware-state-path\", firmware_state.wstring());\n // beads-yjp.16:" - "the upstream firmware-state runner argument") -mph_launcher_replace_required( - " // Read-only identity detail for the dashboard ONLINE card. Captured once\n // at startup: the MAC only changes on the first-ever no-dump launch.\n const std::string identity_mac = read_identity_mac(\n mod_state.bios_path.empty()\n ? mod_state.default_bios_dir\n : bios_dir_from_setting(mod_state.bios_path.c_str()));\n const std::string identity_detail =\n !identity_mac.empty()\n ? \"Console MAC: \" + identity_mac + \" (generated identity)\"\n : std::string(\"Console MAC: from the firmware dump, or created \"\n \"on the first no-dump launch.\");" - " // Read-only identity detail for the dashboard ONLINE card. Prefer the\n // mutable profile once it has been seeded; generated mode falls back to\n // the installation identity before that first launch.\n const std::filesystem::path selected_bios =\n mod_state.bios_path.empty()\n ? mod_state.default_bios_dir\n : bios_dir_from_setting(mod_state.bios_path.c_str());\n bool generated_identity = false;\n if (mod_state.bios_path.empty()) {\n bool conventional_dumps = true;\n for (const NdsDump& dump : kNdsDumps) {\n if (!std::filesystem::is_regular_file(selected_bios / dump.file))\n conventional_dumps = false;\n }\n generated_identity = !conventional_dumps;\n }\n std::string identity_mac = read_firmware_state_mac(firmware_state_path(\n mod_state.settings_path, generated_identity));\n if (identity_mac.empty() && generated_identity)\n identity_mac = read_identity_mac(selected_bios);\n const std::string identity_detail =\n !identity_mac.empty()\n ? \"Console MAC: \" + identity_mac +\n (generated_identity\n ? \" (generated identity)\" : \" (firmware profile)\")\n : std::string(\"Console MAC: from the firmware dump, or created \"\n \"on the first no-dump launch.\");" - "the upstream dashboard firmware identity block") set(MPH_PROFILE_LAUNCHER_SOURCE "${CMAKE_CURRENT_BINARY_DIR}/launcher_main_profile.cpp") @@ -122,6 +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 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; } From 60d12a38d768136bf66e4c66eb9251d3ff5039c3 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:19:35 +0900 Subject: [PATCH 94/97] Update multi-ROM CI for upstream three-mod launcher --- .github/workflows/mph-multirom-static.yml | 192 ++++++++++------------ 1 file changed, 84 insertions(+), 108 deletions(-) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index 329760a..df18074 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -25,13 +25,19 @@ jobs: 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/benchmark_mph_fmv.py \ tools/fuzz_mph_gameplay.py \ - tools/capture_mph_checkpoints.py + tools/capture_mph_checkpoints.py \ + tools/probe_mph_wfc.py \ + tools/probe_mph_online_first_run.py - name: Check shell syntax run: bash -n tools/build-linux.sh @@ -52,6 +58,23 @@ jobs: } if ($failed) { exit 1 } + - name: Verify upstream launcher sync + run: | + 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 runtime detector against melonPrimeDS develop_hud run: | curl -fsSL --retry 3 \ @@ -88,11 +111,8 @@ jobs: "mph_profile": "EU1_1", "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", "scenario": "scenarios/adventure_start.json", - "static_coverage": {"tier3_entries9": 3}, "tier3_coverage": {"entries": [ - {"cpu": 9, "pc": 33570816, "thumb": 0, "kind": 2, "hits": 99}, {"cpu": 9, "pc": 33570848, "thumb": 0, "kind": 2, "hits": 7}, - {"cpu": 9, "pc": 33579008, "thumb": 0, "kind": 2, "hits": 5}, {"cpu": 7, "pc": 37224480, "thumb": 0, "kind": 3, "hits": 4} ]} } @@ -109,46 +129,10 @@ jobs: assert p['profile'] == 'EU1_1' assert p['game_sha1'] == 'bdcd1dea293e24c98d4c481430e90d21198985a5' assert p['scenario'] == 'scenarios/adventure_start.json' - assert p['main_image']['arm9']['end'] == '0x02005000' - assert [e['addr'] for e in p['entry_points']['arm9']] == ['0x02004020'] - assert [e['addr'] for e in p['entry_points']['arm7']] == ['0x02380020'] + assert p['entry_points']['arm9'] + assert p['entry_points']['arm7'] PY - truncate -s $((0x00408000)) /tmp/eu-runtime.bin - cat > /tmp/eu-before.json <<'EOF' - { - "mph_profile": "EU1_1", - "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", - "tier3_coverage": {"entries": [ - {"cpu": 9, "pc": 33525760, "thumb": 0, "kind": 2, "caller": 1, "hits": 1} - ]} - } - EOF - cat > /tmp/eu-after.json <<'EOF' - { - "mph_profile": "EU1_1", - "rom_sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5", - "runtime_capture": { - "sha1": "2dacada9962037d4e1fa3099d9de8cdf616c318c", - "bytes": 4227072 - }, - "tier3_coverage": {"entries": [ - {"cpu": 9, "pc": 33525760, "thumb": 0, "kind": 2, "caller": 1, "hits": 2}, - {"cpu": 9, "pc": 33525792, "thumb": 0, "kind": 3, "caller": 2, "hits": 3} - ]} - } - EOF - python tools/promote_mph_runtime_coverage.py \ - --version EU1_1 \ - --before-benchmark /tmp/eu-before.json \ - --benchmark /tmp/eu-after.json \ - --image /tmp/eu-runtime.bin \ - --out /tmp/eu-runtime.toml - grep -q 'Profile: EU1_1' /tmp/eu-runtime.toml - grep -q 'Metroid Prime Hunters (Europe rev 1) ARM9 FMV runtime' /tmp/eu-runtime.toml - grep -q 'id = "mph_amhp1_arm9_fmv_runtime"' /tmp/eu-runtime.toml - ! grep -q 'Metroid Prime Hunters (USA) ARM9 FMV runtime' /tmp/eu-runtime.toml - - name: Verify registry-driven profile and FMV routing run: | grep -q 'MPH_PROFILE_FMV_RUNTIME_BANK' CMakeLists.txt @@ -158,16 +142,24 @@ jobs: grep -q 'MPH_PROFILE_KEYS' CMakeLists.txt ! grep -q 'PROPERTY STRINGS US1_0 EU1_1' CMakeLists.txt ! grep -q "ValidateSet('US1_0', 'EU1_1')" tools/build-windows.ps1 - grep -q 'FMV_RUNTIME_BANK' tools/build-linux.sh - grep -q 'FmvRuntimeBank' tools/build-windows.ps1 - grep -q 'FmvRuntimeBank' tools/make_release.ps1 grep -q 'rom_bytes\[0x1E\]' tools/prepare_mph.py ! grep -q 'rom_bytes\[0x1C\]' tools/prepare_mph.py + python - <<'PY' + import json + p=json.load(open('config/mph_rom_profiles.json', encoding='utf-8')) + assert p['profiles']['US1_0']['adaptive_widescreen'] is True + assert p['profiles']['EU1_1']['adaptive_widescreen'] is True + assert len(p['runtime_profiles']) == 7 + assert len(p['runtime_checksums']) == 17 + 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: Fetch exact pinned ndsrecomp revision run: | pin="$(tr -d '\r\n' < ndsrecomp.pin)" - test "${#pin}" -eq 40 + 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" @@ -191,46 +183,44 @@ jobs: -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' \ - -DMPH_LAUNCHER_ADAPTIVE_WIDESCREEN=OFF + '-DMPH_LAUNCHER_DEFAULT_ROM=Metroid Prime Hunters (Europe Rev 1).nds' us=/tmp/mph-launcher-us10/launcher_main_profile.cpp eu=/tmp/mph-launcher-eu11/launcher_main_profile.cpp test -f "$us" -a -f "$eu" grep -q '90164d1ac127ee5f9815ea4ae7de798c7b5fc629' "$us" - grep -q 'game.region = "USA";' "$us" - grep -q 'game.known_sha1_hex = nullptr;' "$us" - grep -q 'game.num_known_sha1 = 0;' "$us" - grep -q 'bool adaptive_widescreen = true;' "$us" - grep -A1 'int mod_feature_count' "$us" | grep -q 'return 2;' - grep -q 'adaptive = adaptive && true;' "$us" - grep -q 'bdcd1dea293e24c98d4c481430e90d21198985a5' "$eu" + grep -q 'game.region = "USA";' "$us" grep -q 'game.region = "Europe";' "$eu" + grep -q 'game.known_sha1_hex = nullptr;' "$us" grep -q 'game.known_sha1_hex = nullptr;' "$eu" + grep -q 'game.num_known_sha1 = 0;' "$us" grep -q 'game.num_known_sha1 = 0;' "$eu" grep -q 'exe / "Metroid Prime Hunters (Europe Rev 1).nds";' "$eu" - grep -q 'bool adaptive_widescreen = false;' "$eu" - grep -A1 'int mod_feature_count' "$eu" | grep -q 'return 1;' - grep -q 'if (!false) ++index;' "$eu" - grep -q 'adaptive = adaptive && false;' "$eu" + grep -q 'bool adaptive_widescreen = true;' "$us" + grep -q 'bool adaptive_widescreen = true;' "$eu" + grep -A1 'int mod_feature_count' "$us" | grep -q 'return 3;' + grep -A1 'int mod_feature_count' "$eu" | grep -q 'return 3;' + grep -q 'copy_text(output->id, "hd-rendering")' "$us" + grep -q 'copy_text(output->id, "hd-rendering")' "$eu" + grep -q -- '--firmware-state-path' "$us" + grep -q -- '--firmware-state-path' "$eu" + ! grep -q 'game.known_sha1_hex = sha1;' "$us" + ! grep -q 'game.known_sha1_hex = sha1;' "$eu" - name: Verify Linux profile-owned launch policy run: | grep -q 'launcher_default_rom' tools/build-linux.sh grep -q 'fmv_runtime_bank' tools/build-linux.sh - grep -q 'cp "$GAME_CONFIG" "$APPDIR/usr/share/mph-recomp/game.toml"' \ - tools/build-linux.sh - grep -q -- '--config "$HERE/usr/share/mph-recomp/game.toml"' \ - tools/build-linux.sh + grep -q 'cp "$GAME_CONFIG" "$APPDIR/usr/share/mph-recomp/game.toml"' tools/build-linux.sh + grep -q -- '--config "$HERE/usr/share/mph-recomp/game.toml"' tools/build-linux.sh ! grep -q -- '--adaptive-widescreen' tools/build-linux.sh - name: Verify ndsrecomp runtime patch is idempotent @@ -240,86 +230,72 @@ jobs: --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 - + /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 + /tmp/ndsrecomp/runner/src/main.cpp > /tmp/second.sha256 diff -u /tmp/first.sha256 /tmp/second.sha256 - - grep -q '0x020DB138u, 0x020DEE46u, 0x020DEE4Eu' \ - /tmp/ndsrecomp/runner/src/mph_runtime_profiles.generated.h - grep -q 'bdcd1dea293e24c98d4c481430e90d21198985a5' \ - /tmp/ndsrecomp/runner/src/mph_runtime_profiles.generated.h - grep -q 'nds_title_patches_select_mph_runtime_profile' \ - /tmp/ndsrecomp/runner/src/main.cpp - grep -q 'nds_title_patches_mph_in_ball' \ - /tmp/ndsrecomp/runner/src/frontend.cpp + 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 - ! grep -q 'kMphUs10AimY' /tmp/ndsrecomp/runner/src/title_patches.cpp - name: Compile patched runner translation units run: | 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 + 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 + 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 runtime base-profile and compatibility dispatch run: | 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 US1.0 and EU1.1 ROM checkers + - name: Verify exact-content ROM checker inputs run: | - 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 + python - <<'PY' + import json, tomllib + p=json.load(open('config/mph_rom_profiles.json', encoding='utf-8')) + for key in ('US1_0','EU1_1'): + item=p['profiles'][key] + with open(item['game_config'],'rb') as f: cfg=tomllib.load(f)['game'] + assert cfg['id'] == item['game_code'] + assert cfg['revision'] == item['revision'] + assert cfg['rom_size'] == item['rom_size'] + assert cfg['sha1'] == item['sha1'] + PY - name: Diff sanity - run: git diff --check + run: | + grep -q 'known_sha1_hex = nullptr' launcher/recomp-ui/CMakeLists.txt + ! grep -q 'rom_bytes\[0x1C\]' tools/prepare_mph.py + ! 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 || true From 9711da172a357fe073153e024b77de9c6ddb56cd Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:23:47 +0900 Subject: [PATCH 95/97] Restore complete multi-ROM CI coverage --- .github/workflows/mph-multirom-static.yml | 199 +++++++++------------- 1 file changed, 85 insertions(+), 114 deletions(-) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index df18074..a7af493 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -19,8 +19,10 @@ jobs: with: python-version: '3.12' - - name: Check Python syntax + - name: Check script syntax + shell: bash run: | + set -euo pipefail python -m py_compile \ tools/mph_profile.py \ tools/prepare_mph.py \ @@ -33,33 +35,24 @@ jobs: tools/patch_ndsrecomp_mph_widescreen_reset.py \ tools/promote_mph_static_coverage.py \ tools/promote_mph_runtime_coverage.py \ - tools/benchmark_mph_fmv.py \ - tools/fuzz_mph_gameplay.py \ - tools/capture_mph_checkpoints.py \ tools/probe_mph_wfc.py \ tools/probe_mph_online_first_run.py - - - name: Check shell syntax - run: bash -n tools/build-linux.sh - - - name: Check PowerShell syntax - shell: pwsh - run: | - $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 "$path has PowerShell parser errors:`n$($errors | Out-String)" + 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 } + if ($failed) { exit 1 } + ' - - name: Verify upstream launcher sync + - 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 @@ -75,8 +68,10 @@ jobs: 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 runtime detector against melonPrimeDS develop_hud + - 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 @@ -88,9 +83,23 @@ jobs: 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: Test profile-aware coverage promotion + - 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] @@ -118,46 +127,24 @@ jobs: } EOF python tools/promote_mph_static_coverage.py \ - --version EU1_1 \ - --inputs /tmp/eu-inputs \ - --trace /tmp/eu-trace.json \ + --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')) + p=json.load(open('/tmp/eu-coverage.json', encoding='utf-8')) assert p['profile'] == 'EU1_1' assert p['game_sha1'] == 'bdcd1dea293e24c98d4c481430e90d21198985a5' - assert p['scenario'] == 'scenarios/adventure_start.json' - assert p['entry_points']['arm9'] - assert p['entry_points']['arm7'] + assert p['entry_points']['arm9'] and p['entry_points']['arm7'] PY - - - name: Verify registry-driven profile and FMV routing - run: | - grep -q 'MPH_PROFILE_FMV_RUNTIME_BANK' CMakeLists.txt - grep -q 'config/${MPH_PROFILE_FMV_RUNTIME_BANK}.toml' CMakeLists.txt - grep -q 'capture/${MPH_PROFILE_FMV_RUNTIME_BANK}.bin' CMakeLists.txt - ! grep -q -- '--bank mph_arm9_fmv_runtime' CMakeLists.txt - grep -q 'MPH_PROFILE_KEYS' CMakeLists.txt - ! grep -q 'PROPERTY STRINGS US1_0 EU1_1' CMakeLists.txt - ! grep -q "ValidateSet('US1_0', 'EU1_1')" tools/build-windows.ps1 grep -q 'rom_bytes\[0x1E\]' tools/prepare_mph.py ! grep -q 'rom_bytes\[0x1C\]' tools/prepare_mph.py - python - <<'PY' - import json - p=json.load(open('config/mph_rom_profiles.json', encoding='utf-8')) - assert p['profiles']['US1_0']['adaptive_widescreen'] is True - assert p['profiles']['EU1_1']['adaptive_widescreen'] is True - assert len(p['runtime_profiles']) == 7 - assert len(p['runtime_checksums']) == 17 - 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 + ! 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 @@ -167,7 +154,9 @@ jobs: 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) @@ -178,56 +167,32 @@ jobs: 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 \ + -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 \ + -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 - us=/tmp/mph-launcher-us10/launcher_main_profile.cpp - eu=/tmp/mph-launcher-eu11/launcher_main_profile.cpp - test -f "$us" -a -f "$eu" - - grep -q '90164d1ac127ee5f9815ea4ae7de798c7b5fc629' "$us" - grep -q 'bdcd1dea293e24c98d4c481430e90d21198985a5' "$eu" - grep -q 'game.region = "USA";' "$us" - grep -q 'game.region = "Europe";' "$eu" - grep -q 'game.known_sha1_hex = nullptr;' "$us" - grep -q 'game.known_sha1_hex = nullptr;' "$eu" - grep -q 'game.num_known_sha1 = 0;' "$us" - grep -q 'game.num_known_sha1 = 0;' "$eu" - grep -q 'exe / "Metroid Prime Hunters (Europe Rev 1).nds";' "$eu" - grep -q 'bool adaptive_widescreen = true;' "$us" - grep -q 'bool adaptive_widescreen = true;' "$eu" - grep -A1 'int mod_feature_count' "$us" | grep -q 'return 3;' - grep -A1 'int mod_feature_count' "$eu" | grep -q 'return 3;' - grep -q 'copy_text(output->id, "hd-rendering")' "$us" - grep -q 'copy_text(output->id, "hd-rendering")' "$eu" - grep -q -- '--firmware-state-path' "$us" - grep -q -- '--firmware-state-path' "$eu" - ! grep -q 'game.known_sha1_hex = sha1;' "$us" - ! grep -q 'game.known_sha1_hex = sha1;' "$eu" - - - name: Verify Linux profile-owned launch policy - run: | - grep -q 'launcher_default_rom' tools/build-linux.sh - grep -q 'fmv_runtime_bank' tools/build-linux.sh - grep -q 'cp "$GAME_CONFIG" "$APPDIR/usr/share/mph-recomp/game.toml"' tools/build-linux.sh - grep -q -- '--config "$HERE/usr/share/mph-recomp/game.toml"' tools/build-linux.sh - ! grep -q -- '--adaptive-widescreen' tools/build-linux.sh - - - name: Verify ndsrecomp runtime patch is idempotent + - 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 + --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 \ @@ -236,8 +201,7 @@ jobs: /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 + --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 \ @@ -253,7 +217,9 @@ jobs: ! 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 \ @@ -261,41 +227,46 @@ jobs: : > "/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 + -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 runtime base-profile and compatibility dispatch + - 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: Verify exact-content ROM checker inputs + - name: Compile exact-content ROM checkers + shell: bash run: | - python - <<'PY' - import json, tomllib - p=json.load(open('config/mph_rom_profiles.json', encoding='utf-8')) - for key in ('US1_0','EU1_1'): - item=p['profiles'][key] - with open(item['game_config'],'rb') as f: cfg=tomllib.load(f)['game'] - assert cfg['id'] == item['game_code'] - assert cfg['revision'] == item['revision'] - assert cfg['rom_size'] == item['rom_size'] - assert cfg['sha1'] == item['sha1'] - PY + 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 'rom_bytes\[0x1C\]' tools/prepare_mph.py ! 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 || true + git diff --check HEAD^ HEAD From 39b85492b27fdd6eee752e2e06b2aa39469a426d Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:25:32 +0900 Subject: [PATCH 96/97] Fix shallow-checkout diff validation --- .github/workflows/mph-multirom-static.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index a7af493..4df8af0 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout project uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Set up Python uses: actions/setup-python@v5 From e3f0f9959f5d22b2547740689ed3a3c69c6cf1c8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 14:26:58 +0900 Subject: [PATCH 97/97] Sync upstream v0.4.0 release metadata --- CMakeLists.txt | 4 ++-- README.md | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a5d755..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) +project(MetroidPrimeHuntersRecomp VERSION 0.4.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -263,4 +263,4 @@ if(NOT MSVC) endif() add_custom_target(metroidprimehuntersrecomp - DEPENDS mph_romcheck mph_recompiled_banks) \ No newline at end of file + DEPENDS mph_romcheck mph_recompiled_banks) diff --git a/README.md b/README.md index 26f91cd..98de666 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,24 @@ Click the image to watch the gameplay preview on YouTube. ## Current Release Latest upstream release: -**[v0.3.0-alpha](https://github.com/mstan/MetroidPrimeHuntersRecomp/releases/tag/v0.3.0-alpha)**. +**[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 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: @@ -80,9 +86,13 @@ content/capture coverage before it is considered fully brought up. 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 @@ -92,6 +102,8 @@ content/capture coverage before it is considered fully brought up. 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.