test(cpu): integrate pinned public Thumb compatibility suite

Add --suite thumb while retaining default ARM preparation. Verify hashes, the Thumb-to-ARM evaluation bridge, and the result checkpoint; retain upstream provenance outside tracked files. Expand offline synthetic adapter and CLI regressions.

Darwin arm64 baseline: the unmodified Thumb ROM reaches ARM eval at 0x08000934 after 771 steps with r7=229 in debug and release. ARM remains passing. Online/offline preparation agrees and all 21 Python tests pass. CPU behavior is unchanged.
This commit is contained in:
0xrsydn 2026-10-05 20:40:37 +07:00
commit 08c8705904
11 changed files with 304 additions and 50 deletions

View file

@ -0,0 +1,66 @@
{
"repository": "jsmolka/gba-tests",
"revision": "a7113b67e63f83a9b321696ddd7042ccfad6c881",
"license": "MIT",
"rom": "thumb/thumb.gba",
"completion_pc": "0x08000934",
"result_register": 7,
"files": {
"LICENSE": {
"sha256": "b59a9ed235d76752c977f04116bc72155a14af48176bdbf50d7e5c29fca35aa7",
"bytes": 1070
},
"README.md": {
"sha256": "488f50d9d9031c88cea4161fad69f6460209dd54d965645fa45cf5e972cc9062",
"bytes": 434
},
"thumb/thumb.asm": {
"sha256": "090a6bb08b3813e2da9adef9705368d5c292bd563aeada82189f93b5b16e0f6e",
"bytes": 790
},
"thumb/thumb.gba": {
"sha256": "b5cb2291df4ab314b31c598acd9bff2ccfa0b38efff29daadfe97422ce369b67",
"bytes": 3680
},
"thumb/arithmetic.asm": {
"sha256": "3da934b2b05f208c6ddea8617fa281845db09816b4e4c050990a2e0c0c82724f",
"bytes": 4990
},
"thumb/branches.asm": {
"sha256": "69c6ce873b68beebcf1cea49a057ebc360d1539c8c112d607563757ed7414f60",
"bytes": 1766
},
"thumb/logical.asm": {
"sha256": "f3c70c79867ec57f0826f727246650ba12d1156085eb4e0678deed5177343f46",
"bytes": 2436
},
"thumb/memory.asm": {
"sha256": "250b5b0e4a616f2b836d8fe525df4092936a5992f8b2143c7784cdbe42a84e03",
"bytes": 10357
},
"thumb/shifts.asm": {
"sha256": "cc7c8395c5e9abbb220f47af507d9256378a8186a0514b4c73add1dc12db5e70",
"bytes": 5018
},
"lib/constants.inc": {
"sha256": "b4dd3f4760ac1ae7e99cdc7e255f8220eed666e1ab821b150561db58e5d3b7ea",
"bytes": 900
},
"lib/macros.inc": {
"sha256": "25b0ff553c4ca99d76ec82c8c9db874e25d6eed58e2e69fa27f1a0fe7a8c46d4",
"bytes": 2864
},
"lib/header.asm": {
"sha256": "e9d1e8c7b6382eeb1b7059423ec365e32911f702eadfa51804a2119438a87357",
"bytes": 1720
},
"lib/text.asm": {
"sha256": "77e579d6cf0db182fd24e8d420c8c0a81592d78197e8fb06accc8bcfb43db17e",
"bytes": 2288
},
"lib/glyphs.asm": {
"sha256": "538beb37f40b1fd56beed9253e0e9eab25444c748adba7440a2093f49e3cbcc9",
"bytes": 2545
}
}
}

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Prepare a hash-verified, pinned public ARM test outside tracked source paths.
"""Prepare a hash-verified, pinned public ARM or Thumb 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.
@ -14,7 +14,8 @@ import sys
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parent.parent
LOCK = Path(__file__).with_name("gba-tests-arm.lock.json")
LOCKS = {suite: Path(__file__).with_name(f"gba-tests-{suite}.lock.json")
for suite in ("arm", "thumb")}
MAX_FILE_BYTES = 1024 * 1024
@ -43,9 +44,11 @@ def destination(path):
return path
def prepare(output, source=None, download=fetch):
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(LOCK.read_text(encoding="utf-8"))
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():
@ -61,17 +64,20 @@ def prepare(output, source=None, download=fetch):
files[name] = data
# The source-defined eval label starts with m_vsync's register save. All
# failure exits branch here with r12 set; successful fallthrough keeps r12=0.
# Hash pinning is authoritative; this check also catches adapter-offset mistakes.
# failure exits arrive with the result register set (ARM 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 > len(rom) or struct.unpack_from("<I", rom, offset)[0] != 0xE92D0003:
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": "jsmolka-arm",
"name": f"jsmolka-{suite}",
"rom": lock["rom"],
"step_limit": 1_000_000,
"completion": {"pc": lock["completion_pc"], "instruction_set": "arm"},
@ -96,14 +102,15 @@ def prepare(output, source=None, download=fetch):
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)
manifest = prepare(args.output, args.source_dir, suite=args.suite)
except (OSError, ValueError) as error:
print(f"cannot prepare public ARM test: {error}", file=sys.stderr)
print(f"cannot prepare public {args.suite} test: {error}", file=sys.stderr)
return 1
print(f"Prepared pinned public ARM suite: {manifest}")
print(f"Prepared pinned public {args.suite} suite: {manifest}")
print("Run with gameboy-rust --test-suite PATH. Preparation does not mean the CPU tests passed.")
return 0

View file

@ -1,5 +1,6 @@
"""Adapter regressions with original synthetic bytes; no network or public ROMs."""
import hashlib
import io
import json
from pathlib import Path
import struct
@ -11,29 +12,41 @@ import prepare_gba_tests as adapter
class PreparationTests(unittest.TestCase):
suite = "arm"
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
self.output = self.root / "prepared"
self.rom = f"{self.suite}/{self.suite}.gba"
if self.suite == "arm":
data = struct.pack("<II", 0xEAFFFFFE, 0xE92D0003)
pc, result = "0x08000004", 12
else:
data = struct.pack("<IIHHI", 0xEAFFFFFE, 0xE1A00000, 0xA000, 0x4700, 0xE92D0003)
pc, result = "0x0800000c", 7
self.files = {
"arm/arm.gba": struct.pack("<II", 0xEAFFFFFE, 0xE92D0003),
self.rom: data,
"LICENSE": b"Original fixture, not upstream license content.\n",
"arm/arm.asm": b"Original fixture source marker.\n",
f"{self.suite}/{self.suite}.asm": b"Original fixture source marker.\n",
}
self.lock = {
"repository": "original/test-fixture",
"revision": "0" * 40,
"license": "fixture",
"rom": "arm/arm.gba",
"completion_pc": "0x08000004",
"result_register": 12,
"rom": self.rom,
"completion_pc": pc,
"result_register": result,
"files": {name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
for name, data in self.files.items()},
}
self.lock_path = self.root / "lock.json"
self.save_lock()
patch = mock.patch.object(adapter, "LOCK", self.lock_path)
# The unselected path does not exist. Reading the wrong lock must fail.
locks = {suite: self.root / f"missing-{suite}.json" for suite in adapter.LOCKS}
locks[self.suite] = self.lock_path
patch = mock.patch.object(adapter, "LOCKS", locks)
patch.start()
self.addCleanup(patch.stop)
@ -46,7 +59,17 @@ class PreparationTests(unittest.TestCase):
return self.files[url[len(prefix):]]
def prepare(self):
return adapter.prepare(self.output, download=self.download)
# Exercise the existing default ARM API as well as explicit Thumb selection.
options = {} if self.suite == "arm" else {"suite": self.suite}
return adapter.prepare(self.output, download=self.download, **options)
def offline_source(self):
source = self.root / "source"
for name, data in self.files.items():
target = source / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
return source
def test_verified_bytes_license_and_provenance_are_retained(self):
manifest_path = self.prepare()
@ -54,46 +77,50 @@ class PreparationTests(unittest.TestCase):
self.assertEqual((self.output / name).read_bytes(), expected)
self.assertEqual(json.loads((self.output / "source-lock.json").read_text()), self.lock)
manifest = json.loads(manifest_path.read_text())
case = manifest["cases"][0]
self.assertEqual(case["completion"], {"pc": "0x08000004", "instruction_set": "arm"})
self.assertEqual(case["checks"], [{"kind": "register", "index": 12, "equals": 0}])
self.assertEqual(case["rom"], "arm/arm.gba")
self.assertEqual(case["step_limit"], 1_000_000)
self.assertEqual(manifest, {
"version": 1,
"cases": [{
"name": f"jsmolka-{self.suite}",
"rom": self.rom,
"step_limit": 1_000_000,
# The Thumb ROM returns to ARM before evaluation.
"completion": {"pc": self.lock["completion_pc"], "instruction_set": "arm"},
"checks": [{"kind": "register", "index": self.lock["result_register"], "equals": 0}],
}],
})
def test_existing_directory_is_rejected_before_fetching(self):
self.output.mkdir()
sentinel = self.output / "keep"
sentinel.write_text("unchanged")
with self.assertRaisesRegex(ValueError, "already exists"):
adapter.prepare(self.output, download=lambda _: self.fail("unexpected download"))
adapter.prepare(self.output, download=lambda _: self.fail("unexpected download"), suite=self.suite)
self.assertEqual(sentinel.read_text(), "unchanged")
def test_failed_hash_or_size_leaves_no_output(self):
for replacement in [b"short", b"x" * len(self.files["arm/arm.gba"])]:
for replacement in [b"short", b"x" * len(self.files[self.rom])]:
with self.subTest(replacement=replacement):
self.files["arm/arm.gba"] = replacement
self.files[self.rom] = replacement
with self.assertRaisesRegex(ValueError, "mismatch"):
self.prepare()
self.assertFalse(self.output.exists())
def test_bad_checkpoint_is_rejected_even_with_matching_hash(self):
self.lock["completion_pc"] = "0x08000000"
self.save_lock()
with self.assertRaisesRegex(ValueError, "checkpoint"):
self.prepare()
self.assertFalse(self.output.exists())
for pc in ["0x07fffffc", "0x08000000", "0x08000003", "0x08001000"]:
with self.subTest(pc=pc):
self.lock["completion_pc"] = pc
self.save_lock()
with self.assertRaisesRegex(ValueError, "checkpoint"):
self.prepare()
self.assertFalse(self.output.exists())
def test_offline_tree_gets_the_same_checks_and_no_network(self):
source = self.root / "source"
for name, data in self.files.items():
target = source / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
adapter.prepare(self.output, source, download=lambda _: self.fail("unexpected network"))
self.assertEqual((self.output / "arm/arm.gba").read_bytes(), self.files["arm/arm.gba"])
source = self.offline_source()
adapter.prepare(self.output, source, download=lambda _: self.fail("unexpected network"), suite=self.suite)
self.assertEqual((self.output / self.rom).read_bytes(), self.files[self.rom])
(source / "LICENSE").write_bytes(b"modified")
with self.assertRaisesRegex(ValueError, "mismatch"):
adapter.prepare(self.root / "second", source)
adapter.prepare(self.root / "second", source, suite=self.suite)
self.assertFalse((self.root / "second").exists())
def test_download_failure_does_not_publish_partial_fixture(self):
@ -105,7 +132,7 @@ class PreparationTests(unittest.TestCase):
raise OSError("network unavailable")
return self.download(url)
with self.assertRaisesRegex(OSError, "network unavailable"):
adapter.prepare(self.output, download=download)
adapter.prepare(self.output, download=download, suite=self.suite)
self.assertFalse(self.output.exists())
def test_tracked_repository_paths_are_rejected(self):
@ -123,6 +150,71 @@ class PreparationTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "size mismatch"):
adapter.verify("fixture", data, expected)
def test_cli_prepares_offline_and_reports_errors_without_network(self):
source = self.offline_source()
argv = ["prepare_gba_tests.py", str(self.output), "--source-dir", str(source)]
if self.suite != "arm":
argv += ["--suite", self.suite]
with mock.patch("sys.argv", argv), mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
self.assertEqual(adapter.main(), 0)
self.assertIn(f"public {self.suite} suite", stdout.getvalue())
# Existing output is an error, not an overwrite or a successful preparation.
with mock.patch("sys.argv", argv), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
self.assertEqual(adapter.main(), 1)
self.assertIn("already exists", stderr.getvalue())
class ThumbPreparationTests(PreparationTests):
suite = "thumb"
def test_thumb_bridge_is_checked_even_with_matching_hash(self):
original = self.files[self.rom]
# Wrong ADR, wrong BX, or no bridge before an otherwise valid eval word.
for data, pc in [
(original[:8] + struct.pack("<HHI", 0xA001, 0x4700, 0xE92D0003), "0x0800000c"),
(original[:8] + struct.pack("<HHI", 0xA000, 0x4708, 0xE92D0003), "0x0800000c"),
(struct.pack("<I", 0xE92D0003), "0x08000000"),
]:
with self.subTest(data=data):
self.files[self.rom] = data
self.lock["files"][self.rom] = {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
self.lock["completion_pc"] = pc
self.save_lock()
with self.assertRaisesRegex(ValueError, "Thumb-to-ARM checkpoint bridge"):
self.prepare()
self.assertFalse(self.output.exists())
class SuiteSelectionTests(unittest.TestCase):
def test_unknown_suite_is_rejected_before_download_or_output(self):
with tempfile.TemporaryDirectory() as temp:
output = Path(temp) / "prepared"
with self.assertRaisesRegex(ValueError, "unsupported public suite"):
adapter.prepare(output, suite="../unknown", download=lambda _: self.fail("unexpected network"))
self.assertFalse(output.exists())
with mock.patch("sys.argv", ["prepare_gba_tests.py", str(output), "--suite", "unknown"]):
with mock.patch("sys.stderr", new_callable=io.StringIO), self.assertRaises(SystemExit) as exit:
adapter.main()
self.assertEqual(exit.exception.code, 2)
self.assertFalse(output.exists())
def test_committed_locks_keep_distinct_verified_protocols(self):
for suite, pc, register in [("arm", "0x08001d4c", 12), ("thumb", "0x08000934", 7)]:
lock = json.loads(adapter.LOCKS[suite].read_text())
self.assertEqual(lock["repository"], "jsmolka/gba-tests")
self.assertEqual(lock["revision"], "a7113b67e63f83a9b321696ddd7042ccfad6c881")
self.assertEqual(lock["license"], "MIT")
self.assertEqual(lock["completion_pc"], pc)
self.assertEqual(lock["result_register"], register)
self.assertEqual(lock["rom"], f"{suite}/{suite}.gba")
self.assertIn(lock["rom"], lock["files"])
self.assertIn("LICENSE", lock["files"])
for name, metadata in lock["files"].items():
self.assertFalse(Path(name).is_absolute())
self.assertNotIn("..", Path(name).parts)
self.assertRegex(metadata["sha256"], r"^[0-9a-f]{64}$")
self.assertTrue(0 < metadata["bytes"] <= adapter.MAX_FILE_BYTES)
if __name__ == "__main__":
unittest.main()