"""A profile's checks are the ones the emulator's code runs. Each case reads a profile from emulators/ and offers its entry files of chosen sizes or contents, then compares the verdict with what the code at the profile's pin does with the same file. The cases come from reading that code: a check the code does not run rejects a file the emulator loads, and a check left out lets through a file the emulator refuses. """ from __future__ import annotations import sys import tempfile import unittest from pathlib import Path import yaml REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "scripts")) from validation import _build_validation_index, check_file_validation # noqa: E402 def _profile(name: str) -> dict: with open(REPO_ROOT / "emulators" / f"{name}.yml", encoding="utf-8") as fh: return yaml.safe_load(fh) class _Probe(unittest.TestCase): """Offer a profile entry a file and read the verdict.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) def accepts(self, emulator: str, name: str, data: bytes, strict: bool = False) -> bool: """strict counts a check the code only warns about.""" index = _build_validation_index({emulator: _profile(emulator)}) path = Path(self._tmp.name) / name path.write_bytes(data) return check_file_validation( str(path), name, index, emulators={emulator}, strict=strict ) is None class BeebEmTeletextFont(_Probe): """BuildMode7Font reads 96 characters of 18 two-byte rows, 3456 bytes, and returns false at the first EOF, which stops BeebEm at startup; bytes past them are never read (Src/Video.cpp:167-215 at a982ba6).""" def test_short_is_refused_and_longer_is_read(self): self.assertFalse(self.accepts("beebem", "Teletext.fnt", b"\0" * 100)) self.assertTrue(self.accepts("beebem", "Teletext.fnt", b"\0" * 3456)) self.assertTrue(self.accepts("beebem", "Teletext.fnt", b"\0" * 4000)) class BeebEmSystemRoms(_Probe): """The OS, Tube, Z80 and Master 512 ROMs are read with one fread of the slot's size. A short read reports an error and BeebEm runs with what it read; bytes past the slot are never read (Src/BeebMem.cpp:1291-1308, Src/Tube.cpp:1540-1553, Src/Z80Support.cpp:247-261, Src/Master512CoPro.cpp:3777-3790 at a982ba6).""" CASES = (("OS12.rom", 16384), ("B+MOS.rom", 16384), ("MOS.rom", 16384), ("MOS400.rom", 16384), ("6502Tube.rom", 2048), ("Z80.rom", 4096), ("BIOS.rom", 16384)) def test_longer_is_read_and_shorter_only_warns(self): for name, size in self.CASES: with self.subTest(name=name): self.assertTrue(self.accepts("beebem", name, b"\0" * (size * 2))) self.assertTrue(self.accepts("beebem", name, b"\0" * (size // 2))) self.assertFalse( self.accepts("beebem", name, b"\0" * (size // 2), strict=True) ) class BeebEmSidewaysRoms(_Probe): """ReadRom loads any file up to MAX_PALROM_SIZE (131072) into a bank and refuses a larger one, leaving the bank empty (Src/BeebMem.cpp:1207-1262, Src/PALRom.h:52 at a982ba6).""" def test_up_to_128k_is_read_and_larger_refused(self): for size in (8192, 16384, 32768, 131072): with self.subTest(size=size): self.assertTrue(self.accepts("beebem", "BASIC2.rom", b"\0" * size)) self.assertFalse(self.accepts("beebem", "BASIC2.rom", b"\0" * 200000)) class BeetleLynxBootRom(_Probe): """CRom drops a boot ROM under 512 bytes and keeps the 0x88 fill, so no game boots; a longer one is read for its first 512 bytes (mednafen/lynx/rom.cpp:61-75 at fcdefcf).""" def test_short_is_refused_and_longer_is_read(self): self.assertFalse(self.accepts("beetle_lynx", "lynxboot.img", b"\0" * 256)) self.assertTrue(self.accepts("beetle_lynx", "lynxboot.img", b"\0" * 512)) self.assertTrue(self.accepts("beetle_lynx", "lynxboot.img", b"\0" * 1024)) class BeetleGbaBios(_Probe): """CPUInit copies the BIOS only when it is exactly 0x4000 bytes and otherwise falls back to HLE (mednafen/gba/GBA.cpp:2639-2673 at b158166).""" def test_only_16k_is_used(self): self.assertTrue(self.accepts("beetle_gba", "gba_bios.bin", b"\0" * 16384)) for size in (1000, 32768): with self.subTest(size=size): self.assertFalse(self.accepts("beetle_gba", "gba_bios.bin", b"\0" * size)) class Atari800SystemRoms(_Probe): """SYSROM_FindInDir takes a file of an allowed length whose CRC32 is in SYSROM_roms, else, by its lowercased name, any file of that length as a custom ROM (atari800/src/sysrom.c:88-112, 281-315, 387-416 at 4e7fbc7). BB01R4_OS.ROM and XEGAME.ROM are on no name list: only their CRC32 makes them that ROM.""" def test_named_roms_need_only_the_length(self): for name, size in (("ATARIOSA.ROM", 10240), ("ATARIOSB.ROM", 10240), ("ATARIXL.ROM", 16384), ("5200.rom", 2048), ("ATARIBAS.ROM", 8192)): with self.subTest(name=name): self.assertTrue(self.accepts("atari800", name, b"\1" * size)) self.assertFalse(self.accepts("atari800", name, b"\1" * (size + 1))) def test_unlisted_names_need_their_crc(self): for name, size in (("BB01R4_OS.ROM", 16384), ("XEGAME.ROM", 8192)): with self.subTest(name=name): self.assertFalse(self.accepts("atari800", name, b"\1" * size)) class AresAtari5200Bios(unittest.TestCase): """ares registers an Atari 5200 firmware by sha256 and refuses to load a game without it (desktop-ui/emulator/atari-5200.cpp:12-16, 82-88 at 4cb8d92); the profile left the system out, so no pack carried the BIOS.""" def test_the_profile_declares_it(self): profile = _profile("ares") self.assertIn("atari-5200", profile["systems"]) entry = next(f for f in profile["files"] if f.get("system") == "atari-5200") self.assertTrue(entry["required"]) self.assertEqual( entry["sha256"], "06b250f18983d058c0f156ce7ee88ae48b6eaf11e6f10f21dccf6ac7ffb6a6af", ) class A5200Bios(_Probe): """The core reads 0x800 bytes and falls back to the Altirra OS on a short read; bytes past them are never read (libretro/libretro.c:456-468 at 40c6f2f).""" def test_short_falls_back_and_longer_is_read(self): self.assertFalse(self.accepts("a5200", "5200.rom", b"\0" * 1024)) self.assertTrue(self.accepts("a5200", "5200.rom", b"\0" * 2048)) self.assertTrue(self.accepts("a5200", "5200.rom", b"\0" * 4096)) class BeetlePceSystemCards(_Probe): """PCE_LoadCD hands the file to HuC_Load, which skips a 512-byte copier header and checks no hash (mednafen/pce/pce.cpp:441-457, mednafen/pce/huc.cpp:185-202 at b96c11e). The profile carried md5 and sha1 values no code holds, and a headered card the core boots was reported as a hash discrepancy.""" def test_a_headered_card_is_not_judged(self): self.assertTrue(self.accepts("beetle_pce", "syscard3.pce", b"\0" * 262656)) def test_no_hash_the_code_does_not_hold(self): for entry in _profile("beetle_pce")["files"]: with self.subTest(name=entry["name"]): self.assertFalse({"md5", "sha1", "crc32", "sha256"} & set(entry)) class EightyOneRoms(_Probe): """memory_load reads the ROM and CRC32Block's value only picks the tape patches in rompatch.c; a ROM the table does not know loads without them (81/zx81/zx81.c:102-103, 81/Ace/ace.c:77-78, 81/zx81/rompatch.c at fa70949). The profile declared a crc32 check with no value.""" def test_an_unknown_rom_is_not_refused(self): for name, size in (("zx81.edition3.rom", 8192), ("zx80.rom", 4096), ("jupiterace.rom", 8192)): with self.subTest(name=name): self.assertTrue(self.accepts("81", name, b"\1" * size)) entry = next(f for f in _profile("81")["files"] if f["name"] == name) self.assertNotIn("crc32", entry.get("validation") or []) class AdvanceMameArchives(_Probe): """verify_length_and_hash checks each member and only adds a warning; the archive itself has no size or hash anyone checks (src/romload.c:327-363 at 21f8f6f). The 26 BIOS archives declared archive-level checks with no archive values.""" def test_no_archive_level_check(self): # a7800's MAME warns the same way (src/emu/romload.cpp:427-452 at 7a2afdc). for emulator in ("advancemame", "a7800"): for entry in _profile(emulator)["files"]: if entry.get("category") != "bios_zip": continue with self.subTest(emulator=emulator, name=entry["name"]): self.assertFalse(entry.get("validation")) self.assertTrue(entry.get("contents")) class MameMemberChecksOnlyWarn(unittest.TestCase): """libretro MAME and mame2015 warn on a member's wrong length or checksums and run (src/emu/romload.cpp:602-625 at 9069f39, src/emu/romload.c:501-519 at 7adbf44); an archive-level check with no archive value ran nothing.""" def test_no_valueless_archive_check(self): for emulator, names in ( ("mame", ("vsmile.zip", "qsound.zip", "ym2413.zip", "ym2608.zip")), ("cdi2015", ("cdimono1.zip",)), ): entries = {f["name"]: f for f in _profile(emulator)["files"]} for name in names: with self.subTest(emulator=emulator, name=name): self.assertFalse(entries[name].get("validation")) if __name__ == "__main__": unittest.main()