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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-20 21:26:48 +02:00
1 parent 8660892d60
commit b2b8dcd62c
6 files changed
+752 -1

No files matched your search

+1
View File
@@ -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"
+473
View File
@@ -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, "<system_directory>"}
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
+27
View File
@@ -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)
+233
View File
@@ -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()
+17
View File
@@ -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),
+1 -1
View File
@@ -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` |