"""Device ROM sets read from a MAME -listxml and merged into a profile. Synthetic listxml and driver source, no network: the scan keeps only device sets with dumped ROMs, the source line is the ROM_START block holding them, and the merge adds sets, refreshes the contents of written ones without touching what a reader wrote, and leaves BIOS root sets alone. Each set is filed under the systems of the machines that can load it, never guessed. """ from __future__ import annotations import io import json import tempfile import unittest from pathlib import Path from scripts.common import yaml_load from scripts.scraper._hash_merge import ( DEVICE_SECTION, SOFTLIST_SECTION, mame_ref_role, merge_mame_devices, merge_mame_profile, merge_mame_support, ) from scripts.scraper.mame_devices import ( ABSENT, AMBIGUOUS, NO_FILE, MachineLinks, attribute_softlists, attribute_systems, default_floppy_samples, drive_form_factor, find_rom_block, floppy_drives, floppy_sample_systems, locate_sources, open_listxml, platform_seeds, profile_seeds, scan_device_sets, scan_release, selected_form_factor, source_path, ) LISTXML = b""" ]> \t \t\tNeo-Geo MV-6F \t\t \t \t \t\tQSound \t\t \t \t \t\tKeyboard & Mouse - New \t\t \t\t \t\t \t \t \t\tUndumped \t\t \t \t \t\tNo ROM \t """ KBD_SOURCE = """// keyboards ROM_START(kbd_old) \tROM_REGION(0x0800, "mcu", 0) \tROM_LOAD("6500-1", 0x0000, 0x0800, CRC(4a3fc332) SHA1(83b21d0c8b93fc9b9b3b287fde4ec8f3badac5a2) BAD_DUMP) \tROM_LOAD("bad.bin", 0x0800, 0x0010, CRC(00000011) SHA1(0000000000000000000000000000000000000011)) ROM_END ROM_START(kbd_new) \tROM_REGION(0x0800, "mcu", 0) \tROM_LOAD("6570-036", 0x0000, 0x0800, CRC(4a3fc332) SHA1(83b21d0c8b93fc9b9b3b287fde4ec8f3badac5a2)) \tROM_LOAD("bad.bin", 0x0800, 0x0010, CRC(00000011) SHA1(0000000000000000000000000000000000000011)) ROM_END """.splitlines() QSOUND_SOURCE = """#define QSOUND_ROMS \\ \tROM_LOAD16_WORD_SWAP("dl-1425.bin", 0x0000, 0x6000, CRC(d6cf5ef5) SHA1(555f50fe5cdf127619da7d854c03f4a244a0c501)) // ROM_START( commented ) CRC(d6cf5ef5) ROM_START( qsound ) \tROM_REGION16_BE( 0x2000, "dsp", 0 ) \tQSOUND_ROMS ROM_END """.splitlines() PROFILE = """emulator: MAME source: "https://github.com/libretro/mame" upstream: "https://github.com/mamedev/mame" upstream_commit: "f34f02505e32c1993c6a782b6814232cbfc74e36" core_version: "0.289" files: # SNK Neo Geo - name: neogeo.zip required: true category: bios_zip system: snk-neogeo-mvs source_ref: "upstream src/mame/snk/neogeo.cpp:2400" contents: - name: "sp-s2.sp1" description: "Europe MVS (Ver. 2)" size: 131072 crc32: "oldcrc32" - name: qsound.zip required: true category: bios_zip system: capcom-cps2 source_ref: "upstream src/devices/sound/qsound.cpp:121-125" note: >- Written by hand. contents: - name: "dl-1425.bin" description: "QSound DSP microcode" size: 24576 crc32: "d6cf5ef5" """ def _devices() -> dict[str, dict]: tag, devices = scan_device_sets(io.BytesIO(LISTXML)) assert tag == "mame0289" return devices def _fetch(path: str) -> list[str] | None: return { "src/devices/kbd.cpp": KBD_SOURCE, "src/devices/sound/qsound.cpp": QSOUND_SOURCE, }.get(path) class ScanKeepsDeviceSetsWithDumpedRoms(unittest.TestCase): def test_only_devices_with_a_crc_are_kept(self): devices = _devices() self.assertEqual(sorted(devices), ["kbd_new", "qsound"]) def test_rom_fields_bios_description_and_bad_dump(self): kbd = _devices()["kbd_new"] self.assertEqual(kbd["description"], "Keyboard & Mouse - New") self.assertEqual(kbd["sourcefile"], "devices/kbd.cpp") first, second = kbd["roms"] self.assertEqual( (first["name"], first["size"], first["crc32"], first["bios_description"]), ("6570-036", 2048, "4a3fc332", "Version 2"), ) self.assertTrue(second["bad_dump"]) def test_a_document_without_a_build_is_refused(self): with self.assertRaises(ValueError): scan_device_sets(io.BytesIO(b"")) def test_the_release_zip_is_read(self): import zipfile with tempfile.TemporaryDirectory() as tmp: pack = Path(tmp) / "mame0289lx.zip" with zipfile.ZipFile(pack, "w") as archive: archive.writestr("mame0289.xml", LISTXML) with open_listxml(pack) as stream: tag, devices = scan_device_sets(stream) self.assertEqual((tag, sorted(devices)), ("mame0289", ["kbd_new", "qsound"])) def test_source_paths(self): self.assertEqual(source_path("devices/sound/qsound.cpp"), "src/devices/sound/qsound.cpp") self.assertEqual(source_path("snk/neogeo.cpp"), "src/mame/snk/neogeo.cpp") class SourceLinesAreTheBlockHoldingTheRoms(unittest.TestCase): def test_found_through_a_rom_macro_past_a_comment(self): self.assertEqual( find_rom_block(QSOUND_SOURCE, "qsound", [("dl-1425.bin", "d6cf5ef5")]), ("5-8", ""), ) def test_a_block_inside_a_comment_is_not_a_block(self): source = [ "/* ROM_START( old )", '\tROM_LOAD("dl-1425.bin", 0, 0x6000, CRC(d6cf5ef5) SHA1(555f50fe5cdf127619da7d854c03f4a244a0c501))', "ROM_END */", "ROM_START( current )", '\tROM_LOAD("dl-1425.bin", 0, 0x6000, CRC(d6cf5ef5) SHA1(555f50fe5cdf127619da7d854c03f4a244a0c501))', "ROM_END", ] self.assertEqual( find_rom_block(source, "dsp", [("dl-1425.bin", "d6cf5ef5")]), ("4-6", "") ) def test_same_dump_under_two_names_is_told_apart_by_name(self): line, _ = find_rom_block( KBD_SOURCE, "kbd_new", [("6570-036", "4a3fc332"), ("bad.bin", "00000011")] ) self.assertEqual(line, "8-12") def test_not_found(self): self.assertEqual( find_rom_block(KBD_SOURCE, "x", [("x.bin", "deadbeef")]), (None, ABSENT) ) def test_two_blocks_and_no_way_to_choose(self): self.assertEqual( find_rom_block(KBD_SOURCE, "other", [("bad.bin", "00000011")]), (None, AMBIGUOUS), ) def test_the_label_device_rom_region_returns_settles_it(self): source = KBD_SOURCE + [ 'DEFINE_DEVICE_TYPE(KBD_X, kbd_x_device, "kbd_x", "Keyboard X")', "const tiny_rom_entry *kbd_x_device::device_rom_region() const", "{", "\treturn ROM_NAME(kbd_old);", "}", ] self.assertEqual( find_rom_block(source, "kbd_x", [("bad.bin", "00000011")]), ("2-6", "") ) def test_locate_sources_reports_what_it_cannot_place(self): devices = _devices() devices["ghost"] = { "description": "Ghost", "sourcefile": "devices/ghost.cpp", "roms": [{"name": "g.bin", "size": 1, "crc32": "00000001", "sha1": ""}], } unplaced = locate_sources(devices, _fetch, workers=2) self.assertEqual(unplaced, {"ghost": NO_FILE}) self.assertEqual(devices["qsound"]["source_file"], "src/devices/sound/qsound.cpp") self.assertEqual(devices["kbd_new"]["source_line"], "8-12") class MergeIntoTheProfile(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.path = Path(self._tmp.name) / "mame.yml" self.path.write_text(PROFILE, encoding="utf-8") self.devices = _devices() locate_sources(self.devices, _fetch, workers=2) def tearDown(self): self._tmp.cleanup() def _merge(self) -> dict[str, list[str]]: return merge_mame_devices(str(self.path), self.devices, write=True) def _entries(self) -> dict[str, dict]: data = yaml_load(self.path.read_text(encoding="utf-8")) return {f["name"]: f for f in data["files"]} def test_a_device_with_roms_is_added_under_the_section(self): report = self._merge() self.assertEqual(report["added"], ["kbd_new"]) text = self.path.read_text(encoding="utf-8") self.assertLess(text.index(DEVICE_SECTION), text.index("- name: kbd_new.zip")) entry = self._entries()["kbd_new.zip"] self.assertIs(entry["required"], False) self.assertEqual(entry["category"], "bios_zip") self.assertEqual(entry["source_ref"], "upstream src/devices/kbd.cpp:8-12") self.assertEqual( entry["note"], "Device ROM set, loaded when a machine instantiates `Keyboard & Mouse - New`.", ) self.assertEqual(entry["contents"][0]["description"], "`Version 2`") self.assertEqual(entry["contents"][0]["sha1"], "83b21d0c8b93fc9b9b3b287fde4ec8f3badac5a2") self.assertIs(entry["contents"][1]["bad_dump"], True) def test_devices_without_roms_are_not_written(self): self._merge() entries = self._entries() self.assertNotIn("nodump_dev.zip", entries) self.assertNotIn("empty_dev.zip", entries) def test_an_unplaced_set_is_written_without_a_line(self): del self.devices["kbd_new"]["source_line"] self._merge() self.assertNotIn("source_ref", self._entries()["kbd_new.zip"]) def test_a_written_entry_gets_its_contents_and_keeps_the_rest(self): report = self._merge() self.assertEqual(report["updated"], ["qsound"]) entry = self._entries()["qsound.zip"] self.assertIs(entry["required"], True) self.assertEqual(entry["system"], "capcom-cps2") self.assertEqual(entry["note"], "Written by hand.") self.assertEqual(entry["source_ref"], "upstream src/devices/sound/qsound.cpp:121-125") member = entry["contents"][0] self.assertEqual(member["description"], "QSound DSP microcode") self.assertEqual(member["sha1"], "555f50fe5cdf127619da7d854c03f4a244a0c501") def test_a_written_entry_without_a_ref_gets_the_found_one(self): self.path.write_text( PROFILE.replace(' source_ref: "upstream src/devices/sound/qsound.cpp:121-125"\n', ""), encoding="utf-8", ) self._merge() self.assertEqual( self._entries()["qsound.zip"]["source_ref"], "upstream src/devices/sound/qsound.cpp:5-8", ) def test_bios_roots_are_left_as_written(self): before = PROFILE[PROFILE.index(" # SNK"):PROFILE.index(" - name: qsound.zip")] self._merge() self.assertIn(before, self.path.read_text(encoding="utf-8")) def test_a_second_run_changes_nothing(self): self._merge() first = self.path.read_text(encoding="utf-8") report = self._merge() self.assertEqual((report["added"], report["updated"]), ([], [])) self.assertEqual(self.path.read_text(encoding="utf-8"), first) def test_new_sets_keep_name_order_inside_the_section(self): self._merge() self.devices["aaa_dev"] = dict(self.devices["kbd_new"]) self.devices["zzz_dev"] = dict(self.devices["kbd_new"]) self._merge() text = self.path.read_text(encoding="utf-8") order = [text.index(f"- name: {n}.zip") for n in ("aaa_dev", "kbd_new", "zzz_dev")] self.assertEqual(order, sorted(order)) self.assertTrue(text.endswith(' bad_dump: true\n')) def test_another_category_under_a_device_name_is_reported(self): self.path.write_text( PROFILE + "\n - name: kbd_new.zip\n category: game_data\n", encoding="utf-8" ) report = self._merge() self.assertEqual(report["conflict"], ["kbd_new"]) def test_a_new_bios_root_lands_before_the_device_section(self): self._merge() hashes = Path(self._tmp.name) / "h.json" hashes.write_text(json.dumps({ "version": "0.289", "commit": "f34f02505e32c1993c6a782b6814232cbfc74e36", "bios_sets": {"newbios": { "source_file": "src/mame/x/x.cpp", "source_line": 7, "roms": [{"name": "n.bin", "size": 4, "crc32": "12345678"}], }}, }), encoding="utf-8") merge_mame_profile(str(self.path), str(hashes), write=True) text = self.path.read_text(encoding="utf-8") self.assertLess(text.index("newbios.zip"), text.index(DEVICE_SECTION)) self.assertEqual(self._entries()["newbios.zip"]["source_ref"], "upstream src/mame/x/x.cpp:7") self.assertIn("kbd_new.zip", self._entries()) class RefRole(unittest.TestCase): def test_a_port_cites_mamedev_as_upstream(self): self.assertEqual(mame_ref_role({ "source": "https://github.com/libretro/mame", "upstream": "https://github.com/mamedev/mame", }), "upstream ") def test_a_profile_with_one_tree_cites_it_bare(self): self.assertEqual(mame_ref_role({"emulator": "MAME"}), "") GRAPH_LISTXML = b""" \t \t\t \t \t \t\t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t\t\t \t\t\t \t\t \t \t \t\t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t """ SEEDS = {"neogeo": "snk-neogeo-mvs", "apple2gs": "apple2gs"} def _release(): return scan_release(io.BytesIO(GRAPH_LISTXML)) class TheListxmlSaysWhoLoadsASet(unittest.TestCase): """infoxml.cpp writes romof, device_ref and slotoption: the scan keeps them for every element, device or machine.""" def test_links(self): machines = _release().machines self.assertEqual( machines["mslugj"], MachineLinks("mslug", False, ("cart_dev",), (), "snk/neogeo.cpp"), ) self.assertEqual(machines["apple2gs"].uses, ("a2diskii", "a2ssc")) # A device's ROMs are searched in its parent ROM device's set too. self.assertEqual(machines["cart_dev"].uses, ("cart_base",)) self.assertTrue(machines["cart_dev"].isdevice) def test_scan_device_sets_is_the_same_scan(self): release = _release() self.assertEqual( scan_device_sets(io.BytesIO(GRAPH_LISTXML)), (release.tag, release.devices) ) class DeviceSetsFollowTheMachinesThatLoadThem(unittest.TestCase): def setUp(self): self.systems = attribute_systems(_release().machines, SEEDS) def test_a_clone_reaches_the_bios_through_its_parent(self): self.assertEqual(self.systems["cart_dev"], ["snk-neogeo-mvs"]) self.assertEqual(self.systems["ng_memcard"], ["snk-neogeo-mvs"]) def test_a_parent_rom_device_goes_with_its_child(self): self.assertEqual(self.systems["cart_base"], ["snk-neogeo-mvs"]) def test_slot_cards_and_their_own_devices(self): self.assertEqual(self.systems["a2ssc"], ["apple2gs"]) self.assertEqual(self.systems["a2diskii"], ["apple2gs"]) self.assertEqual(self.systems["diskii_fdc"], ["apple2gs"]) def test_a_machine_no_entry_places_files_nothing(self): # aes shares neogeo.cpp with the MVS but no entry lists it: the # driver file is not a system, so ym2610 gets the MVS only through # the MVS machines, and pc_kbd nothing at all. self.assertEqual(self.systems["ym2610"], ["snk-neogeo-mvs"]) self.assertNotIn("pc_kbd", self.systems) def test_several_systems(self): systems = attribute_systems(_release().machines, {**SEEDS, "aes": "snk-neogeo-aes"}) self.assertEqual(systems["ym2610"], ["snk-neogeo-aes", "snk-neogeo-mvs"]) self.assertEqual(systems["ng_memcard"], ["snk-neogeo-mvs"]) def test_a_device_seed_passes_to_what_it_uses_never_up_its_romof(self): systems = attribute_systems( _release().machines, {"a2diskii": "apple2gs", "cart_base": "x-system"} ) self.assertEqual(systems["diskii_fdc"], ["apple2gs"]) self.assertNotIn("cart_dev", systems) def test_seeds_are_the_written_entries_only(self): text = ( PROFILE + "\n" + DEVICE_SECTION + "\n - name: ym2610.zip\n" " required: false\n category: bios_zip\n system: snk-neogeo-mvs\n" ) self.assertEqual( profile_seeds(text), {"neogeo": "snk-neogeo-mvs", "qsound": "capcom-cps2"} ) class TheMergeWritesTheSystems(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.path = Path(self._tmp.name) / "mame.yml" self.path.write_text(PROFILE, encoding="utf-8") self.devices = _release().devices self.devices["qsound"] = _devices()["qsound"] systems = attribute_systems(_release().machines, SEEDS) for name, device in self.devices.items(): device["systems"] = systems.get(name, []) def tearDown(self): self._tmp.cleanup() def _merge(self) -> dict[str, list[str]]: return merge_mame_devices(str(self.path), self.devices, write=True) def _entries(self) -> dict[str, dict]: data = yaml_load(self.path.read_text(encoding="utf-8")) return {f["name"]: f for f in data["files"]} def test_one_system_several_or_none(self): self.devices["ym2610"]["systems"] = ["snk-neogeo-aes", "snk-neogeo-mvs"] self._merge() entries = self._entries() self.assertEqual(entries["a2diskii.zip"]["system"], "apple2gs") self.assertNotIn("systems", entries["a2diskii.zip"]) self.assertEqual(entries["ym2610.zip"]["systems"], ["snk-neogeo-aes", "snk-neogeo-mvs"]) self.assertNotIn("system", entries["ym2610.zip"]) self.assertNotIn("system", entries["pc_kbd.zip"]) self.assertNotIn("systems", entries["pc_kbd.zip"]) text = self.path.read_text(encoding="utf-8") block = text[text.index("- name: a2diskii.zip"):] self.assertLess(block.index("category: bios_zip"), block.index("system: apple2gs")) def test_a_written_entry_keeps_its_system(self): self.devices["qsound"]["systems"] = ["apple2gs"] self._merge() self.assertEqual(self._entries()["qsound.zip"]["system"], "capcom-cps2") def test_a_refresh_rewrites_the_generated_systems(self): self._merge() self.devices["a2diskii"]["systems"] = ["apple2gs", "coco"] self.devices["cart_dev"]["systems"] = [] report = self._merge() self.assertIn("a2diskii", report["updated"]) entries = self._entries() self.assertEqual(entries["a2diskii.zip"]["systems"], ["apple2gs", "coco"]) self.assertNotIn("system", entries["cart_dev.zip"]) self.devices["a2diskii"]["systems"] = ["coco"] self._merge() entries = self._entries() self.assertEqual(entries["a2diskii.zip"]["system"], "coco") self.assertNotIn("systems", entries["a2diskii.zip"]) # The list items of systems: went with it, the member list did not. self.assertEqual(entries["a2diskii.zip"]["contents"][0]["name"], "341-0027.bin") def test_a_second_run_changes_nothing(self): self._merge() first = self.path.read_text(encoding="utf-8") report = self._merge() self.assertEqual(report["updated"], []) self.assertEqual(self.path.read_text(encoding="utf-8"), first) class APickReadsTheEntrySystems(unittest.TestCase): """--system on a pack and on the installer selects by the entry's own systems: before the profile's.""" SYSTEMS = ["apple2gs", "coco", "snk-neogeo-mvs"] def test_entry_system_ids(self): from scripts.common import entry_system_ids profile = {"systems": self.SYSTEMS} self.assertEqual(entry_system_ids({"system": "coco"}, profile), ["coco"]) self.assertEqual( entry_system_ids({"systems": ["apple2gs", "coco"]}, profile), ["apple2gs", "coco"] ) self.assertEqual(entry_system_ids({}, profile), self.SYSTEMS) def test_entry_serves_systems(self): from scripts.common import entry_serves_systems profile = {"systems": self.SYSTEMS} entry = {"systems": ["apple2gs", "coco"]} self.assertTrue(entry_serves_systems(entry, profile, ["coco"])) self.assertFalse(entry_serves_systems(entry, profile, ["snk-neogeo-mvs"])) self.assertTrue(entry_serves_systems({}, profile, ["snk-neogeo-mvs"])) def test_the_gap_report_and_the_extras_carry_them(self): import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) from packextras import _collect_emulator_extras, _extra_system_ids from verify import find_undeclared_files profile = { "emulator": "MAME", "type": "libretro", "systems": self.SYSTEMS, "files": [{"name": "a2diskii.zip", "category": "bios_zip", "systems": ["apple2gs", "coco"]}], } config = {"cores": ["mame"], "systems": {}} db = { "files": {"s1": {"name": "a2diskii.zip", "path": "bios/Apple/a2diskii.zip", "size": 10, "sha1": "s1", "md5": "m1", "crc32": "12345678"}}, "indexes": {"by_name": {"a2diskii.zip": ["s1"]}, "by_md5": {"m1": "s1"}, "by_crc32": {"12345678": "s1"}}, } (gap,) = find_undeclared_files(config, "emulators", db, emu_profiles={"mame": profile}) self.assertEqual(gap["systems"], ["apple2gs", "coco"]) (extra,) = _collect_emulator_extras( config, "emulators", db, set(), "", {"mame": profile}, include_all=True ) self.assertEqual(_extra_system_ids(extra), ["apple2gs", "coco"]) def test_the_schema_and_the_profile_list(self): try: from scripts import validate_schemas except ImportError as exc: self.skipTest(f"validate_schemas needs jsonschema: {exc}") validator = validate_schemas._validator("emulator.schema.json") root = Path(__file__).resolve().parents[1] base = {"emulator": "x", "type": "libretro", "systems": ["a", "b"]} both = {**base, "files": [{"name": "x.zip", "system": "a", "systems": ["a", "b"]}]} self.assertTrue(list(validator.iter_errors(both))) one = {**base, "files": [{"name": "x.zip", "systems": ["a"]}]} self.assertTrue(list(validator.iter_errors(one))) fine = {**base, "files": [{"name": "x.zip", "systems": ["a", "b"]}]} self.assertEqual(list(validator.iter_errors(fine)), []) outside = {**base, "files": [{"name": "x.zip", "systems": ["a", "c"]}]} found = validate_schemas._systems_outside_profile(root / "emulators" / "x.yml", outside) self.assertEqual(len(found), 1) self.assertIn("system c", found[0]) single = {**base, "files": [{"name": "x.zip", "system": "c"}]} found = validate_schemas._systems_outside_profile(root / "emulators" / "x.yml", single) self.assertEqual(len(found), 1) self.assertIn("system c", found[0]) listed = {**base, "files": [{"name": "x.zip", "system": "a"}]} self.assertEqual( validate_schemas._systems_outside_profile(root / "emulators" / "x.yml", listed), [] ) SUPPORT_LISTXML = b""" \t \t\t \t\t \t \t \t\t \t \t \t\t \t\t\t \t\t \t \t \t\t \t\t \t \t \t\t \t \t \t\t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t \t \t\t \t \t \t """ SUPPORT_SEEDS = {"bbcb": "acorn-bbc-micro", "apple2gs": "apple2gs", "pcw": "pcw"} FLOPPY_CPP = """\ floppy_sound_device::floppy_sound_device(const machine_config &mconfig, const char *tag) \t: samples_device(mconfig, FLOPPYSOUND, tag, owner, clock), \tm_spin_kind(floppy_sound_samples::QUIET) { \t// m_default_samples.add_spin_sample("commented_out", 0); \tm_default_samples.set_form_factor(floppy_image::FF_35, "floppy"); \tm_default_samples.add_spin_sample("35_spin", floppy_sound_samples::START_EMPTY); \tm_default_samples.add_seek_sample("35_seek", 2, 3); \tm_default_samples.set_form_factor(floppy_image::FF_525, "floppy"); \tm_default_samples.add_step_sample("525_step"); } DEFINE_DEVICE_TYPE(FLOPPY_35_DD, floppy_35_dd, "floppy_35_dd", "3.5 inch drive") DEFINE_DEVICE_TYPE(FLOPPY_525_QD, floppy_525_qd, "floppy_525_qd", "5.25 inch drive") DEFINE_DEVICE_TYPE(FLOPPY_3_SSSD, floppy_3_sssd, "floppy_3_sssd", "3 inch drive") DEFINE_DEVICE_TYPE(FLOPPY_8_SSSD, floppy_8_sssd, "floppy_8_sssd", "8 inch drive") void floppy_35_dd::setup_characteristics() { \tm_form_factor = floppy_image::FF_35; } void floppy_525_qd::setup_characteristics() { \tm_form_factor = floppy_image::FF_525; } void floppy_3_sssd::setup_characteristics() { \tm_form_factor = floppy_image::FF_3; } void floppy_8_sssd::setup_characteristics() { \tm_form_factor = floppy_image::FF_8; } """ def _support(): machines = scan_release(io.BytesIO(SUPPORT_LISTXML)).machines return machines, attribute_systems(machines, SUPPORT_SEEDS) class SoftwareListsFollowTheMachinesThatDeclareThem(unittest.TestCase): """software_list_device::parse opens .xml for each list a machine declares: a --system pick needs the lists of that system's machines.""" def test_the_scan_keeps_lists_and_source_files(self): machines = scan_release(io.BytesIO(SUPPORT_LISTXML)).machines self.assertEqual(machines["bbcb"].softlists, ("bbc_flop",)) self.assertEqual(machines["a2disk35"].sourcefile, "devices/bus/a2bus/a2disk35.cpp") def test_lists_of_machines_clones_and_slot_cards(self): lists = attribute_softlists(*_support()) self.assertEqual(lists["bbc_flop"], ["acorn-bbc-micro"]) self.assertEqual(lists["bbc_flop_us"], ["acorn-bbc-micro"]) self.assertEqual(lists["apple2gs_flop"], ["apple2gs"]) # No entry places the PC: its list serves no system of the profile. self.assertNotIn("ibm5150", lists) class FloppySamplesFollowTheDrives(unittest.TestCase): """Every drive adds a floppy sound device that loads the default list of the form factor its drive selects (floppy.cpp:1428-1473, 1709-1738).""" def test_default_list_by_form_factor(self): self.assertEqual( default_floppy_samples(FLOPPY_CPP), {"35_spin": "FF_35", "35_seek": "FF_35", "525_step": "FF_525"}, ) def test_drive_form_factor(self): self.assertEqual(drive_form_factor(FLOPPY_CPP, "floppy_35_dd"), "FF_35") self.assertEqual(drive_form_factor(FLOPPY_CPP, "floppy_3_sssd"), "FF_3") self.assertEqual(drive_form_factor(FLOPPY_CPP, "missing"), "") def test_select_falls_back_like_the_code(self): available = {"FF_35", "FF_525"} self.assertEqual(selected_form_factor("FF_35", available), "FF_35") self.assertEqual(selected_form_factor("FF_3", available), "FF_35") self.assertEqual(selected_form_factor("FF_8", available), "FF_525") self.assertEqual(selected_form_factor("FF_TWIG", available), "FF_525") self.assertEqual(selected_form_factor("FF_3", {"FF_525"}), "FF_525") self.assertEqual(selected_form_factor("FF_35", set()), "") def test_samples_go_to_the_systems_of_the_drives(self): machines, systems = _support() self.assertEqual( floppy_drives(machines), ["floppy_35_dd", "floppy_3_sssd", "floppy_525_qd", "floppy_8_sssd"], ) samples = default_floppy_samples(FLOPPY_CPP) form_factors = {d: drive_form_factor(FLOPPY_CPP, d) for d in floppy_drives(machines)} found = floppy_sample_systems(machines, systems, samples, form_factors) self.assertEqual(found["35_spin"], ["apple2gs", "pcw"]) self.assertEqual(found["525_step"], ["acorn-bbc-micro"]) def test_an_unread_form_factor_loads_every_list(self): machines, systems = _support() samples = default_floppy_samples(FLOPPY_CPP) found = floppy_sample_systems(machines, systems, samples, {}) self.assertEqual(found["525_step"], ["acorn-bbc-micro", "apple2gs", "pcw"]) class PlatformFilesSeedMachines(unittest.TestCase): def test_a_platform_statement_seeds_a_machine(self): machines = scan_release(io.BytesIO(SUPPORT_LISTXML)).machines seeds, conflicts = platform_seeds( [ ("bbcb.zip", "acorn-bbc-micro"), ("bbcb.zip", "bbcmicro"), # not a system of the profile ("ibm5150.zip", "ibm-pc"), # idem ("a2disk35.zip", "apple2gs"), # a device, not a machine ("pcw.zip", "pcw"), # written by the profile ("apple2gs.zip", "apple2gs"), ("apple2gs.zip", "acorn-bbc-micro"), ("missing.zip", "pcw"), ("bbcb.rom", "acorn-bbc-micro"), ], machines, ["acorn-bbc-micro", "apple2gs", "pcw"], {"pcw": "pcw"}, ) self.assertEqual(seeds, {"bbcb": "acorn-bbc-micro"}) self.assertEqual(conflicts, {"apple2gs": ["acorn-bbc-micro", "apple2gs"]}) SUPPORT_PROFILE = """emulator: MAME source: "https://github.com/libretro/mame" upstream: "https://github.com/mamedev/mame" systems: - acorn-bbc-micro - apple2gs files: - name: "hash/" path: "mame/hash/" type: directory mode: libretro category: game_data required: false - name: "hash/" path: "hash/" type: directory mode: standalone category: game_data required: false bundled: true - name: "35_spin.wav" path: "mame/samples/floppy/35_spin.wav" mode: libretro category: game_data required: false size: 4 - name: "525_step.wav" path: "mame/samples/floppy/525_step.wav" mode: libretro category: game_data system: pcw required: false - name: neogeo.zip required: true category: bios_zip system: acorn-bbc-micro """ class TheSupportMergeWritesListsAndSamples(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.path = Path(self._tmp.name) / "mame.yml" self.path.write_text( SUPPORT_PROFILE + "\n" + DEVICE_SECTION + "\n\n - name: ym2610.zip\n" " required: false\n category: bios_zip\n", encoding="utf-8", ) self.lists = {"bbc_flop": ["acorn-bbc-micro"], "apple2gs_flop": ["acorn-bbc-micro", "apple2gs"]} self.samples = {"35_spin": ["apple2gs"]} def tearDown(self): self._tmp.cleanup() def _merge(self) -> dict: return merge_mame_support(str(self.path), self.lists, self.samples, write=True) def _entries(self) -> list[dict]: return yaml_load(self.path.read_text(encoding="utf-8"))["files"] def test_one_entry_per_list_under_the_delivered_tree(self): self._merge() text = self.path.read_text(encoding="utf-8") self.assertLess(text.index(SOFTLIST_SECTION), text.index(DEVICE_SECTION)) lists = [e for e in self._entries() if str(e["name"]).endswith(".xml")] self.assertEqual( [(e["name"], e["mode"], e.get("system"), e.get("systems")) for e in lists], [ ("apple2gs_flop.xml", "libretro", None, ["acorn-bbc-micro", "apple2gs"]), ("bbc_flop.xml", "libretro", "acorn-bbc-micro", None), ], ) self.assertFalse(any(str(e.get("path")).startswith("hash/") and e["name"] != "hash/" for e in self._entries())) self.assertIn("upstream src/emu/softlist_dev.cpp:281-313", text) def test_samples_get_the_systems_of_their_drives(self): self._merge() entries = {e["name"]: e for e in self._entries()} self.assertEqual(entries["35_spin.wav"]["system"], "apple2gs") self.assertEqual(entries["35_spin.wav"]["size"], 4) # No drive of a profile system loads it any more: it serves them all. self.assertNotIn("system", entries["525_step.wav"]) self.assertEqual(entries["neogeo.zip"]["system"], "acorn-bbc-micro") def test_a_refresh_rewrites_the_section(self): self._merge() self.lists = {"bbc_flop": ["apple2gs"]} self._merge() names = [e["name"] for e in self._entries()] self.assertNotIn("apple2gs_flop.xml", names) self.assertEqual(names.count("bbc_flop.xml"), 1) self.assertEqual(names[-1], "ym2610.zip") self.lists = {} self._merge() self.assertNotIn(SOFTLIST_SECTION, self.path.read_text(encoding="utf-8")) def test_a_second_run_changes_nothing(self): self._merge() first = self.path.read_text(encoding="utf-8") report = self._merge() self.assertEqual(report["changed"], []) self.assertEqual(report["samples"], []) self.assertEqual(self.path.read_text(encoding="utf-8"), first) def test_new_bios_roots_and_seeds_stay_ahead_of_the_sections(self): self._merge() text = self.path.read_text(encoding="utf-8") self.assertEqual(profile_seeds(text), {"neogeo": "acorn-bbc-micro"}) def test_a_pick_takes_its_lists_and_leaves_the_others(self): from scripts.common import entry_serves_systems, expand_directory_entries self._merge() profile = yaml_load(self.path.read_text(encoding="utf-8")) db = {"files": {}, "indexes": {"by_path_suffix": {}}} for index, dest in enumerate( ["mame/hash/bbc_flop.xml", "mame/hash/apple2gs_flop.xml", "mame/hash/nes.xml"] ): sha1 = f"{index:040d}" db["files"][sha1] = {"path": f"bios/Other/{dest}", "name": dest.rsplit("/", 1)[1]} db["indexes"]["by_path_suffix"][dest] = [sha1] files = [e for e in profile["files"] if e.get("mode") != "standalone"] files = expand_directory_entries(files, db) picked = sorted( str(e.get("path")) for e in files if str(e.get("path", "")).startswith("mame/hash/") and entry_serves_systems(e, profile, ["apple2gs"]) ) self.assertEqual(picked, ["mame/hash/apple2gs_flop.xml", "mame/hash/nes.xml"]) class TheRepositoryProfile(unittest.TestCase): """mame.yml as committed: every list filed is one the hash tree holds.""" def test_generated_lists_sit_inside_the_hash_tree(self): path = Path(__file__).resolve().parents[1] / "emulators" / "mame.yml" text = path.read_text(encoding="utf-8") start = text.find(SOFTLIST_SECTION) self.assertNotEqual(start, -1) section = yaml_load("files:\n" + text[start:text.index(DEVICE_SECTION)])["files"] self.assertTrue(section) for entry in section: self.assertEqual(entry["path"], "mame/hash/" + entry["name"]) self.assertTrue(entry.get("system") or entry.get("systems"), entry["name"]) class Cli(unittest.TestCase): def test_devices_needs_a_listxml(self): from scripts.scraper.mame_hash_scraper import build_parser, main # noqa: F401 import sys from unittest import mock with mock.patch.object(sys, "argv", ["x", "--devices"]), \ mock.patch("sys.stderr", new=io.StringIO()): with self.assertRaises(SystemExit) as raised: main() self.assertEqual(raised.exception.code, 2) class TheDevicesFollowTheRelease(unittest.TestCase): """Pipeline step 2a moved mame.yml to a new release and left the device sets on the lines of the previous one: the profile named two releases.""" def _run(self, moves: bool, dry_run: bool = False) -> list: import argparse from unittest import mock from scripts.scraper import mame_hash_scraper as scraper with tempfile.TemporaryDirectory() as directory: profile = Path(directory) / "mame.yml" profile.write_text('core_version: "0.289"\nfiles: []\n') def merge(path, *_args, **_kwargs): if moves: profile.write_text('core_version: "0.290"\nfiles: []\n') merged = [] with mock.patch.multiple( scraper, _EMULATORS_DIR=Path(directory), _fetch_hashes=lambda force: {"bios_sets": {}, "version": "0.290"}, _find_mame_profiles=lambda: [profile], compute_diff=lambda *a, **k: {"updated": ["x"], "added": []}, _format_diff=lambda *a, **k: [], merge_mame_profile=merge, fetch_release_listxml=lambda version: Path(f"mame{version}lx.zip"), _merge_devices=merged.append, ), mock.patch("sys.stdout", new=io.StringIO()): scraper._run(argparse.Namespace(force=False, json=False, dry_run=dry_run)) return merged def test_a_moved_pin_merges_that_release_devices(self): (args,) = self._run(moves=True) self.assertEqual(args.listxml, Path("mame0.290lx.zip")) self.assertFalse(args.dry_run) def test_an_unmoved_pin_leaves_the_devices(self): self.assertEqual(self._run(moves=False), []) self.assertEqual(self._run(moves=True, dry_run=True), []) if __name__ == "__main__": unittest.main()