gameboy-rust/tools/prepare_gba_tests.py
0xrsydn 661f00d682 test(memory): integrate pinned public memory compatibility suite
Add --suite memory with verified source hashes, ARM eval checkpoint 0x08000350, and the r12 result assertion. Keep default ARM and explicit Thumb preparation unchanged. Extend offline adapter and CLI tests; retain public binaries outside tracked files.

The unmodified memory ROM passes in debug and release after 174 steps and 1478 nominal cycles, without changing core behavior. Document weak byte-write assertions and mode-selection caveats. Add original CPU-driven tests with exact sentinels, mode/mirror boundaries, byte lanes, forced blank, readback, and nominal device timing.

Validated on Darwin arm64: 851 workspace tests in debug and release; 782 core/demo tests separately; 30 Python tests; all three pinned public suites in both profiles; native ROM windows; all ten demo smoke tests; fmt, clippy, rustdoc, and local documentation links.
2026-10-05 21:19:32 +07:00

119 lines
5 KiB
Python

#!/usr/bin/env python3
"""Prepare a pinned public ARM, Thumb, or memory test outside tracked paths.
This adapter is original. Upstream sources, license, and ROM bytes are downloaded
only to the requested local directory. They are not embedded in this repository.
"""
import argparse
import hashlib
import json
from pathlib import Path
import struct
import sys
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parent.parent
LOCKS = {suite: Path(__file__).with_name(f"gba-tests-{suite}.lock.json")
for suite in ("arm", "thumb", "memory")}
MAX_FILE_BYTES = 1024 * 1024
def fetch(url):
request = Request(url, headers={"User-Agent": "gameboy-rust-public-test-adapter"})
with urlopen(request, timeout=30) as response:
return response.read(MAX_FILE_BYTES + 1)
def verify(name, data, expected):
if len(data) > MAX_FILE_BYTES or len(data) != expected["bytes"]:
raise ValueError(f"size mismatch for {name}")
if hashlib.sha256(data).hexdigest() != expected["sha256"]:
raise ValueError(f"SHA-256 mismatch for {name}")
def destination(path):
path = Path(path).resolve()
# Public ROM headers can contain third-party assets. Keep them untracked.
if path.is_relative_to(ROOT) and not path.is_relative_to(ROOT / "roms"):
raise ValueError("output must be outside the repository or inside ignored roms/")
if path.exists():
raise ValueError(f"output directory already exists: {path}")
if not path.parent.is_dir():
raise ValueError("output parent directory must already exist")
return path
def prepare(output, source=None, download=fetch, suite="arm"):
if suite not in LOCKS:
raise ValueError(f"unsupported public suite: {suite}")
output = destination(output)
lock = json.loads(LOCKS[suite].read_text(encoding="utf-8"))
base = f"https://raw.githubusercontent.com/{lock['repository']}/{lock['revision']}/"
files = {}
for name, expected in lock["files"].items():
if source is None:
data = download(base + name)
else:
path = Path(source) / name
if not path.is_file() or path.stat().st_size > MAX_FILE_BYTES:
raise ValueError(f"source must be a bounded regular file: {name}")
with path.open("rb") as stream:
data = stream.read(MAX_FILE_BYTES + 1)
verify(name, data, expected)
files[name] = data
# The source-defined eval label starts with m_vsync's register save. All
# failure exits set the result register (ARM/memory r12, Thumb r7).
# Thumb tests switch back to ARM before eval. Success leaves the result zero.
# Hash pinning is authoritative; these checks catch adapter-offset mistakes.
pc = int(lock["completion_pc"], 16)
offset = pc - 0x08000000
rom = files[lock["rom"]]
if offset < 0 or offset % 4 or offset + 4 > len(rom) or struct.unpack_from("<I", rom, offset)[0] != 0xE92D0003:
raise ValueError("pinned evaluation checkpoint does not match the ROM")
if suite == "thumb" and (offset < 4 or struct.unpack_from("<HH", rom, offset - 4) != (0xA000, 0x4700)):
raise ValueError("pinned Thumb-to-ARM checkpoint bridge does not match the ROM")
manifest = {
"version": 1,
"cases": [{
"name": f"jsmolka-{suite}",
"rom": lock["rom"],
"step_limit": 1_000_000,
"completion": {"pc": lock["completion_pc"], "instruction_set": "arm"},
"checks": [{"kind": "register", "index": lock["result_register"], "equals": 0}],
}],
}
# Validate every file before creating output. Existing destinations are never
# reused, and file creation is exclusive. Disk errors can leave partial output.
output.mkdir()
for name, data in files.items():
target = output / name
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("xb") as stream:
stream.write(data)
for name, value in [("suite.json", manifest), ("source-lock.json", lock)]:
with (output / name).open("x", encoding="utf-8") as stream:
json.dump(value, stream, indent=2)
stream.write("\n")
return output / "suite.json"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output", type=Path, help="new directory outside the repository or under roms/")
parser.add_argument("--suite", choices=tuple(LOCKS), default="arm", help="public suite to prepare (default: arm)")
parser.add_argument("--source-dir", type=Path, help="offline upstream tree; every selected file must match the lock")
args = parser.parse_args()
try:
manifest = prepare(args.output, args.source_dir, suite=args.suite)
except (OSError, ValueError) as error:
print(f"cannot prepare public {args.suite} test: {error}", file=sys.stderr)
return 1
print(f"Prepared pinned public {args.suite} suite: {manifest}")
print("Run with gameboy-rust --test-suite PATH. Preparation does not mean the tests passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())