diff --git a/emulators/aethersx2.yml b/emulators/aethersx2.yml index 7466b06bb..96af44ebe 100644 --- a/emulators/aethersx2.yml +++ b/emulators/aethersx2.yml @@ -22,6 +22,8 @@ systems: bios_directory: "bios/" resources_directory: "resources/" +standalone_directories: + - "[Folders] Bios = bios" data_root: android: @@ -53,7 +55,8 @@ notes: | A BIOS image is required and boot stops without one. It belongs in the bios folder of the data directory given in data_root, and the Folders section of PCSX2.ini names another path when set. - ref: AetherSX2-release MacOS/AetherSX2, aethersx2 archive/desktop/mac/README.md + ref: AetherSX2-release MacOS/AetherSX2, aethersx2 archive/desktop/mac/README.md, + aethersx2/pcsx2/Pcsx2Config.cpp:935-946 Detection is by content. The scan lists the BIOS folder with a *.* mask, keeps files between 4 and 8 MB, and accepts an image whose romdir carries a RESET diff --git a/emulators/armsx2.yml b/emulators/armsx2.yml index f9134ab1c..04ca1de72 100644 --- a/emulators/armsx2.yml +++ b/emulators/armsx2.yml @@ -24,6 +24,8 @@ systems: bios_directory: "bios/" resources_directory: "resources/" +standalone_directories: + - "[Folders] Bios = bios" data_root: android: diff --git a/emulators/quasi88kai.yml b/emulators/quasi88kai.yml index 5f149dca2..a5ff83db3 100644 --- a/emulators/quasi88kai.yml +++ b/emulators/quasi88kai.yml @@ -16,6 +16,8 @@ built_by: [retropie] systems: - nec-pc-88 - nec-pc-80 +standalone_directories: + - "-romdir = rom" notes: | Continuation of Showzoh Fukunaga's QUASI88, version 0.7.1 at the pinned diff --git a/scripts/launchers.py b/scripts/launchers.py index f8199f870..7b898da25 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -312,8 +312,10 @@ def launcher_places(config: dict) -> list[LauncherPlace]: # component scripts $bios_path. BIOS_ROOT_VARIABLES = ("$biosdir", "$home/RetroPie/BIOS", "${biosPath}", "$bios_path") -# The kinds that hand an emulator a whole directory. -HANDED_KINDS = ("links", "passes_path") +# The kinds that hand an emulator a whole directory. A scans_dir record sets +# the directory the emulator picks its file from: ROCKNIX writes ARMSX2's +# [Folders] Bios, EmuDeck PCSX2's. +HANDED_KINDS = ("links", "passes_path", "scans_dir") # How a launcher record says the platform starts the emulator in a directory # (retropie_launchers._working_directory), and the handle a profile gives @@ -357,6 +359,19 @@ def normalize_handle(token: str) -> str: return _FLATPAK_DATA.sub("$XDG_DATA_HOME", token) +_INI_KEY = re.compile(r"(\[[^\]\s]+\])\s+([^\s,;=()]+)") + + +def ini_keys(passes_to: str) -> frozenset[str]: + """The INI keys a record's ``passes_to`` writes, as "[Section] Key". + + A key alone is ambiguous: PCSX2 reads its BIOS folder from [Folders] + Bios and the image name from [Filenames] BIOS. A profile declares the + key it resolves a folder from in that spelling ("[Folders] Bios = bios"). + """ + return frozenset(f"{section} {key}" for section, key in _INI_KEY.findall(passes_to)) + + def _folder_in_bios(directory: str, base: str) -> str | None: """A directory relative to the BIOS root, None when it lies outside.""" relative = _bios_relative(directory, base) @@ -485,7 +500,7 @@ def handed_directories(config: dict) -> list[HandedDirectory]: handle for token in passes_to.split() if (handle := normalize_handle(token)) and handle != directory - ) + ) | ini_keys(passes_to) folder = _folder_in_bios(part, base) for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")): found.append(HandedDirectory( diff --git a/scripts/packextras.py b/scripts/packextras.py index 559014fd9..3731ec9ff 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -567,7 +567,15 @@ def _agnostic_scan_extras( agnostic_votes.get(directory + "/", 0) + 1 ) - for profile, standalone in platform_layouts(emu_name, profile, standalone_set or set()): + own = profile + for profile, standalone in platform_layouts(emu_name, own, standalone_set or set()): + # A layout copies each entry in order (a launcher's folder, a + # system directory): its files are looked up as the profile's + # own, or a bound layout scans nothing (KNULLI's bios/ps2). + origin = { + id(view): entry + for view, entry in zip(profile.get("files", []), own.get("files", [])) + } for f in profile.get("files", []): if not is_profile_agnostic and not f.get("agnostic"): continue @@ -617,8 +625,8 @@ def _agnostic_scan_extras( companion_ext, image = _companion_of(profile, fname) path_prefix = ( - resolved_dirs.get(id(image), "") if image - else resolved_dirs.get(id(f), "") + resolved_dirs.get(id(origin.get(id(image), image)), "") if image + else resolved_dirs.get(id(origin.get(id(f), f)), "") ) # A placeholder (.bin) resolves to nothing and anchors no # directory. The folders where the system's other profiles @@ -639,7 +647,10 @@ def _agnostic_scan_extras( # A name matching one file identifies it. A name matching several # identifies nothing on its own, so the profile's other files have # to agree on the directory before the scan walks it. - ambiguous = id(f) in named_only and len(by_name.get(fname, [])) > 1 + ambiguous = ( + id(origin.get(id(f), f)) in named_only + and len(by_name.get(fname, [])) > 1 + ) if ambiguous and agnostic_votes.get(path_prefix, 0) < 2: continue diff --git a/scripts/verify.py b/scripts/verify.py index 73bc6a769..9aaf6b4ca 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -564,15 +564,19 @@ def find_undeclared_files( if emu_name not in relevant: continue - # Skip agnostic profiles entirely (filename-agnostic BIOS detection) - if profile.get("bios_mode") == "agnostic": - continue + # A filename-agnostic profile is served by the scan (packextras), all + # but the entries its layout reads outside the BIOS tree: those are + # named here, or a launcher folder drops them without a word + # (ARMSX2's arcade/proverb.zip once ROCKNIX binds bios/armsx2). + agnostic = profile.get("bios_mode") == "agnostic" for profile, is_standalone in platform_layouts(emu_name, profile, standalone_set): for f in expand_directory_entries( profile.get("files", []), db, is_standalone ): + if agnostic and not f.get("unplaced"): + continue fname = f.get("name", "") # One destination rule for the dedup key and for the entry: a # standalone build without standalone_path falls back on path. diff --git a/tests/test_launcher_placement.py b/tests/test_launcher_placement.py index 93e05ef2b..95170a93a 100644 --- a/tests/test_launcher_placement.py +++ b/tests/test_launcher_placement.py @@ -553,5 +553,173 @@ class EachProgramItsOwnProfile(unittest.TestCase): self.assertEqual(resolve_platform_cores(config, profiles), {"emu"}) +BIOS_SETTING = { + "emulator": "ps2emu", "build": "standalone", "kind": "passes_path", "names": [], + "search_dir": "/userdata/bios/ps2", + "passes_to": "inis/PCSX2.ini [Folders] Bios = ../../../bios/ps2, relative to the data root", + "source_ref": "ps2emuGenerator.py:219", +} +# The same folder through a scans_dir record, and a record setting the image +# name alone, which hands no folder. +BIOS_SCAN = {**BIOS_SETTING, "kind": "scans_dir", "passes_to": "PCSX2.ini [Folders] Bios"} +BIOS_NAME = {**BIOS_SETTING, "passes_to": "PCSX2.ini [Filenames] BIOS"} + + +class ASettingSetsTheBiosFolder(unittest.TestCase): + """KNULLI sets AetherSX2's [Folders] Bios to bios/ps2. + + The profile reads its BIOS images by content from bios/ under its data + root, and names that setting in standalone_directories. Every image the + scan finds goes to ps2/ in the pack, the manifest and verify, and a file + the layout keeps outside its bios/ folder is named, never packed. + """ + + def setUp(self) -> None: + _emulator_profiles_cache.clear() + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + files, by_md5, by_name = {}, {}, {} + for folder, name, data in ( + ("Sony/PlayStation 2", "img-a.bin", b"PS2 IMAGE A....."), + ("Sony/PlayStation 2", "img-b.bin", b"PS2 IMAGE B....."), + ("Other/ps2emu/arcade", "boot.zip", b"BOOT PROGRAMS"), + ): + where = self.root / "bios" / folder + where.mkdir(parents=True, exist_ok=True) + (where / name).write_bytes(data) + sha1 = hashlib.sha1(data).hexdigest() + md5 = hashlib.md5(data).hexdigest() + files[sha1] = {"path": str(where / name), "name": name, "sha1": sha1, + "md5": md5, "crc32": "", "size": len(data)} + by_md5[md5] = sha1 + by_name[name] = [sha1] + self.db = { + "files": files, + "indexes": {"by_md5": by_md5, "by_name": by_name, "by_crc32": {}, + "by_path_suffix": {}}, + } + self.platforms = self.root / "platforms" + self.emulators = self.root / "emulators" + self.platforms.mkdir() + self.emulators.mkdir() + platforms = {"setplat": [BIOS_SETTING], "scanplat": [BIOS_SCAN], + "nameplat": [BIOS_NAME], "plainplat": []} + for name, launchers in platforms.items(): + (self.platforms / f"{name}.yml").write_text(yaml.safe_dump({ + "platform": name, "version": "1", "verification_mode": "existence", + "base_destination": "bios", "cores": ["ps2emu"], + "standalone_cores": ["ps2emu"], "launchers": launchers, + "systems": {"sony-playstation-2": {"files": []}}, + })) + (self.platforms / "_registry.yml").write_text(yaml.safe_dump({"platforms": { + name: {"config": f"{name}.yml"} for name in platforms + }})) + (self.emulators / "ps2emu.yml").write_text(yaml.safe_dump({ + "emulator": "PS2Emu", "type": "standalone", "bios_mode": "agnostic", + "cores": ["ps2emu"], "systems": ["sony-playstation-2"], + "bios_directory": "bios/", + "standalone_directories": ["[Folders] Bios = bios"], + "files": [ + {"name": "img-a.bin", "path": "bios/img-a.bin", "required": True, + "min_size": 16, "max_size": 16, "validation": ["size"]}, + # Read below the data root, never below its bios/ folder. + {"name": "boot.zip", "standalone_path": "arcade/boot.zip", + "mode": "standalone", "required": False}, + ], + })) + + def pack_names(self, platform: str) -> list[str]: + with redirect_stdout(io.StringIO()): + zip_path = generate_pack( + platform, str(self.platforms), self.db, str(self.root / "bios"), + str(self.root / "out" / platform), emulators_dir=str(self.emulators), + offline=True, + ) + with zipfile.ZipFile(zip_path) as zf: + return sorted(n for n in zf.namelist() if n.endswith((".bin", ".zip"))) + + def manifest(self, platform: str) -> dict: + with redirect_stdout(io.StringIO()): + return generate_manifest( + platform, str(self.platforms), self.db, str(self.root / "bios"), + str(self.platforms / "_registry.yml"), + emulators_dir=str(self.emulators), offline=True, + ) + + def verify_dests(self, platform: str) -> list[str]: + config = load_platform_config(platform, str(self.platforms)) + result = verify_platform(config, self.db, str(self.emulators)) + return sorted(d for d in result["pack_destinations"] if d.endswith((".bin", ".zip"))) + + def assert_placed(self, platform: str, expected: list[str]) -> None: + self.assertEqual(self.pack_names(platform), expected) + self.assertEqual( + sorted(f["dest"] for f in self.manifest(platform)["files"]), expected + ) + self.assertEqual(self.verify_dests(platform), expected) + + def test_every_scanned_image_lands_in_the_folder_set(self) -> None: + self.assert_placed("setplat", ["ps2/img-a.bin", "ps2/img-b.bin"]) + + def test_a_scans_dir_record_sets_it_too(self) -> None: + self.assert_placed("scanplat", ["ps2/img-a.bin", "ps2/img-b.bin"]) + + def test_the_image_name_setting_hands_no_folder(self) -> None: + self.assert_placed("nameplat", ["img-a.bin", "img-b.bin"]) + + def test_a_platform_without_the_record_keeps_the_layout(self) -> None: + self.assert_placed("plainplat", ["img-a.bin", "img-b.bin"]) + + def test_a_file_outside_the_bios_folder_is_named(self) -> None: + omitted = [o for o in self.manifest("setplat")["omitted_files"] + if o["name"] == "boot.zip"] + self.assertEqual( + [(o["dest"], o["root"], o["reason"], o["cores"]) for o in omitted], + [("arcade/boot.zip", "emulator", "outside_bios_folder", ["ps2emu"])], + ) + + def test_the_ini_key_carries_its_section(self) -> None: + from launchers import ini_keys + + self.assertEqual( + ini_keys("PCSX2.ini [Folders] Bios = /storage/roms/bios/armsx2, [Filenames] BIOS"), + {"[Folders] Bios", "[Filenames] BIOS"}, + ) + self.assertEqual(ini_keys("command line -rompath"), frozenset()) + + +class AetherSX2OnKnulli(unittest.TestCase): + """The real profile against KNULLI's aethersx2Generator.py:219 record.""" + + def test_the_bios_images_land_in_ps2(self) -> None: + profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["aethersx2"] + config = load_platform_config("knulli", str(REPO_ROOT / "platforms")) + (view, standalone), = platform_layouts("aethersx2", profile, standalone_set(config)) + self.assertTrue(standalone) + for entry in view["files"]: + path = entry.get("path") or "" + if path.startswith("bios/"): + self.assertEqual(entry["standalone_path"], "ps2/" + path.removeprefix("bios/")) + elif path: + self.assertEqual(entry.get("unplaced"), "outside_bios_folder", path) + + +class Quasi88OnRetroPie(unittest.TestCase): + """RetroPie writes -romdir $biosdir/pc88 into quasi88.ini (quasi88.sh:67).""" + + def test_the_roms_land_in_pc88(self) -> None: + profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["quasi88kai"] + config = load_platform_config("retropie", str(REPO_ROOT / "platforms")) + (view, standalone), = platform_layouts("quasi88kai", profile, standalone_set(config)) + self.assertTrue(standalone) + paths = [e["path"] for e in view["files"]] + self.assertTrue(paths and all(p.startswith("rom/") for p in paths)) + self.assertEqual( + [e["standalone_path"] for e in view["files"]], + ["pc88/" + p.removeprefix("rom/") for p in paths], + ) + + if __name__ == "__main__": unittest.main()