Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,24 @@ else()
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}/${MPH_PROFILE_COVERAGE}")
if(NOT EXISTS "${MPH_COVERAGE_SEEDS}")
message(FATAL_ERROR
"Coverage seed file for ${MPH_VERSION} not found: ${MPH_COVERAGE_SEEDS}")

set(MPH_COVERAGE_SEEDS "")
set(MPH_PREP_COVERAGE_ARGS)
set(MPH_PREP_COVERAGE_DEPENDS)
if(NOT MPH_PROFILE_COVERAGE STREQUAL "")
set(MPH_COVERAGE_SEEDS
"${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()
list(APPEND MPH_PREP_COVERAGE_ARGS
--coverage "${MPH_COVERAGE_SEEDS}")
list(APPEND MPH_PREP_COVERAGE_DEPENDS "${MPH_COVERAGE_SEEDS}")
else()
message(STATUS
"MPH profile ${MPH_VERSION}: no static coverage seed; "
"using ROM-header ARM9/ARM7 bootstrap roots only")
endif()

message(STATUS
Expand Down Expand Up @@ -110,11 +123,11 @@ add_custom_command(
COMMAND "${MPH_PYTHON}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/prepare_mph.py"
--rom "${MPH_ROM}"
--out "${MPH_INPUT_DIR}"
--coverage "${MPH_COVERAGE_SEEDS}"
${MPH_PREP_COVERAGE_ARGS}
--version "${MPH_VERSION}"
--profiles "${MPH_PROFILE_FILE}"
DEPENDS "${MPH_ROM}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/prepare_mph.py"
"${MPH_PROFILE_FILE}" "${MPH_COVERAGE_SEEDS}"
"${MPH_PROFILE_FILE}" ${MPH_PREP_COVERAGE_DEPENDS}
COMMENT "Verifying ${MPH_VERSION} and preparing main/overlay code images")
add_custom_target(mph_prepare DEPENDS ${MPH_PREP_OUTPUTS})

Expand Down
2 changes: 1 addition & 1 deletion config/mph_rom_profiles.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"rom_size": 67108864,
"sha1": "bdcd1dea293e24c98d4c481430e90d21198985a5",
"program_id": "mph_amhp1",
"coverage": "coverage/eu11-bootstrap-entry-points.json",
"coverage": "",
"game_config": "game.toml",
"fmv_runtime": false,
"fmv_runtime_bank": "mph_amhp1_arm9_fmv_runtime",
Expand Down
10 changes: 0 additions & 10 deletions coverage/eu11-bootstrap-entry-points.json

This file was deleted.

20 changes: 14 additions & 6 deletions docs/EU1_1_BRINGUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ Current clean EU1.1 profile:
- whole-ROM SHA-1: `bdcd1dea293e24c98d4c481430e90d21198985a5`
- default launcher ROM: `Metroid Prime Hunters (Europe Rev 1).nds`
- shared frontend config: `game.toml`
- static coverage: none currently promoted; exact AOT preparation bootstraps
from the ROM-header ARM9/ARM7 entry points only
- Adaptive Widescreen: exposed and revision-aware
- FMV runtime bank: disabled until an EU1.1-specific capture is validated

Expand Down Expand Up @@ -319,23 +321,29 @@ during the build.
The preparation path intentionally remains exact-content gated. This protects
generated code provenance; it is not the runtime selector.

Example EU1.1 preparation:
EU1.1 currently has no promoted static coverage, so preparation intentionally
uses only the ROM-header ARM9/ARM7 entry points:

```bash
python tools/prepare_mph.py \
--version EU1_1 \
--rom "/path/to/Metroid Prime Hunters (Europe Rev 1).nds" \
--coverage coverage/eu11-bootstrap-entry-points.json \
--out generated/EU1_1/inputs
```

`prepare_mph.py` checks:
`prepare_mph.py` always checks:

- exact whole-ROM SHA-1
- expected ROM size
- game code at `0x0C`
- revision at `0x1E`
- coverage `game_sha1`

When `--coverage <manifest.json>` is supplied, it additionally verifies the
coverage `game_sha1` and adds those ARM9/ARM7 entry points. Omitting
`--coverage` is a deliberate bootstrap-only mode and still emits the ROM-header
entry roots. The old `coverage/eu11-bootstrap-entry-points.json` placeholder was
removed because it contained zero ARM9 and zero ARM7 coverage entries and was
therefore behaviorally identical to omitting coverage.

It then extracts ARM9, ARM7 and overlays and emits content-specific seed
configs.
Expand Down Expand Up @@ -388,8 +396,8 @@ produce the canonical melonPrimeDS executable checksums. It verifies:
- upstream launcher/tests and the imported overlay QA tools remain pinned to the
audited upstream title commit

`MPH Multi-ROM Static Checks` #123 passed after the upstream `905ffab` / ndsrecomp
`302404ad...` integration.
The multi-ROM static workflow is required to pass after every profile/schema
change, including bootstrap-only profiles with no promoted coverage file.

## 13. Remaining optimization work

Expand Down
56 changes: 47 additions & 9 deletions tools/check_mph_multirom_profiles.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""Validate the current multi-ROM schema, then run the legacy deep checks.

The legacy checker predates the all-version Adaptive Widescreen address table
and the shared runtime-generic frontend config. Preserve its broad coverage on
a temporary compatibility view while validating the current scale fields and
generic config contract against the real registry.
The legacy checker predates the all-version Adaptive Widescreen address table,
the shared runtime-generic frontend config, and optional bootstrap-only coverage.
Preserve its broad coverage on a temporary compatibility view while validating
the current scale fields and generic config contract against the real registry.
"""

from __future__ import annotations
Expand Down Expand Up @@ -105,6 +105,25 @@ def validate_scale_registry(repo: Path, table: Path | None) -> None:
if profile.get("game_config") != "game.toml":
die(f"{key}: content profiles must share the generic game.toml frontend config")

coverage = profile.get("coverage")
if not isinstance(coverage, str):
die(f"{key}.coverage must be a string; use an empty string for bootstrap-only")
if coverage:
coverage_path = repo / coverage
if not coverage_path.is_file():
die(f"{key}.coverage does not exist: {coverage_path}")
coverage_doc = json.loads(coverage_path.read_text(encoding="utf-8"))
if coverage_doc.get("game_sha1") != profile.get("sha1"):
die(f"{key}.coverage game_sha1 does not match profile SHA-1")

# EU1.1 never had promoted coverage: the removed file contained zero ARM9
# and zero ARM7 entries and existed only to satisfy the old required-file
# shape. Keep the source tree honest and represent that state as no seed.
if profiles.get("EU1_1", {}).get("coverage") != "":
die("EU1_1 should remain bootstrap-only until real coverage is promoted")
if (repo / "coverage" / "eu11-bootstrap-entry-points.json").exists():
die("obsolete empty EU1.1 bootstrap coverage placeholder still exists")

if table:
text = table.read_text(encoding="utf-8")
rows = {
Expand All @@ -126,7 +145,7 @@ def validate_scale_registry(repo: Path, table: Path | None) -> None:


def legacy_compat_view(repo: Path, destination: Path) -> Path:
"""Synthesize the old per-profile config shape only for the legacy checker."""
"""Synthesize old required-file/profile shapes only for the legacy checker."""
target = destination / "repo"
shutil.copytree(
repo, target,
Expand All @@ -139,11 +158,30 @@ def legacy_compat_view(repo: Path, destination: Path) -> Path:
for field in SCALE_FIELDS:
runtime.pop(field, None)

# The old checker expects exact identity duplicated into each game config
# and also expects the historical EU1.1 widescreen-disable state. Generate
# those files only inside the temporary compatibility copy so production
# source keeps one generic game.toml.
# The old checker requires a concrete coverage file for every profile.
# Generate an empty identity-only manifest in the temporary compatibility
# copy when the production profile intentionally has no promoted coverage.
for key, profile in registry["profiles"].items():
if not profile.get("coverage"):
legacy_coverage_rel = f"coverage/.legacy-{key}-bootstrap.json"
legacy_coverage_path = target / legacy_coverage_rel
legacy_coverage_path.parent.mkdir(parents=True, exist_ok=True)
legacy_coverage_path.write_text(
json.dumps(
{
"schema": 1,
"game_sha1": profile["sha1"],
"scenario": None,
"selection": "legacy checker bootstrap compatibility view",
"entry_points": {"arm9": [], "arm7": []},
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
profile["coverage"] = legacy_coverage_rel

legacy_rel = f"config/.legacy-{key}.toml"
profile["game_config"] = legacy_rel
adaptive = profile.get("adaptive_widescreen") is True
Expand Down
42 changes: 30 additions & 12 deletions tools/prepare_mph.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,14 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
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(
"--coverage",
type=Path,
help=(
"optional static coverage seed manifest; omit to bootstrap from "
"the ROM-header ARM9/ARM7 entry points only"
),
)
parser.add_argument(
"--version",
default=DEFAULT_VERSION,
Expand Down Expand Up @@ -232,16 +239,20 @@ def main() -> int:
)

rom = ndspy.rom.NintendoDSRom(rom_bytes)
coverage = json.loads(args.coverage.read_text(encoding="utf-8"))
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_coverage: list[dict[str, object]] = []
arm7_coverage: list[dict[str, object]] = []
if args.coverage is not None:
coverage = json.loads(args.coverage.read_text(encoding="utf-8"))
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)
arm9 = code_compression.decompress(arm9_compressed)
if len(arm9) < len(arm9_compressed):
Expand Down Expand Up @@ -326,6 +337,13 @@ def main() -> int:
f"{expected_game_code.decode('ascii')} revision={expected_revision} "
f"SHA-1 {digest}"
)
if args.coverage is None:
print("Static coverage: none; using ROM-header ARM9/ARM7 bootstrap roots only")
else:
print(
f"Static coverage: {len(arm9_coverage)} ARM9 + "
f"{len(arm7_coverage)} ARM7 seed entries from {args.coverage}"
)
print(
f"ARM9: {len(arm9_compressed):,} compressed bytes -> "
f"{len(arm9):,} bytes at 0x{int(rom.arm9RamAddress):08X}"
Expand All @@ -340,4 +358,4 @@ def main() -> int:


if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
Loading