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.
This commit is contained in:
Abdessamad Derraz committed 2026-11-10 11:46:57 +01:00
1 parent 62278e1d4e
commit ddc947c85d
4 files changed
+192 -10

No files matched your search

+18 -3
View File
@@ -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(
+15 -4
View File
@@ -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 (<bios>.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
+7 -3
View File
@@ -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.
+152
View File
@@ -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()