From cd38b9ed107470f87f5192d4421665c1bbd1026e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:30:06 +0100 Subject: [PATCH] feat: bind standalone layouts per system and pool A launcher record can name the systems it is written for, and then replaces there what the records naming none hand: RetroBat passes XRoar -rompath bios/coco for coco and bios/dragon otherwise. An entry is launched for the systems it names and those the platform declares a file of its name under. A {system} search_dir gives one directory per platform system, a colon search path each of its directories. standalone_pools names settings a program reads as a list of directories, each searched like a layout subfolder (openMSX filepool, XRoar rompath). Any entry serves: the one the platform list fills comes first, then the first of the BIOS tree. A handle above a declared directory lands it where its link points (configure --prefix). Record order now survives the standalone set. --- schemas/emulator.schema.json | 7 + schemas/platform.schema.json | 4 +- scripts/common.py | 98 +++++++++++-- scripts/launchers.py | 257 +++++++++++++++++++++++++-------- tests/test_standalone_links.py | 130 +++++++++++++++++ 5 files changed, 422 insertions(+), 74 deletions(-) diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index 4165c814c..b05d2d74f 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -1035,6 +1035,13 @@ }, "description": "What the standalone build resolves standalone_path against, as its code names it: a directory ($XDG_DATA_HOME/flycast), a setting (Dreamcast.BiosPath), an environment variable or the function returning the directory. A platform launcher record that links or passes a folder of its BIOS tree under one of these puts the standalone files in that folder." }, + "standalone_pools": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Settings the standalone build reads as a list of directories it searches one after the other, each mapped to the subfolder of the layout (relative to standalone_directories, empty for the whole of it) its directories stand for: openMSX's filepool entries are searched like share/systemroms, XRoar's rompath entries hold the ROMs themselves. A file in any entry is read, so several platform records naming entries give the first one a pack can fill." + }, "prose_written_at": { "type": "string", "pattern": "^[0-9a-f]{40}$", diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index 0d409de16..16e061f17 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -241,7 +241,7 @@ "items": { "type": "string" }, - "description": "Systems the package registers, under the platform's own names, in the order the script adds them" + "description": "Systems the package registers, under the platform's own names, in the order the script adds them. On a record handing a directory, the systems it is written for: the record applies to an emulator launched for one of them, and replaces there what the records naming no system hand" }, "slot": { "type": "string", @@ -270,7 +270,7 @@ }, "search_dir": { "type": "string", - "description": "Directory the launcher looks in, as the code resolves it" + "description": "Directory the launcher looks in, as the code resolves it. {system} stands for the platform system the emulator is launched for" }, "passes_to": { "type": "string", diff --git a/scripts/common.py b/scripts/common.py index 25923b849..05a3ffc73 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -6,7 +6,7 @@ and file resolution - eliminates DRY violations across scripts. from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Mapping from collections import Counter import argparse @@ -2685,6 +2685,10 @@ CORE_DIR_PREFIX = "coredir:" # Marks, in the same set, a file the platform's launcher executes # (launchers.executed_files), as JSON. EXECUTED_PREFIX = "exec:" +# Marks, in the same set, a file name the platform declares under one of the +# systems a launcher record is written for, as JSON [system, name]: it says +# which launch reads a profile entry of that name. +SYSTEM_FILE_PREFIX = "sysfile:" def core_system_dirs( @@ -2752,12 +2756,22 @@ def standalone_set(config: dict) -> set[str]: if names - {n for n in names if n.startswith(CORE_DIR_PREFIX)}: from launchers import executed_files, handed_directories # noqa: PLC0415 + handed = handed_directories(config) + # The record's position travels too: a search list is read in order. names |= { HANDED_PREFIX + json.dumps( - [h.emulator, h.folder, h.directory, sorted(h.handles), h.source_ref, - h.roms_folder] + [position, h.emulator, h.folder, h.directory, sorted(h.handles), h.source_ref, + h.roms_folder, sorted(h.systems), h.holds_declared] ) - for h in handed_directories(config) + for position, h in enumerate(handed) + } + named = {system for h in handed for system in h.systems} + names |= { + SYSTEM_FILE_PREFIX + json.dumps([slug, str(f["name"]).lower()]) + for slug, system in (config.get("systems") or {}).items() + if slug in named + for f in system.get("files") or [] + if f.get("name") } names |= { EXECUTED_PREFIX + json.dumps([run.emulator, run.name, run.place, run.source_ref]) @@ -2790,9 +2804,39 @@ def _handed(standalone_cores: Iterable[str]) -> list: for tag in standalone_cores: if not tag.startswith(HANDED_PREFIX): continue - emulator, folder, directory, handles, ref, roms = json.loads(tag[len(HANDED_PREFIX):]) - found.append(HandedDirectory(emulator, folder, directory, frozenset(handles), ref, roms)) - return found + position, emulator, folder, directory, handles, ref, roms, systems, held = json.loads( + tag[len(HANDED_PREFIX):] + ) + found.append(((position, tag), HandedDirectory( + emulator, folder, directory, frozenset(handles), ref, roms, frozenset(systems), held + ))) + return [h for _key, h in sorted(found, key=lambda pair: pair[0])] + + +def system_file_index(standalone_cores: Iterable[str]) -> dict[str, set[str]]: + """File name, lower case -> the platform systems declaring it (SYSTEM_FILE_PREFIX).""" + index: dict[str, set[str]] = {} + for tag in standalone_cores: + if tag.startswith(SYSTEM_FILE_PREFIX): + slug, name = json.loads(tag[len(SYSTEM_FILE_PREFIX):]) + index.setdefault(name, set()).add(slug) + return index + + +def entry_launch_systems(entry: dict, index: Mapping[str, set[str]]) -> frozenset[str]: + """The platform systems, by our ids, that launch the emulator for an entry. + + The systems the entry names, and those under which the platform declares + a file of one of its names (``index``, system_file_index): Recalbox lists + XRoar's coco3.rom under trs80coco, which our coco is. + """ + systems = {str(s) for s in entry.get("systems") or []} + if entry.get("system"): + systems.add(str(entry["system"])) + for name in [entry.get("name"), *(entry.get("aliases") or [])]: + if name: + systems |= index.get(str(name).lower(), set()) + return frozenset(systems) def _executed(standalone_cores: Iterable[str]) -> list: @@ -2853,11 +2897,16 @@ def standalone_destination( def standalone_bindings_for( - emu_name: str, profile: dict, standalone_cores: Iterable[str] + emu_name: str, profile: dict, standalone_cores: Iterable[str], + systems: Iterable[str] = (), ) -> dict[str, tuple[str, str]]: - """The places a platform gives this emulator's standalone layout, by subfolder.""" + """The places a platform gives this emulator's standalone layout, by subfolder. + + ``systems`` are those the entry is launched for (entry_launch_systems). + """ declared = profile.get("standalone_directories") or [] - if not declared: + pools = profile.get("standalone_pools") or {} + if not declared and not pools: return {} from launchers import standalone_bindings # noqa: PLC0415 @@ -2867,7 +2916,8 @@ def standalone_bindings_for( if isinstance(f, dict) and (path := standalone_layout_path(f, profile)) ] return standalone_bindings( - names, declared, _handed(standalone_cores), _executed(standalone_cores), layout + names, declared, _handed(standalone_cores), _executed(standalone_cores), layout, + pools, systems, ) @@ -2887,8 +2937,28 @@ def platform_layouts( return [(profile, False)] return [(system_dir_view(profile, d), False) for d in directories] standalone = profile - bindings = standalone_bindings_for(emu_name, profile, standalone_cores) - if bindings: + # Bindings can depend on the system an entry is launched for, so each + # set of launch systems gets its own. + by_systems: dict[frozenset[str], dict[str, tuple[str, str]]] = {} + names = {emu_name, *(str(c) for c in profile.get("cores") or [])} + per_system = bool( + profile.get("standalone_directories") or profile.get("standalone_pools") + ) and any(h.systems and h.emulator in names for h in _handed(standalone_cores)) + index = system_file_index(standalone_cores) if per_system else {} + + def bindings_of(entry: dict) -> dict[str, tuple[str, str]]: + systems = entry_launch_systems(entry, index) if per_system else frozenset() + if systems not in by_systems: + by_systems[systems] = standalone_bindings_for( + emu_name, profile, standalone_cores, systems + ) + return by_systems[systems] + + layout_entries = [ + f for f in profile.get("files") or [] + if isinstance(f, dict) and standalone_layout_path(f, profile) is not None + ] + if any(bindings_of(f) for f in layout_entries): # The standalone view carries the platform's destination, so every # reader of standalone_path (builder, manifest, verify, slots) places # the file in the folder the platform hands the emulator. @@ -2901,7 +2971,7 @@ def platform_layouts( if not isinstance(f, dict) or standalone_layout_path(f, profile) is None: files.append(f) continue - placed = standalone_destination(f, profile, bindings) + placed = standalone_destination(f, profile, bindings_of(f)) if placed is None or placed[0] == "outside": files.append({**f, "unplaced": OUTSIDE_BIOS_FOLDER}) continue diff --git a/scripts/launchers.py b/scripts/launchers.py index 75a25186d..c19de9aea 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -17,7 +17,7 @@ under the launcher's name, a file the core accepts by content. from __future__ import annotations import re -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass from common import declared_root, name_match_size_ok, resolve_local_file, sanitize_pack_path @@ -383,6 +383,11 @@ class HandedDirectory: handles: frozenset[str] source_ref: str roms_folder: str | None = None + # The platform systems, by our ids, the record applies to. Empty when + # the launcher hands the directory whatever the system. + systems: frozenset[str] = frozenset() + # Whether the platform's own list declares files in the folder. + holds_declared: bool = False def place(self) -> Place | None: """The folder as a place, None when it lies outside both roots.""" @@ -393,14 +398,55 @@ class HandedDirectory: return None +# The placeholder a launcher record writes for the system it is launched +# for: Recalbox passes -rompath os.path.join(BIOS, args.system) to XRoar. +SYSTEM_PLACEHOLDER = "{system}" + + +def native_systems(config: dict) -> dict[str, str]: + """The platform's own system names, each to our id for it. + + A system states its name (native_id) or its files do (native_system). + Without either, our id is the platform's name. + """ + found: dict[str, str] = {} + for slug, system in (config.get("systems") or {}).items(): + names = {str(system["native_id"])} if system.get("native_id") else { + str(f["native_system"]) for f in system.get("files") or [] if f.get("native_system") + } + for name in names or {slug}: + found.setdefault(name, slug) + return found + + +def _listed_directories(raw: str) -> list[str] | None: + """The directories of a colon-separated search path, None for one directory. + + XRoar's rompath is such a list (RetroDECK writes four directories), each + entry a path or a variable spelling one. A Windows drive letter or a + backslash means a single path. + """ + if ":" not in raw or "\\" in raw or re.match(r"^[A-Za-z]:", raw): + return None + parts = [p.strip() for p in raw.split(":")] + if all(p and p[0] in "/$~" and not re.search(r"[\s,;]", p) for p in parts): + return parts + return None + + def handed_directories(config: dict) -> list[HandedDirectory]: """Every whole directory a platform links or passes to a standalone build. A record naming files (gpsp's gba_bios.bin) or one slot of a directory (RetroDECK's GC/EUR) hands over no whole directory, and a search_dir - holding several directories names no single one. + describing several directories in prose names no single one. A search + path (XRoar's rompath) gives each of its directories, in order. A record + naming systems applies to those systems only, and one whose search_dir + holds {system} gives one directory per system the platform declares. """ base = str(config.get("base_destination") or "bios") + natives = native_systems(config) + declared = {d.lower() for d in declared_destinations(config)} found: list[HandedDirectory] = [] for record in config.get("launchers") or []: if record.get("build") != "standalone" or record.get("kind") not in HANDED_KINDS: @@ -408,30 +454,48 @@ def handed_directories(config: dict) -> list[HandedDirectory]: if record.get("names") or record.get("slot"): continue raw = str(record.get("search_dir") or "").strip() - if not raw or re.search(r"[\s,:;]", raw): + if not raw: continue - directory = normalize_handle(raw) - passes_to = str(record.get("passes_to") or "") - if passes_to.startswith(WORKING_DIRECTORY_PASSED): - # The program runs from the directory: what its code reads - # through getcwd(), or relative to it, is below it. - handles = frozenset({WORKING_DIRECTORY}) + named = frozenset(natives.get(str(s), str(s)) for s in record.get("systems") or []) + if SYSTEM_PLACEHOLDER in raw: + variants = [ + (raw.replace(SYSTEM_PLACEHOLDER, native), frozenset({slug})) + for native, slug in sorted(natives.items()) + if not named or slug in named + ] else: - handles = frozenset( - handle - for token in passes_to.split() - if (handle := normalize_handle(token)) and handle != directory - ) - folder = _folder_in_bios(raw, base) - for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")): - found.append(HandedDirectory( - emulator=emulator, - folder=folder, - directory=directory, - handles=handles, - source_ref=str(record.get("source_ref", "")), - roms_folder=None if folder is not None else folder_in_roms(raw, config), - )) + variants = [(raw, named)] + passes_to = str(record.get("passes_to") or "") + for spelled, systems in variants: + parts = _listed_directories(spelled) or [spelled] + if any(re.search(r"[\s,:;]", part) for part in parts): + continue + for part in parts: + directory = normalize_handle(part) + if passes_to.startswith(WORKING_DIRECTORY_PASSED): + # The program runs from the directory: what its code + # reads through getcwd(), or relative to it, is below it. + handles = frozenset({WORKING_DIRECTORY}) + else: + handles = frozenset( + handle + for token in passes_to.split() + if (handle := normalize_handle(token)) and handle != directory + ) + folder = _folder_in_bios(part, base) + for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")): + found.append(HandedDirectory( + emulator=emulator, + folder=folder, + directory=directory, + handles=handles, + source_ref=str(record.get("source_ref", "")), + roms_folder=None if folder is not None else folder_in_roms(part, config), + systems=systems, + holds_declared=bool(folder) and any( + d.startswith(f"{folder.lower()}/") for d in declared + ), + )) return found @@ -515,17 +579,113 @@ def executed_files(config: dict) -> list[ExecutedFile]: return found +def _below(handle: str, directory: str) -> str | None: + """``handle`` relative to ``directory``, None when it is not inside it.""" + if handle == directory: + return "" + if handle.startswith(directory + "/"): + return handle[len(directory) + 1:] + return None + + +def _join(*parts: str) -> str: + return "/".join(p for p in parts if p) + + +def _record_places( + record: HandedDirectory, inner: str, records: list[HandedDirectory] +) -> list[tuple[str, Place]]: + """(subfolder, place) for the directory ``inner`` below what a record hands. + + A record handing a folder of a tree gives it directly. One handing a + directory outside the trees is followed through the links that put a + folder at that directory, below it or above it. + """ + if record.folder is not None: + return [("", (BIOS_TREE, _join(record.folder, inner)))] + base = _join(record.directory, inner) + found: list[tuple[str, Place]] = [] + for link in records: + target = link.place() + if target is None: + continue + for handle in link.handles: + rest = _below(handle, base) + if rest is not None: + found.append((rest, target)) + continue + up = _below(base, handle) if inner else None + if up: + found.append(("", (target[0], _join(target[1], up)))) + if not found and record.roms_folder is not None: + found.append(("", (ROMS_TREE, _join(record.roms_folder, inner)))) + return found + + +def _bind( + records: list[HandedDirectory], + links: list[HandedDirectory], + wanted: set[str], + pools: Mapping[str, str], +) -> dict[str, set[Place]]: + """The places each subfolder of the layout is given by ``records``. + + A record whose handle is a declared directory, or lies below one, gives + that subfolder. One whose handle lies above a declared directory gives + the declared directory where it lands: RetroPie configures XRoar with + --prefix=$md_inst, so ${prefix}/share/xroar/roms is $md_inst/share/xroar/roms. + ``links`` are the records followed from a directory outside the trees. + + A handle in ``pools`` names a list of directories the program searches + one after the other: openMSX's filepool, XRoar's rompath. A file in any + of them is read, so the pool gives one: the directory the platform's own + list already fills (Batocera's bios/openmsx beside bios/Machines), else + the first of the BIOS tree, else the first a pack can fill. + """ + found: dict[str, set[Place]] = {} + pooled: dict[str, list[tuple[bool, Place]]] = {} + for record in records: + for handle in record.handles: + if handle in pools: + for rest, place in _record_places(record, "", links): + pooled.setdefault(_join(pools[handle], rest), []).append( + (record.holds_declared, place) + ) + continue + for directory in wanted: + sub = _below(handle, directory) + inner = "" + if sub is None: + up = _below(directory, handle) + if not up: + continue + sub, inner = "", up + for rest, place in _record_places(record, inner, links): + found.setdefault(_join(sub, rest), set()).add(place) + for sub, entries in pooled.items(): + # The directory the platform's own list fills, else the first of the + # BIOS tree, else the first a pack fills. + filled = [p for held, p in entries if held] + in_bios = [p for _held, p in entries if p[0] == BIOS_TREE] + found.setdefault(sub, set()).add((filled or in_bios or [p for _h, p in entries])[0]) + return found + + def standalone_bindings( names: Iterable[str], declared: Iterable[str], handed: Iterable[HandedDirectory], executed: Iterable[ExecutedFile] = (), layout: Iterable[str] = (), + pools: Mapping[str, str] | None = None, + systems: Iterable[str] = (), ) -> dict[str, Place]: """The folders a platform gives a standalone build's layout. ``declared`` are the directories the build resolves its paths against, - as its profile names them (``standalone_directories``). The result maps + as its profile names them (``standalone_directories``), and ``pools`` + the settings it reads as search lists, each to the subfolder of the + layout its directories stand for (``standalone_pools``). The result maps a subfolder of that layout ("" for the whole of it) to the place, a folder of the BIOS tree or of the roms directory, the emulator reads it from. @@ -538,6 +698,11 @@ def standalone_bindings( and RetroPie starts UAE4All2 in $md_inst, whose kickstarts folder it links to bios/amiga. + ``systems`` are the platform systems, by our ids, an entry is launched + for. A record naming systems replaces, for those systems, what the + records naming none give: RetroBat passes XRoar -rompath bios/coco for + coco and bios/dragon for every other system. + A profile whose program reads its files beside its executable declares EXECUTABLE_DIRECTORY, and ``layout`` holds its paths: a launcher that executes one of them gives the folder holding it. Batocera runs @@ -547,42 +712,18 @@ def standalone_bindings( A subfolder two records give two places is left out. """ names = set(names) - wanted = {normalize_handle(d) for d in declared} - if not wanted: + pools = {normalize_handle(k): str(v).strip("/") for k, v in (pools or {}).items()} + wanted = {normalize_handle(d) for d in declared} - set(pools) + if not wanted and not pools: return {} + systems = set(systems) records = [h for h in handed if h.emulator in names] + generic = [h for h in records if not h.systems] + specific = [h for h in records if h.systems & systems] - def below(handle: str, directory: str) -> str | None: - if handle == directory: - return "" - if handle.startswith(directory + "/"): - return handle[len(directory) + 1:] - return None - - found: dict[str, set[Place]] = {} - for record in records: - subs = { - sub for handle in record.handles for directory in wanted - if (sub := below(handle, directory)) is not None - } - for sub in subs: - if record.folder is not None: - found.setdefault(sub, set()).add((BIOS_TREE, record.folder)) - continue - # A link into the BIOS tree comes first: the pack fills that. - linked = False - for link in records: - target = link.place() - if target is None: - continue - for handle in link.handles: - rest = below(handle, record.directory) - if rest is not None: - key = "/".join(p for p in (sub, rest) if p) - found.setdefault(key, set()).add(target) - linked = True - if not linked and record.roms_folder is not None: - found.setdefault(sub, set()).add((ROMS_TREE, record.roms_folder)) + found = _bind(generic, generic, wanted, pools) + # A link is followed whatever system it was written for. + found.update(_bind(specific, specific + generic, wanted, pools)) if EXECUTABLE_DIRECTORY in wanted: paths = list(layout) for run in executed: diff --git a/tests/test_standalone_links.py b/tests/test_standalone_links.py index 979c2ff83..ad83765cd 100644 --- a/tests/test_standalone_links.py +++ b/tests/test_standalone_links.py @@ -23,12 +23,14 @@ REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "scripts")) from common import ( # noqa: E402 + entry_launch_systems, load_emulator_profiles, load_platform_config, platform_layouts, standalone_bindings_for, standalone_destination, standalone_set, + system_file_index, ) from launchers import ( # noqa: E402 bound_path, @@ -156,6 +158,133 @@ class Root(unittest.TestCase): )) +def bind(cfg: dict, declared: list[str], pools: dict[str, str] | None = None, + systems: tuple[str, ...] = ()) -> dict[str, tuple[str, str]]: + return standalone_bindings( + {"emu"}, declared, handed_directories(cfg), pools=pools, systems=systems + ) + + +class SearchLists(unittest.TestCase): + """A setting read as a list of directories, every one searched.""" + + POOLS = {"filepool": "share/systemroms"} + + def filepool(self, folder: str, position: int) -> dict: + return passes( + "emu", f"/userdata/bios/{folder}", + f"written: filepool add -path /userdata/bios/{folder} -types system_rom -position {position}", + ) + + def test_two_pool_entries_give_the_first_one(self) -> None: + # Batocera adds bios/Machines and bios/openmsx to openMSX's filepool. + cfg = config(self.filepool("Machines", 1), self.filepool("openmsx", 2)) + self.assertEqual( + bind(cfg, ["~/.openMSX"], self.POOLS), {"share/systemroms": ("", "Machines")} + ) + + def test_the_entry_the_platform_list_fills_comes_first(self) -> None: + cfg = config(self.filepool("Machines", 1), self.filepool("openmsx", 2)) + cfg["systems"] = {"msx": {"files": [{"name": "a.rom", "destination": "openmsx/a.rom"}]}} + self.assertEqual( + bind(cfg, ["~/.openMSX"], self.POOLS), {"share/systemroms": ("", "openmsx")} + ) + + def test_without_the_pool_two_folders_give_nothing(self) -> None: + cfg = config(self.filepool("Machines", 1), self.filepool("openmsx", 2)) + self.assertEqual(bind(cfg, ["~/.openMSX", "filepool"]), {}) + + def test_a_colon_list_gives_each_directory_in_order(self) -> None: + # RetroDECK writes XRoar's rompath as four directories. + found = handed_directories(config(passes( + "emu", "$roms_path/coco:$roms_path/dragon32:$bios_path", "xroar.conf rompath", + ))) + self.assertEqual( + [h.directory for h in found], ["$roms_path/coco", "$roms_path/dragon32", "$bios_path"] + ) + self.assertEqual( + bind(config(passes("emu", "$roms_path/coco:$bios_path/xroar", "xroar.conf rompath")), + [], {"rompath": ""}), + {"": ("", "xroar")}, + ) + + def test_a_drive_letter_is_one_directory(self) -> None: + self.assertEqual(handed_directories(config(passes("emu", "C:/bios", "rompath"))), []) + + +class AboveTheDeclaredDirectory(unittest.TestCase): + def test_a_prefix_lands_the_declared_directory_where_its_link_is(self) -> None: + # RetroPie builds XRoar with --prefix=$md_inst and links + # $md_inst/share/xroar/roms to $biosdir. + cfg = config( + passes("emu", "$md_inst", "${prefix} of configure --prefix"), + link("emu", "$biosdir", "$md_inst/share/xroar/roms is a symlink to it"), + base="BIOS", + ) + self.assertEqual(bind(cfg, ["${prefix}/share/xroar/roms"]), {"": ("", "")}) + + def test_a_folder_of_the_tree_takes_the_rest_of_the_path(self) -> None: + cfg = config(passes("emu", "$biosdir/emu", "EMU_HOME"), base="BIOS") + self.assertEqual(bind(cfg, ["EMU_HOME/share/roms"]), {"": ("", "emu/share/roms")}) + + +class PerSystem(unittest.TestCase): + """A record written for some systems replaces, there, the others.""" + + CFG = { + **config( + passes("emu", "bios/dragon", "command line -rompath"), + {**passes("emu", "bios/coco", "command line -rompath"), "systems": ["coco"]}, + ), + "systems": {"coco": {"files": [{"name": "x.zip", "native_system": "coco"}]}}, + } + POOLS = {"-rompath": ""} + + def test_the_system_record_wins_for_its_system(self) -> None: + self.assertEqual(bind(self.CFG, [], self.POOLS, ("coco",)), {"": ("", "coco")}) + + def test_another_system_keeps_the_generic_record(self) -> None: + self.assertEqual(bind(self.CFG, [], self.POOLS, ("dragon32",)), {"": ("", "dragon")}) + self.assertEqual(bind(self.CFG, [], self.POOLS), {"": ("", "dragon")}) + + def test_native_names_are_read_as_our_ids(self) -> None: + cfg = { + **config({**passes("emu", "bios/trs80coco", "-rompath"), "systems": ["trs80coco"]}), + "systems": {"coco": {"native_id": "trs80coco", "files": []}}, + } + self.assertEqual( + [sorted(h.systems) for h in handed_directories(cfg)], [["coco"]] + ) + + def test_a_system_placeholder_gives_one_directory_per_system(self) -> None: + # Recalbox passes -rompath os.path.join(BIOS, args.system). + cfg = { + **config(passes("emu", "/recalbox/share/bios/{system}", "command line -rompath")), + "systems": { + "coco": {"native_id": "trs80coco", "files": []}, + "dragon64": {"native_id": "dragon", "files": []}, + }, + } + self.assertEqual( + [(h.folder, sorted(h.systems)) for h in handed_directories(cfg)], + [("dragon", ["dragon64"]), ("trs80coco", ["coco"])], + ) + self.assertEqual(bind(cfg, [], self.POOLS | {"-rompath": ""}, ("coco",)), + {"": ("", "trs80coco")}) + self.assertEqual(bind(cfg, [], {"-rompath": ""}, ("dragon32",)), {}) + + def test_an_entry_is_launched_for_the_system_that_declares_its_name(self) -> None: + cfg = { + **config({**passes("emu", "bios/coco", "-rompath"), "systems": ["coco"]}), + "systems": {"coco": {"files": [{"name": "coco3.rom", "native_system": "coco"}]}}, + } + index = system_file_index(standalone_set(cfg)) + self.assertEqual( + entry_launch_systems({"name": "COCO3.ROM", "system": "coco3"}, index), + frozenset({"coco3", "coco"}), + ) + + class Layout(unittest.TestCase): LINKED = link("emu", "$biosdir/dc", "$home/.local/share/emu is a symlink to it") @@ -367,3 +496,4 @@ class Scrapers(unittest.TestCase): if __name__ == "__main__": unittest.main() +