From 62278e1d4e060b6c8d1eb3eb80cedf491278588d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:46:03 +0100 Subject: [PATCH 1/3] feat: name the BIOS folder key of two PS2 forks AetherSX2 and ARMSX2 resolve their BIOS folder from PCSX2.ini [Folders] Bios against the data root, bios by default (aethersx2/pcsx2/Pcsx2Config.cpp:935-946, pcsx2/Pcsx2Config.cpp:2617-2627 in ARMSX2). standalone_directories now names that key, so a launcher that writes it places the layout's bios/ folder: KNULLI sets bios/ps2, ROCKNIX bios/armsx2. --- emulators/aethersx2.yml | 5 ++++- emulators/armsx2.yml | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) 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: From ddc947c85d15666f754bb09440942b5ea9152c94 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:46:57 +0100 Subject: [PATCH 2/3] fix: place files in a folder a launcher sets KNULLI sets AetherSX2's [Folders] Bios to bios/ps2 (aethersx2Generator.py:219), yet its 112 PS2 BIOS files went to the BIOS root. Three causes: - a record's passes_to was split into words, so "[Folders] Bios" could not be told from "[Filenames] BIOS". ini_keys() adds the section-qualified key a profile names in standalone_directories. - scans_dir records hand a folder too (ROCKNIX's bios/armsx2, EmuDeck's PCSX2 and ARMSX2) and were not read as handed. - the agnostic scan looked its seeds up by the id of the profile's entries, while it walks the copies platform_layouts makes: any bound layout scanned nothing. Copies map back to the profile's entries by position. An agnostic profile's entry the layout reads outside the BIOS tree is now named by the gap report (outside_bios_folder) instead of dropped: ARMSX2's arcade/proverb.zip on ROCKNIX and EmuDeck. --- scripts/launchers.py | 21 ++++- scripts/packextras.py | 19 +++- scripts/verify.py | 10 +- tests/test_launcher_placement.py | 152 +++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 10 deletions(-) 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 df9d22557..3b3ad8354 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..107767814 100644 --- a/tests/test_launcher_placement.py +++ b/tests/test_launcher_placement.py @@ -553,5 +553,157 @@ 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) + + if __name__ == "__main__": unittest.main() From adac87ec32429389715926022530007f4775aaac Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:50:38 +0100 Subject: [PATCH 3/3] feat: name the ROM folder option of QUASI88kai -romdir replaces the ROM directory (src/getconf.cpp:282-290, 529), and RetroPie writes -romdir $biosdir/pc88 into quasi88.ini (scriptmodules/emulators/quasi88.sh:67). Its 19 ROMs move from rom/ to pc88/ in the RetroPie pack, manifest and verify. --- emulators/quasi88kai.yml | 2 ++ tests/test_launcher_placement.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) 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/tests/test_launcher_placement.py b/tests/test_launcher_placement.py index 107767814..95170a93a 100644 --- a/tests/test_launcher_placement.py +++ b/tests/test_launcher_placement.py @@ -705,5 +705,21 @@ class AetherSX2OnKnulli(unittest.TestCase): 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()