mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-20 18:59:07 -05:00
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:
1 parent
8660892d60
commit
b2b8dcd62c
6 files changed
+752
-1
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in new issue
Block a user