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:
parent
a9493d4cc6
commit
08c8705904
11 changed files with 304 additions and 50 deletions
66
tools/gba-tests-thumb.lock.json
Normal file
66
tools/gba-tests-thumb.lock.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue