From b2b8dcd62c160670aad7db4bc5dc1eb4a6474107 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:17:38 +0200 Subject: [PATCH] feat: read platform launchers in verify and packs A names_searched list is one place the launcher fills with the first name it finds, so a core file it would never open once another name of the list is held is not reported missing. A place no declared file and no packed core file fills receives, under the launcher's name, a file the core accepts by content. The layer is applied inside find_undeclared_files, which verify and the pack builder both read, and a test reads their source to keep it there. --- .github/workflows/deploy-site.yml | 1 + scripts/launchers.py | 473 ++++++++++++++++++++++++++++++ scripts/verify.py | 27 ++ tests/test_launcher_layer.py | 233 +++++++++++++++ wiki/adding-a-platform.md | 17 ++ wiki/testing-guide.md | 2 +- 6 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 scripts/launchers.py create mode 100644 tests/test_launcher_layer.py diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index e7685191..59c6bc9a 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -22,6 +22,7 @@ on: - "scripts/generate_site.py" - "scripts/hashing.py" - "scripts/largefiles.py" + - "scripts/launchers.py" - "scripts/mkdocs_hooks.py" - "scripts/nativemode.py" - "scripts/packextras.py" diff --git a/scripts/launchers.py b/scripts/launchers.py new file mode 100644 index 00000000..7c29c059 --- /dev/null +++ b/scripts/launchers.py @@ -0,0 +1,473 @@ +"""The platform's launcher layer, read by verify and by the pack builder. + +A platform's own launch code often decides which name a file is read under. +Batocera's DuckStation generator tries 22 names per region and keeps the +first it finds, RetroBat's ares launcher writes the LaserActive image it +finds into ares's settings. The platform scraper records that code under +``launchers:`` and this module turns the records into places: a set of +destinations, relative to the directory core files go to, any one of which +the launcher reads for one purpose. + +Two consequences follow, both computed here once for every reader. A core +file the launcher would not read because another name of the same place is +held is not missing on that platform. A place nothing fills yet receives, +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 dataclasses import dataclass + +from common import declared_root, name_match_size_ok, sanitize_pack_path + +# The kinds whose names say which file the launcher opens. The others hand +# over a directory, copy a file the platform already names, or let the +# emulator choose. +PLACE_KINDS = ("names_searched", "fixed_name", "core_option") + +_LABELLED = re.compile(r"^([^\s()]+\.[A-Za-z0-9]+) \([^()]*\)$") +_PLACEHOLDER = re.compile(r"\{(\w+)\}") +_HASH_FIELDS = ("sha1", "md5", "crc32", "sha256") + + +@dataclass(frozen=True) +class LauncherPlace: + """One file a launcher opens, under any of ``paths``. + + ``paths`` are in the order the launcher tries them, relative to the + directory core files go to. An unanchored place has no directory of its + own: the core resolves the name the launcher passes in the directory it + reads the entry from. ``alternatives`` is true when the launcher keeps + the first name it finds, false when each path is the one it passes. + """ + + emulator: str + build: str + kind: str + slot: str + paths: tuple[str, ...] + case_insensitive: bool + anchored: bool + conditional: bool + source_ref: str + + def fold(self, name: str) -> str: + return name.lower() if self.case_insensitive else name + + def basenames(self) -> set[str]: + return {self.fold(p.rsplit("/", 1)[-1]) for p in self.paths} + + @property + def stands_for_default(self) -> bool: + """Whether filling the place means the core's default is never read. + + A launcher that always passes the file it found replaces the name + the core would open by itself. A record that applies only under an + option or a system leaves that default in use elsewhere, and a core + option offers a file the user may choose without ever choosing it. + """ + return self.kind in ("names_searched", "fixed_name") and not self.conditional + + +def _bios_relative(directory: str, base: str) -> str | None: + """A launcher directory relative to the BIOS root, None when it is not one.""" + directory = directory.strip().rstrip("/") + roots = {base, ""} + head, _, rest = directory.partition("/") + if directory in roots: + return "" + if head in roots: + relative = rest + elif directory.startswith("/") and base and f"/{base}/" in f"{directory}/": + relative = f"{directory}/".split(f"/{base}/", 1)[1].rstrip("/") + else: + return None + return None if re.search(r"[\s<>]", relative) else relative + + +def _directories(record: dict, base: str) -> list[str] | None: + """The directories a record reads, in order. None when the code says none. + + ``search_dir`` transcribes the code, conditions included: "bios/chihiro, + then bios", "bios/pcsx2/bios if any listed name is there, else bios". + A directory the record describes rather than names ("directory of the + Chihiro BIOS found") cannot be placed. + """ + raw = record.get("search_dir") + if raw is None: + return None + text = re.sub(r"\([^()]*\)", "", str(raw)) + text = re.sub(r"\s+if\s+[^,]*", "", text) + found: list[str] = [] + for part in re.split(r",|\bthen\b|\belse\b", text): + if not part.strip(): + continue + relative = _bios_relative(part, base) + if relative is None: + return [] + if relative not in found: + found.append(relative) + return found + + +def _names(record: dict) -> list[str]: + """The file names a record tries, placeholders expanded in code order.""" + expands = record.get("expands") or {} + names: list[str] = [] + for raw in record.get("names") or []: + name = str(raw).strip() + labelled = _LABELLED.match(name) + if labelled: + name = labelled.group(1) + expanded = [name] + for key in _PLACEHOLDER.findall(name): + values = expands.get(key) + if values is None: + expanded = [] + break + expanded = [ + candidate.replace("{" + key + "}", str(value)) + for candidate in expanded + for value in values + ] + for candidate in expanded: + if "<" in candidate or "." not in candidate.rsplit("/", 1)[-1]: + continue + if candidate not in names: + names.append(candidate) + return names + + +def launcher_places(config: dict) -> list[LauncherPlace]: + """Every place a platform's launchers open a named file from.""" + base = str(config.get("base_destination") or "bios") + places: list[LauncherPlace] = [] + for record in config.get("launchers") or []: + kind = record.get("kind") + if kind not in PLACE_KINDS: + continue + names = _names(record) + directories = _directories(record, base) + if not names or directories == []: + continue + anchored = directories is not None + + def paths_of(name: str) -> tuple[str, ...]: + if not anchored: + return (name,) + return tuple( + sanitize_pack_path(f"{d}/{name}" if d else name) for d in directories + ) + + common = { + "emulator": str(record.get("emulator", "")), + "build": str(record.get("build", "")), + "kind": kind, + "slot": str(record.get("slot") or ""), + # A filesystem that folds case folds it for the launcher too. + "case_insensitive": bool( + record.get("case_insensitive") or config.get("case_insensitive_fs") + ), + "anchored": anchored, + "conditional": bool(record.get("depends_on_option")), + "source_ref": str(record.get("source_ref", "")), + } + if kind == "names_searched": + # Each directory in turn, every name in it. + depth = len(directories) if anchored else 1 + ordered = tuple(paths_of(name)[i] for i in range(depth) for name in names) + places.append(LauncherPlace(paths=ordered, **common)) + continue + # A fixed name is the one file passed, and a core option offers + # each value as the file the user picks: one place per name. + places.extend(LauncherPlace(paths=paths_of(name), **common) for name in names) + return places + + +def declared_destinations(config: dict) -> set[str]: + """Each destination the platform declares, relative to the core file directory. + + Case is kept: a launcher that matches names exactly is not served by a + declaration of another case. + """ + prefix = declared_root(config) + found: set[str] = set() + for system in config.get("systems", {}).values(): + for entry in system.get("files", []): + slot = sanitize_pack_path(entry.get("destination") or entry.get("name", "")) + if prefix and slot.lower().startswith(prefix.lower() + "/"): + slot = slot[len(prefix) + 1:] + if slot: + found.add(slot) + return found + + +def _hashes(entry: dict) -> dict[str, set[str]]: + found: dict[str, set[str]] = {} + for field in _HASH_FIELDS: + value = entry.get(field) or (entry.get("expected") or {}).get(field) + if not value: + continue + values = value if isinstance(value, list) else str(value).split(",") + found[field] = {str(v).strip().lower() for v in values if str(v).strip()} + return found + + +def accepts(entry: dict, record: dict) -> bool: + """Whether a core entry takes the database file ``record`` by its content.""" + hashes = _hashes(entry) + if not hashes: + return False + return any( + str(record.get(field, "")).lower() in values + for field, values in hashes.items() + ) + + +@dataclass +class _Candidate: + """A core entry one launcher place stands for, and how it was matched.""" + + entry: dict + names: set[str] + by_content: bool + + +class LauncherLayer: + """The launcher places of one platform matched to the cores it runs. + + ``declared`` holds each destination the platform declares, relative to + the directory core files go to, case kept. + """ + + def __init__( + self, + config: dict, + profiles: dict, + relevant: Iterable[str], + db: dict, + declared: Iterable[str], + ) -> None: + self.places = launcher_places(config) + self.declared = set(declared) + self.declared_folded = {d.lower() for d in self.declared} + self.declared_names = {d.rsplit("/", 1)[-1] for d in self.declared} + self.declared_names_folded = {n.lower() for n in self.declared_names} + by_name = db.get("indexes", {}).get("by_name", {}) + self._by_name = by_name + self._by_name_folded: dict[str, list[str]] = {} + for name, sha1s in by_name.items(): + self._by_name_folded.setdefault(name.lower(), []).extend(sha1s) + self._files = db.get("files", {}) + self._owners: dict[tuple[str, bool], list[LauncherPlace]] = {} + relevant = set(relevant) + for place in self.places: + standalone = place.build == "standalone" + for emulator in (e.strip() for e in place.emulator.split(",")): + for key in self._profiles_named(emulator, profiles, relevant): + self._owners.setdefault((key, standalone), []).append(place) + + @staticmethod + def _profiles_named(name: str, profiles: dict, relevant: set[str]) -> list[str]: + """The profiles a platform's emulator name designates, among those it runs.""" + return sorted( + key for key in relevant + if (profile := profiles.get(key)) is not None + and profile.get("type") not in ("alias", "launcher") + and (key == name or name in {str(c) for c in profile.get("cores") or []}) + ) + + def __bool__(self) -> bool: + return bool(self._owners) + + def places_for(self, emulator: str, standalone: bool) -> list[LauncherPlace]: + return self._owners.get((emulator, standalone), []) + + def files_named(self, place: LauncherPlace, name: str) -> list[dict]: + """The database files carrying a name, in the place's case rule.""" + if place.case_insensitive: + sha1s = self._by_name_folded.get(name.lower(), []) + else: + sha1s = self._by_name.get(name, []) + return [self._files[s] for s in dict.fromkeys(sha1s) if s in self._files] + + def match(self, place: LauncherPlace, entry: dict) -> _Candidate | None: + """How a core entry belongs to a place: by a name it is read under, or by content.""" + basenames = place.basenames() + own = { + place.fold(str(n).rsplit("/", 1)[-1]) + for n in (entry.get("name", ""), *(entry.get("aliases") or [])) + if n + } + names = own & basenames + if names: + return _Candidate(entry, names, False) + content = { + place.fold(path.rsplit("/", 1)[-1]) + for path in place.paths + if any( + accepts(entry, record) + for record in self.files_named(place, path.rsplit("/", 1)[-1]) + ) + } + if content: + return _Candidate(entry, content, True) + return None + + def platform_holds(self, place: LauncherPlace) -> bool: + """Whether the platform's own list declares a file the place reads.""" + if place.anchored: + pool = self.declared_folded if place.case_insensitive else self.declared + return any(place.fold(path) in pool for path in place.paths) + pool = self.declared_names_folded if place.case_insensitive else self.declared_names + return bool(place.basenames() & pool) + + def settle( + self, + candidates: list[tuple[str, bool, dict, dict]], + resolve_held: Callable[[dict], dict | None], + ) -> tuple[list[dict], set[int]]: + """What the launcher layer adds to the report and what it settles. + + ``candidates`` holds (profile, standalone, profile entry, report + entry) for every core entry the report considered. ``resolve_held`` + returns the database record of the file the collection holds for a + report entry, None when it holds none. + + Returns the report entries to add, one per place nothing filled that + a held file the core accepts can fill under the launcher's name, and + the ids of the report entries the filled places settle. + """ + added: list[dict] = [] + settled: set[int] = set() + # Destinations the report already fills with a held file, whichever + # core asked for it: the pack puts a file there either way. + reported_at: dict[str, dict] = {} + for _emulator, _standalone, _entry, report in candidates: + if report.get("in_repo"): + destination = sanitize_pack_path(report.get("path") or report.get("name", "")) + reported_at.setdefault(destination, report) + by_layout: dict[tuple[str, bool], list[tuple[dict, dict]]] = {} + for emulator, standalone, entry, report in candidates: + by_layout.setdefault((emulator, standalone), []).append((entry, report)) + placed: set[str] = set() + for (emulator, standalone), members in sorted(by_layout.items()): + for place in self.places_for(emulator, standalone): + matched = [ + (candidate, report) + for entry, report in members + if (candidate := self.match(place, entry)) is not None + ] + if not matched: + continue + filled = self._fill(place, matched, reported_at, resolve_held, placed) + if filled is None: + continue + if isinstance(filled, dict): + added.append(filled) + if place.stands_for_default: + settled.update( + id(report) for _candidate, report in matched + if not report.get("in_repo") + ) + return added, settled + + def _fill( + self, + place: LauncherPlace, + matched: list[tuple[_Candidate, dict]], + reported_at: dict[str, dict], + resolve_held: Callable[[dict], dict | None], + placed: set[str], + ) -> dict | bool | None: + """True when the place is filled already, a new report entry when one fills it.""" + if self.platform_holds(place): + return True + folded_reported = {place.fold(dest): r for dest, r in reported_at.items()} + for path in place.paths: + name = path.rsplit("/", 1)[-1] + key = place.fold(name) + if place.anchored: + if place.fold(path) in folded_reported: + return True + elif any( + key == place.fold(r.get("name", "")) or key == place.fold(dest.rsplit("/", 1)[-1]) + for dest, r in folded_reported.items() + ): + return True + if not place.anchored: + continue + source = self._content_for(place, name, key, matched, resolve_held) + if source is None: + continue + report, record = source + if place.fold(path) in placed: + return True + placed.add(place.fold(path)) + return _launcher_entry(report, name, path, record, place) + return None + + def _content_for( + self, + place: LauncherPlace, + name: str, + key: str, + matched: list[tuple[_Candidate, dict]], + resolve_held: Callable[[dict], dict | None], + ) -> tuple[dict, dict] | None: + """A held file the core accepts for one launcher name, and its entry. + + The file the database knows under that name comes first when the + core accepts it: by content when the entry pins one, by name and + size when the entry reads it under that name. Otherwise the file the + collection holds for an entry read under that name. + """ + for candidate, report in matched: + if key not in candidate.names: + continue + for record in self.files_named(place, name): + if accepts(candidate.entry, record) or ( + not candidate.by_content + and not _hashes(candidate.entry) + and name_match_size_ok(candidate.entry, record.get("size")) + ): + return report, record + for candidate, report in matched: + if key not in candidate.names or candidate.by_content: + continue + if not report.get("in_repo"): + continue + record = resolve_held(report) + if record is not None: + return report, record + return None + + +def _launcher_entry( + report: dict, name: str, path: str, record: dict, place: LauncherPlace +) -> dict: + """The report entry asking for a file under the name a launcher opens.""" + entry = { + key: report.get(key) + for key in ( + "profile", "emulator", "systems", "system", "region", "variant_group", + "required", "hle_fallback", "category", + ) + } + entry.update( + name=name, + path=path, + bundled=False, + in_repo=True, + note="", + checks=[], + source_ref=None, + expected={}, + sha1=record.get("sha1"), + md5=record.get("md5"), + launcher=place.source_ref, + launcher_of=report.get("name", ""), + ) + return entry diff --git a/scripts/verify.py b/scripts/verify.py index 5735669d..dfae360a 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -75,6 +75,7 @@ from common import ( ) yaml = require_yaml() +from launchers import LauncherLayer, declared_destinations from nativemode import ( SIZE_AND_ANY_HASH, digest_algorithm, @@ -447,6 +448,17 @@ def _identity_of(entry: dict) -> dict: } +def _held_record(report: dict, db: dict) -> dict | None: + """The database record of the file the collection holds for a report entry.""" + local, status = resolve_local_file(report, db, dest_hint=report.get("path") or "") + if local is None or status in ("not_found", "hash_mismatch"): + return None + for record in db.get("files", {}).values(): + if record.get("path") == local: + return record + return None + + def find_undeclared_files( config: dict, emulators_dir: str, @@ -491,6 +503,12 @@ def find_undeclared_files( relevant = resolve_platform_cores(config, profiles, target_cores=target_cores) standalone_set = platform_standalone_set(config) + # What the platform's own launchers open, matched to the cores it runs: + # the one decision the builder and this report both read. + launcher_layer = LauncherLayer( + config, profiles, relevant, db, declared_destinations(config) + ) + launcher_candidates: list[tuple[str, bool, dict, dict]] = [] undeclared = [] seen_files: set[tuple] = set() # Key -> the entry reported for it. A later profile reading the same @@ -665,12 +683,21 @@ def find_undeclared_files( } if f.get("contents"): entry["contents"] = f["contents"] + if launcher_layer: + launcher_candidates.append((emu_name, is_standalone, f, entry)) if duplicate: first.setdefault("other_claims", []).append(entry) continue reported[seen_key] = entry undeclared.append(entry) + if launcher_layer: + added, settled = launcher_layer.settle( + launcher_candidates, lambda report: _held_record(report, db) + ) + undeclared = [u for u in undeclared if id(u) not in settled] + undeclared.extend(added) + # Append grouped archive entries for entry in sorted(archive_entries.values(), key=lambda e: e["name"]): undeclared.append(entry) diff --git a/tests/test_launcher_layer.py b/tests/test_launcher_layer.py new file mode 100644 index 00000000..db8a29f2 --- /dev/null +++ b/tests/test_launcher_layer.py @@ -0,0 +1,233 @@ +"""The launcher layer read by verify and by the pack builder. + +A platform's launcher decides which name a file is read under. The fixtures +give a synthetic platform three launcher records: a region list tried in +order (Batocera's DuckStation shape), a second list no declared file fills, +and a fixed path the launcher passes (Batocera's x16emu shape). +""" + +from __future__ import annotations + +import hashlib +import inspect +import os +import shutil +import sys +import tempfile +import unittest +import zipfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +import yaml # noqa: E402 + +from common import _emulator_profiles_cache, load_emulator_profiles, load_platform_config # noqa: E402 +from launchers import LauncherLayer, launcher_places # noqa: E402 +from verify import find_undeclared_files, verify_platform # noqa: E402 + +CONTENT = { + "b.bin": b"REGION_B_IMAGE", + "e.bin": b"IMAGE_KNOWN_AS_E", + "core.bin": b"CORE_DEFAULT_ROM", + "plain.bin": b"PLAIN_CORE_FILE", +} + + +def _md5(data: bytes) -> str: + return hashlib.md5(data).hexdigest() + + +class LauncherLayerTest(unittest.TestCase): + def setUp(self) -> None: + _emulator_profiles_cache.clear() + self.root = tempfile.mkdtemp() + self.bios_dir = os.path.join(self.root, "bios") + self.platforms_dir = os.path.join(self.root, "platforms") + self.emulators_dir = os.path.join(self.root, "emulators") + for d in (self.bios_dir, self.platforms_dir, self.emulators_dir): + os.makedirs(d) + files: dict[str, dict] = {} + by_md5: dict[str, str] = {} + by_name: dict[str, list[str]] = {} + for name, data in CONTENT.items(): + path = os.path.join(self.bios_dir, name) + with open(path, "wb") as fh: + fh.write(data) + sha1 = hashlib.sha1(data).hexdigest() + files[sha1] = { + "path": path, "name": name, "md5": _md5(data), "sha1": sha1, + "size": len(data), "crc32": "", + } + by_md5[_md5(data)] = sha1 + by_name.setdefault(name, []).append(sha1) + self.db = { + "files": files, + "indexes": { + "by_md5": by_md5, "by_name": by_name, "by_crc32": {}, + "by_path_suffix": {}, + }, + } + platform = { + "platform": "LauncherTest", + "verification_mode": "md5", + "base_destination": "bios", + "standalone_cores": ["psxemu", "fixemu"], + "systems": { + "test-sys": { + "files": [ + {"name": "plain.bin", "destination": "plain.bin", + "md5": _md5(CONTENT["plain.bin"]), "required": True}, + ], + }, + }, + "launchers": [ + { + "emulator": "psxemu", "build": "standalone", + "kind": "names_searched", "slot": "NTSCU", + "names": ["a.bin", "b.bin", "c.bin"], + "case_insensitive": True, "search_dir": "/userdata/bios", + "source_ref": "gen/psx.py:10-20", + }, + { + "emulator": "psxemu", "build": "standalone", + "kind": "names_searched", "slot": "PAL", + "names": ["d.bin", "e.bin"], + "case_insensitive": True, "search_dir": "/userdata/bios", + "source_ref": "gen/psx.py:21-30", + }, + { + "emulator": "fixemu", "build": "standalone", + "kind": "fixed_name", "names": ["sub/fix.bin"], + "search_dir": "/userdata/bios", + "source_ref": "gen/fix.py:5", + }, + ], + } + with open(os.path.join(self.platforms_dir, "launchertest.yml"), "w") as fh: + yaml.dump(platform, fh) + profiles = { + "psxemu": { + "emulator": "PsxEmu", "type": "standalone", "found_by": "content", + "systems": ["test-sys"], + "files": [ + {"name": "a.bin", "required": True, + "md5": "0" * 32, "validation": ["md5"]}, + {"name": "b.bin", "required": True, + "md5": _md5(CONTENT["b.bin"]), "validation": ["md5"]}, + {"name": "label_pal.bin", "required": True, + "md5": _md5(CONTENT["e.bin"]), "validation": ["md5"]}, + ], + }, + "fixemu": { + "emulator": "FixEmu", "type": "standalone", "systems": ["test-sys"], + "files": [ + {"name": "core.bin", "aliases": ["fix.bin"], "required": True}, + ], + }, + } + for key, profile in profiles.items(): + with open(os.path.join(self.emulators_dir, f"{key}.yml"), "w") as fh: + yaml.dump(profile, fh) + self.config = load_platform_config("launchertest", self.platforms_dir) + self.profiles = load_emulator_profiles(self.emulators_dir) + + def tearDown(self) -> None: + shutil.rmtree(self.root) + + def _report(self) -> list[dict]: + return find_undeclared_files( + self.config, self.emulators_dir, self.db, self.profiles, + ) + + def _pack_names(self) -> set[str]: + from generate_pack import generate_pack + + output = os.path.join(self.root, "dist") + os.makedirs(output) + zip_path = generate_pack( + "launchertest", self.platforms_dir, self.db, self.bios_dir, output, + emulators_dir=self.emulators_dir, emu_profiles=self.profiles, + offline=True, + ) + self.assertIsNotNone(zip_path) + with zipfile.ZipFile(zip_path) as zf: + return set(zf.namelist()) + + def test_places_follow_the_launcher_order(self) -> None: + places = launcher_places(self.config) + self.assertEqual(places[0].paths, ("a.bin", "b.bin", "c.bin")) + self.assertEqual(places[2].paths, ("sub/fix.bin",)) + self.assertTrue(places[0].stands_for_default) + + def test_held_alternative_settles_the_others(self) -> None: + """b.bin fills the NTSCU list, so a.bin is not missing there.""" + report = self._report() + names = {u["name"]: u for u in report} + self.assertNotIn("a.bin", names) + self.assertTrue(names["b.bin"]["in_repo"]) + + def test_without_the_layer_the_alternative_is_missing(self) -> None: + config = {**self.config, "launchers": []} + names = { + u["name"]: u + for u in find_undeclared_files(config, self.emulators_dir, self.db, self.profiles) + } + self.assertFalse(names["a.bin"]["in_repo"]) + + def test_accepted_content_lands_under_the_launcher_name(self) -> None: + """No declared file fills PAL: the image the core accepts goes in as e.bin.""" + added = {u["path"]: u for u in self._report() if u.get("launcher")} + self.assertIn("e.bin", added) + self.assertEqual(added["e.bin"]["md5"], _md5(CONTENT["e.bin"])) + self.assertEqual(added["e.bin"]["launcher_of"], "label_pal.bin") + + def test_fixed_name_receives_the_held_file(self) -> None: + added = {u["path"]: u for u in self._report() if u.get("launcher")} + self.assertIn("sub/fix.bin", added) + self.assertEqual(added["sub/fix.bin"]["md5"], _md5(CONTENT["core.bin"])) + + def test_no_place_is_filled_twice(self) -> None: + added = [u["path"] for u in self._report() if u.get("launcher")] + self.assertEqual(sorted(added), ["e.bin", "sub/fix.bin"]) + + def test_a_declared_name_fills_the_place(self) -> None: + layer = LauncherLayer( + self.config, self.profiles, self.profiles, self.db, {"B.BIN"}, + ) + self.assertTrue(layer.platform_holds(layer.places[0])) + self.assertFalse(layer.platform_holds(layer.places[1])) + + def test_verify_and_pack_agree(self) -> None: + result = verify_platform(self.config, self.db, self.emulators_dir, self.profiles) + missing = [ + u["name"] for u in result["undeclared_files"] + if not u["in_repo"] and u.get("category", "bios") != "game_data" + ] + self.assertEqual(missing, []) + names = self._pack_names() - {"README.txt"} + for expected in ("e.bin", "sub/fix.bin", "b.bin"): + self.assertIn(expected, names) + self.assertNotIn("a.bin", names) + self.assertEqual(result["packed_extras"]["bios"], len(names - {"plain.bin"})) + + def test_one_decision_for_verify_and_pack(self) -> None: + """The layer is applied once, in the report both sides read.""" + import generate_pack + import packextras + import verify + + self.assertIn("LauncherLayer(", inspect.getsource(verify.find_undeclared_files)) + self.assertIn(".settle(", inspect.getsource(verify.find_undeclared_files)) + self.assertIn( + "find_undeclared_files(", inspect.getsource(packextras._collect_emulator_extras) + ) + for module in (generate_pack, packextras, verify): + source = inspect.getsource(module) + with self.subTest(module=module.__name__): + self.assertNotIn("launcher_places(", source) + if module is not verify: + self.assertNotIn("LauncherLayer(", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index c7b79e20..d66bcd16 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -283,6 +283,23 @@ they copy, with `$bios_path` as the framework names it. In both, every line that names the BIOS directory must match a known construct, else the scrape stops with `LauncherPatternMissing`. +`verify.py` and the pack builder read the records through one function, +`LauncherLayer.settle()` in `scripts/launchers.py`, applied inside the +cross-reference both of them consume. A `names_searched` list is one place: +the names are tried in order and one held file fills it, so a core file the +launcher would never open once another name of the list is there is not +reported missing. A `fixed_name` or `core_option` name is a place of its own. +Only a record that applies without condition settles the core's own name: a +core option the user may never set, or a record tied to one system, leaves +the core's default in use elsewhere. +A place that no declared file and no packed core file fills receives, under +the launcher's name, a file the core accepts: the file the collection knows +under that name when the core takes its content, otherwise the copy held for +the core entry read under that name. Records that only hand over a directory +(`passes_path`, `scans_dir`) or copy a file the platform already names +(`links`, `copies`) place nothing, and a `search_dir` the code describes +rather than names ("directory of the Chihiro BIOS found") is skipped. + ### Inheritance If the new platform inherits from an existing one (e.g. Lakka inherits RetroArch), diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index bc9b6789..270190f0 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -39,7 +39,7 @@ Every module in `tests/`, by the area it guards. |------|----------|---------| | Core regression suites | synthetic | `test_e2e`, `test_audit_regressions` | | File resolution and identity | synthetic + collection | `test_adler32_resolution`, `test_agnostic_scan`, `test_agnostic_scan_mode`, `test_agnostic_scope`, `test_agnostic_substitute`, `test_archive_choice`, `test_bios_companions`, `test_bundled_identity`, `test_casefold_cache`, `test_db_aliases`, `test_declared_names`, `test_derivations`, `test_directory_entries`, `test_extra_identity`, `test_foreign_tree`, `test_foreign_tree_names`, `test_frontend_digest`, `test_game_data_homonyms`, `test_hashing`, `test_identity`, `test_md5_pack_context`, `test_name_tiebreak`, `test_nec_filing`, `test_owner_resolution`, `test_path_suffix`, `test_resolution_evidence`, `test_shipped_content`, `test_twin_declarations`, `test_validated_choice`, `test_variant_choice`, `test_variant_identity`, `test_zipped_member` | -| Verification, cross-reference and profile checks | synthetic + profiles | `test_crypto_aes`, `test_crypto_unique_data`, `test_crypto_verify`, `test_case_insensitive_platforms`, `test_checks_have_values`, `test_core_file_fills_declaration`, `test_cross_reference_sources`, `test_file_consumers`, `test_fileless_audit`, `test_gap_settlement`, `test_hidden_extras`, `test_native_mode`, `test_no_case_collisions`, `test_path_conflict_status`, `test_path_null_copies`, `test_profile_checks`, `test_romm_hash_rule`, `test_shared_groups`, `test_standalone_mode`, `test_undeclared_dedup`, `test_unplaced_extras`, `test_validation_scope`, `test_verify_all_dir`, `test_verify_emulator_evidence`, `test_verify_listing`, `test_verify_pack_choice` | +| Verification, cross-reference and profile checks | synthetic + profiles | `test_crypto_aes`, `test_crypto_unique_data`, `test_crypto_verify`, `test_case_insensitive_platforms`, `test_checks_have_values`, `test_core_file_fills_declaration`, `test_cross_reference_sources`, `test_file_consumers`, `test_fileless_audit`, `test_gap_settlement`, `test_hidden_extras`, `test_launcher_layer`, `test_native_mode`, `test_no_case_collisions`, `test_path_conflict_status`, `test_path_null_copies`, `test_profile_checks`, `test_romm_hash_rule`, `test_shared_groups`, `test_standalone_mode`, `test_undeclared_dedup`, `test_unplaced_extras`, `test_validation_scope`, `test_verify_all_dir`, `test_verify_emulator_evidence`, `test_verify_listing`, `test_verify_pack_choice` | | Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_pack_conformance_dirs`, `test_pack_counts`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_required_only_archives`, `test_required_only_twins`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` | | Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` | | Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` |