chore: merge origin/main

# Conflicts:
#	install.ps1
#	install.sh
#	install/launchers/index.json
This commit is contained in:
Abdessamad Derraz committed 2026-11-10 07:39:26 +01:00
commit 8e15f94ed1
2865 files changed
+187255 -99594

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
"""Entries a core accepts in place of one another.
A profile writes ``group:`` on the entries of one search list. Yabause tries
saturn_bios.bin, sega_101.bin, then mpr-17933.bin and loads the first it
finds (libretro.c:1035-1049): it needs one of them and none in particular.
The requirement belongs to the set, in the profile's ``groups:`` map, and a
member's ``required`` is derived from the sets it is in: true when one of
them is required. A fallback two machine models share (pom2's apple2.rom)
then needs no value of its own. ``priority`` keeps the order the code reads
the members in.
Holding one member fills the set, so the other members are not missing. A
set nobody holds is one gap, reported under its first member with the set's
requirement, never one gap per member. A --required-only build keeps one
member of a required set, the first the code would load among those held.
Every reader goes through this module so that verify, the gap report, the
packs and the manifests agree.
"""
from __future__ import annotations
from collections.abc import Callable, Hashable, Iterable, Sequence
from dataclasses import dataclass
_UNRANKED = 1 << 30
def groups_of(entry: dict) -> tuple[str, ...]:
"""The sets an entry belongs to: one name, a list for a file two machine
models both fall back on, or a map from set to the entry's rank there."""
value = entry.get("group")
if not value:
return ()
values = value if isinstance(value, (list, dict)) else [value]
return tuple(str(v) for v in values if v)
def rank_in(entry: dict, group: str) -> int | None:
"""The rank of an entry in one of its sets: the map's value when the
entry ranks differently in each (pom2's apple2.rom is second for the ][
and third at boot), else its priority."""
value = entry.get("group")
if isinstance(value, dict) and isinstance(value.get(group), int):
return value[group]
priority = entry.get("priority")
return priority if isinstance(priority, int) else None
def set_requirements(profile: dict) -> dict[str, bool]:
"""The requirement of each set the profile declares."""
declared = profile.get("groups") or {}
return {
str(name): bool((spec or {}).get("required", True))
for name, spec in declared.items()
}
def derive_member_requirements(profile: dict) -> None:
"""Write on each member the requirement its sets give it.
Read once when a profile is loaded, so every reader sees ``required``
as it always has. A member is required when one of its sets is.
"""
requirements = set_requirements(profile)
for entry in profile.get("files") or []:
if not isinstance(entry, dict):
continue
names = groups_of(entry)
if names and "required" not in entry:
entry["required"] = any(requirements.get(n, True) for n in names)
def required_groups(entry: dict, profile: dict) -> list[str]:
"""The required sets of its profile an entry is a member of."""
requirements = set_requirements(profile)
return [n for n in groups_of(entry) if requirements.get(n, True)]
def set_fields(entry: dict, profile: dict) -> dict:
"""What a --required-only build reads on a core file to keep one member
of a set: the entry's required sets, each with its rank there."""
sets = required_groups(entry, profile)
if not sets:
return {}
return {"required_groups": {group: rank_in(entry, group) for group in sets}}
def set_keys(owner: Hashable, entry: dict) -> tuple[tuple, ...]:
"""The keys of an entry's sets, scoped to its owner (a profile, or a
profile under one build)."""
return tuple((owner, group) for group in groups_of(entry))
def slot_set_keys(owner: Hashable, entries: Iterable[dict]) -> tuple[tuple, ...]:
"""The sets the entries on one destination belong to. A destination has
one outcome however many versions it lists, so its versions' sets are
settled together."""
keys: list[tuple] = []
for entry in entries:
keys.extend(k for k in set_keys(owner, entry) if k not in keys)
return tuple(keys)
def _rank(rank: int | None, position: int) -> tuple[int, int]:
return (rank if isinstance(rank, int) else _UNRANKED, position)
@dataclass
class Standing:
"""The member kept as the one gap of the sets nobody holds."""
others: list[str]
required: bool
def settle_sets(
members: Sequence[tuple[tuple[tuple, ...], dict, bool]],
set_required: Callable[[tuple], bool] = lambda _key: True,
) -> tuple[set[int], dict[int, Standing]]:
"""Which unheld members are excused, and which one stands for its set.
``members`` lists ``(set keys, entry, fills)`` in the order the profile
declares them, where ``fills`` says the member is held (or answered by a
declaration that reports it). Returns the positions to leave out of a
report and, for each set no member fills, the position kept as its one
gap with the names of the others and the set's requirement. A member
fills or not on its own: a held one is never excused, and an unheld one
stays reported when it stands for at least one unfilled set.
"""
by_set: dict[tuple, list[int]] = {}
for position, (keys, _entry, _fills) in enumerate(members):
for key in keys:
by_set.setdefault(key, []).append(position)
standing: dict[int, Standing] = {}
for key, positions in by_set.items():
if any(members[p][2] for p in positions):
continue
first = min(positions, key=lambda p: _rank(rank_in(members[p][1], key[1]), p))
held_up = standing.setdefault(first, Standing([], False))
held_up.required = held_up.required or set_required(key)
for p in positions:
name = str(members[p][1].get("name", ""))
if p != first and name not in held_up.others:
held_up.others.append(name)
excused = {
position
for position, (keys, _entry, fills) in enumerate(members)
if keys and not fills and position not in standing
}
return excused, standing
def required_only_spares(
entries: Sequence[dict],
owner: Callable[[dict], Hashable],
held: Callable[[dict], bool],
gone: Callable[[dict], bool] = lambda _entry: False,
sets: Callable[[dict], dict[str, int | None]] = lambda e: e.get("required_groups") or {},
) -> set[int]:
"""The ids of the held members a --required-only build leaves out.
``sets`` maps the required sets of an entry to its rank in each: a core
file of a platform pack carries them as ``required_groups``.
A required set needs one file: the build keeps the member the code
would load first among those it holds and still carries (``gone`` says
a region filter took it), by ``priority`` then declaration order, and
drops the others. A member of an optional set is not touched here,
nor one that another required set of its own still needs.
"""
candidates: dict[tuple, list[tuple[tuple[int, int], int]]] = {}
for position, entry in enumerate(entries):
if not held(entry) or gone(entry):
continue
for group, rank in sets(entry).items():
candidates.setdefault((owner(entry), group), []).append(
(_rank(rank, position), position)
)
kept: set[int] = set()
members: set[int] = set()
for ranked in candidates.values():
members.update(position for _rank_key, position in ranked)
kept.add(min(ranked)[1])
return {id(entries[p]) for p in members - kept}
def core_file_spares(
entries: Sequence[dict], gone: Callable[[dict], bool] = lambda _entry: False,
) -> set[int]:
"""The core files of a platform pack a --required-only build leaves out.
A set the platform declares a member of is carried by that declaration,
so its other members are spares. Otherwise one held member per required
set stays, as in an emulator pack. Read by the builder, the manifest
and verify alike.
"""
answered = {id(e) for e in entries if e.get("set_declared")}
# An archive under the core's own folder is read before the same name
# at the root (Kronos), so archive units rank first among equals.
rest = sorted(
(e for e in entries if id(e) not in answered),
key=lambda e: not e.get("archive"),
)
return answered | required_only_spares(
rest,
owner=lambda e: e.get("source_profile") or e.get("profile") or "",
held=lambda e: e.get("in_repo", True) is not False,
gone=gone,
)
+4 -1
View File
@@ -66,7 +66,10 @@ def bundled_only(db: dict, profiles: dict, platforms_dir: str, install_dir: Path
sha1 = consumers.sha1_of(entry, entry.get("path") or "", owner=emulator)
if not sha1:
continue
if entry.get("bundled"):
# A platform whose package leaves the file out (not_bundled_on:
# AmberELEC's amiberry 7.1.1 has no AROS ROMs) brings it from
# the collection.
if entry.get("bundled") and not entry.get("not_bundled_on"):
bundled.setdefault(sha1, set()).add(emulator)
else:
needed.add(sha1)
+11
View File
@@ -1615,6 +1615,17 @@ def check_pin_builds(
at = revision or upstream.resolve_head(repo, cache_dir, offline)
lines = upstream.fetch_file(repo, at, reference["path"], cache_dir, offline) \
if at else None
if at and lines is None:
# A file of a submodule is read at the commit the
# platform's tree pins: KNULLI builds Batocera's
# amiberry package through its batocera submodule.
modules = upstream.fetch_file(repo, at, ".gitmodules", cache_dir, offline)
declared = upstream.submodule_urls("\n".join(modules or ()), repo)
if any(reference["path"].startswith(f"{p}/") for p in declared):
linked = upstream.through_gitlink(repo, at, reference["path"],
cache_dir, offline)
if linked is not None:
lines = upstream.fetch_file(*linked, cache_dir, offline)
except upstream.NotCachedError:
findings.append(Finding("profiles", subject, SKIPPED, local=recorded[:12],
detail="offline, the build file is not cached"))
+569 -88
View File
@@ -6,7 +6,7 @@ and file resolution - eliminates DRY violations across scripts.
from __future__ import annotations
from collections.abc import Callable, Iterable
from collections.abc import Callable, Collection, Iterable, Mapping
from collections import Counter
import argparse
@@ -22,6 +22,8 @@ import zipfile
from pathlib import Path
from typing import Any, TextIO
from alternatives import derive_member_requirements
# Every build script imports this module. verify.py, slots.py, validate_pr.py
# and cross_reference.py started under 3.8 and stopped halfway on a
# str.removeprefix AttributeError, which named no version.
@@ -507,6 +509,14 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") -
# The registry key, which per-platform profile fields (installed_on,
# not_bundled_on) name: the display name differs (MiSTer FPGA).
config["_key"] = platform_name
# Where the platform's games live, as its rom folders file records it
# from the OS's own source (platforms/romfolders/<key>.yml, roms_root).
romfolders = os.path.join(platforms_dir, "romfolders", f"{platform_name}.yml")
if os.path.exists(romfolders):
with open(romfolders) as f:
roms_root = (source_yaml(f) or {}).get("roms_root")
if isinstance(roms_root, dict):
config["_roms_root"] = roms_root
_platform_config_cache[cache_key] = config
return config
@@ -859,11 +869,24 @@ def load_target_config(
)
)
declared = (targets[canonical] or {}).get("cores")
return _target_core_set(targets[canonical] or {}, overrides, canonical, platforms_dir)
def _target_core_set(
entry: dict, overrides: dict, target: str, platforms_dir: str
) -> set[str] | None:
"""A target's cores: what the board builds, overrides applied, and the
buildbot cores its RetroArch core downloader installs (`downloads`, a
RetroArch targets entry), which a Batocera x86_64 or armv7 board can
fetch from its menu."""
declared = entry.get("cores")
if declared is None:
return None
cores = set(str(c) for c in declared)
return apply_target_overrides(cores, overrides, canonical)
cores = apply_target_overrides({str(c) for c in declared}, overrides, target)
downloads = entry.get("downloads")
if downloads:
cores |= load_target_config("retroarch", str(downloads), platforms_dir) or set()
return cores
def apply_target_overrides(
@@ -907,12 +930,9 @@ def list_available_targets(
result = []
for tname, tdata in sorted(data.get("targets", {}).items()):
aliases = (overrides.get(tname) or {}).get("aliases", [])
declared = (tdata or {}).get("cores")
# None: the target runs every core the platform declares.
count = (
None if declared is None
else len(apply_target_overrides({str(c) for c in declared}, overrides, tname))
)
found = _target_core_set(tdata or {}, overrides, tname, platforms_dir)
count = None if found is None else len(found)
result.append(
{
"name": tname,
@@ -1267,7 +1287,39 @@ _archive_index: dict[int, tuple[list[dict], int, dict[str | None, list[dict]]]]
def _member(f: dict) -> dict:
return {"crc32": str(f["crc32"]).lower(), "required": bool(f.get("required", True))}
member = {"crc32": str(f["crc32"]).lower(), "required": bool(f.get("required", True))}
if f.get("group"):
# One of the members sharing it is enough (alternatives.py).
member["group"] = f["group"]
return member
def _required_met(members: list[dict], held: Callable[[dict], bool]) -> tuple[int, bool]:
"""How many requirements an archive meets, and whether it meets all.
A required member is one requirement, and so is a required set of
alternatives, met when one of its members is held: Odin reads the
first of three Hikaru boot ROMs it finds in hikaru.zip.
"""
from alternatives import groups_of
met = 0
complete = True
sets: dict[str, bool] = {}
for m in members:
if not m["required"]:
continue
names = groups_of(m)
if names:
for name in names:
sets[name] = sets.get(name, False) or held(m)
continue
if held(m):
met += 1
else:
complete = False
met += sum(sets.values())
return met, complete and all(sets.values())
def archive_members(archive: str, files: list[dict]) -> list[dict]:
@@ -1316,7 +1368,7 @@ def holds_required_members(path: str, members: list[dict]) -> bool:
"""Whether an archive holds every member a profile requires inside it."""
crcs = zip_member_crcs(path)
names = zip_member_names(path)
return all(_member_held(m, crcs, names) for m in members if m["required"])
return _required_met(members, lambda m: _member_held(m, crcs, names))[1]
def contents_members(file_entry: dict) -> list[dict]:
@@ -1360,8 +1412,8 @@ def _holding_members(paths: list[str], members: list[dict]) -> list[str]:
def held(path: str) -> tuple[int, int]:
crcs = zip_member_crcs(path)
names = zip_member_names(path)
found = [m for m in members if _member_held(m, crcs, names)]
return sum(1 for m in found if m["required"]), len(found)
found = {id(m) for m in members if _member_held(m, crcs, names)}
return _required_met(members, lambda m: id(m) in found)[0], len(found)
scores = {path: held(path) for path in paths}
best = max(scores.values(), default=(0, 0))
@@ -1482,6 +1534,29 @@ def resolve_local_file(
system_dir = file_entry.get("system_dir")
if system_dir and dest_hint.startswith(f"{system_dir}/"):
dest_hint = dest_hint[len(system_dir) + 1:]
# A launcher that moves the layout (EmuDeck links azahar's sysdata to
# bios/azahar/keys) changes where the file goes, not the tail the
# collection indexes it under. Both are asked, and the stronger proof
# wins: a content or path match over a name, the destination on a tie.
layout = file_entry.get("layout_path")
if layout and layout != dest_hint:
plain = {k: v for k, v in file_entry.items() if k != "layout_path"}
answers = [
resolve_local_file(
plain, db, zip_contents, dest_hint=hint, _depth=_depth,
data_dir_registry=data_dir_registry,
)
for hint in (dest_hint, str(layout))
]
for found in answers:
if found[0] is not None and (
resolution_is_hash_exact(found[1]) or found[1] == "path_exact"
):
return found
for found in answers:
if found[0] is not None and found[1] not in ("not_found", "hash_mismatch"):
return found
return next((found for found in answers if found[0] is not None), answers[0])
# A name written with its directory is the destination when no path is
# given: gam4980/E.BIN resolved by its basename took another model's OS
# in verify --emulator and the emulator pack, while the platform pack,
@@ -1611,20 +1686,20 @@ def resolve_local_file(
and not sha256_candidates
):
by_crc32 = db.get("indexes", {}).get("by_crc32", {})
found: list[tuple[str, str]] = []
crc_matches: list[tuple[str, str]] = []
for crc in crc_candidates:
match = by_crc32.get(crc)
if match and match in files_db:
path = files_db[match]["path"]
status = _record_match_status(match)
if status and os.path.exists(path):
found.append((path, status))
crc_matches.append((path, status))
# A primary copy over a .variants/ one, as everywhere else.
for path, status in found:
for path, status in crc_matches:
if "/.variants/" not in path:
return path, status
if found:
return found[0]
if crc_matches:
return crc_matches[0]
# 2. MD5 direct lookup (skip for zipped_file: md5 is inner ROM, not container)
# Guard: only accept if the found file's name matches the requested name
@@ -2134,7 +2209,7 @@ def profiles_for_systems(
wanted = {_norm_system_id(sid) for sid in system_ids}
matching = []
for name, profile in sorted(profiles.items()):
if profile.get("type") in ("launcher", "alias", "test"):
if profile.get("type") in ("launcher", "alias") or profile.get("role") == "test":
continue
if standalone and "standalone" not in profile.get("type", "libretro"):
continue
@@ -2188,7 +2263,7 @@ def select_emulator_profiles(
# and the spelling hints sent pcfx to two PlayStation cores.
claimants = sorted(
key for key in upstream_profile_index(all_profiles).get(name, ())
if all_profiles[key].get("type") not in ("launcher", "test")
if all_profiles[key].get("type") != "launcher" and all_profiles[key].get("role") != "test"
)
if len(claimants) == 1:
name = claimants[0]
@@ -2201,7 +2276,7 @@ def select_emulator_profiles(
available = sorted(
key
for key, value in all_profiles.items()
if value.get("type") not in ("alias", "test")
if value.get("type") != "alias" and value.get("role") != "test"
)
raise ProfileSelectionError(
f"emulator '{name}' not found\n"
@@ -2314,6 +2389,7 @@ def load_emulator_profiles(
if "emulator" not in profile:
continue
_normalize_hash_fields(profile)
derive_member_requirements(profile)
profiles[f.stem] = profile
_emulator_profiles_cache[cache_key] = profiles
return profiles
@@ -2345,7 +2421,7 @@ def _normalize_hash_fields(node: object) -> None:
def unique_emulator_profiles(profiles: dict[str, dict]) -> dict[str, dict]:
"""Profiles counting as distinct emulators (aliases and tests excluded)."""
return {
k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test")
k: v for k, v in profiles.items() if v.get("type") != "alias" and v.get("role") != "test"
}
@@ -2467,6 +2543,7 @@ def group_identical_platforms(
cores if isinstance(cores, str) else sorted(map(str, cores or [])),
sorted(map(str, config.get("standalone_cores") or [])),
sorted(map(str, config.get("dual_mode_cores") or [])),
sorted(map(str, config.get("installable_cores") or [])),
# One archive cannot serve a filesystem that folds case and one
# that does not: Lakka keeps bios.col beside BIOS.col.
bool(config.get("case_insensitive_fs")),
@@ -2610,9 +2687,19 @@ def runs_standalone(
return False
if "standalone" not in str(profile.get("type", "")):
return False
return emu_name in standalone_cores or bool(
standalone_cores & {str(c) for c in profile.get("cores", [])}
)
names = {emu_name, *(str(c) for c in profile.get("cores", []))}
# A name a build the platform compiles itself answers to means that
# build there: RetroBat's standalone amiberry is 8.3.0, and the 8.5.0
# profile, run there as the buildbot core, keeps its libretro layout.
owners: dict[str, set[str]] = {}
for tag in standalone_cores:
if tag.startswith(OWN_BUILD_PREFIX):
name, owner, source = json.loads(tag[len(OWN_BUILD_PREFIX):])
if source == profile.get("source"):
owners.setdefault(name, set()).add(owner)
if any(emu_name not in owners[n] for n in names & owners.keys()):
return False
return emu_name in standalone_cores or bool(standalone_cores & names)
# Marks, in a platform's standalone set, an emulator it also launches as a
@@ -2624,6 +2711,18 @@ HANDED_PREFIX = "handed:"
# Marks, in the same set, a libretro core and the system directory the
# platform hands it (``core_system_dirs``), as JSON.
CORE_DIR_PREFIX = "coredir:"
# Marks, in the same set, a file the platform's launcher executes
# (launchers.executed_files), as JSON.
EXECUTED_PREFIX = "exec:"
# Marks, in the same set, a standalone name a build the platform compiles
# itself (built_by) answers to, as JSON [name, profile, source].
OWN_BUILD_PREFIX = "ownbuild:"
# Marks, in the same set, a file name the platform declares under one of the
# systems a launcher record is written for, as JSON [system, name]: it says
# which launch reads a profile entry of that name.
SYSTEM_FILE_PREFIX = "sysfile:"
# The key, in that index, of a machine a launcher record offers.
MACHINE_KEY = "machine:"
def core_system_dirs(
@@ -2672,6 +2771,24 @@ def system_dir_path(entry: dict, path: str) -> str:
return f"{directory}/{path}" if directory else path
def own_build_tags(config: dict, standalone: set[str]) -> set[str]:
"""The standalone names the platform's own builds answer to, tagged.
Read from the profiles of this repository, the ones every pack, manifest
and report is built from.
"""
platform = str(config.get("_key") or "")
if not platform or not standalone:
return set()
profiles = load_emulator_profiles(str(Path(__file__).resolve().parent.parent / "emulators"))
return {
OWN_BUILD_PREFIX + json.dumps([n, key, p.get("source")])
for key, p in profiles.items()
if platform in (p.get("built_by") or []) and "standalone" in str(p.get("type", ""))
for n in {key, *(str(c) for c in p.get("cores") or [])} & standalone
}
def standalone_set(config: dict) -> set[str]:
"""The emulators a platform launches standalone, with its dual-mode ones.
@@ -2681,6 +2798,16 @@ def standalone_set(config: dict) -> set[str]:
"""
names = {str(c) for c in config.get("standalone_cores") or []}
names |= {DUAL_MODE_PREFIX + str(c) for c in config.get("dual_mode_cores") or []}
# A buildbot core the platform's RetroArch installs under the name of an
# emulator it runs standalone is that emulator's libretro build: run
# both ways, as Batocera runs amiberry standalone and can fetch
# amiberry_libretro.
names |= {
DUAL_MODE_PREFIX + str(c)
for c in set(map(str, config.get("installable_cores") or []))
& set(map(str, config.get("standalone_cores") or []))
}
names |= own_build_tags(config, names)
# The system directory each core is handed travels the same way, so
# platform_layouts places a core's files below it for every reader.
names |= {
@@ -2689,13 +2816,40 @@ def standalone_set(config: dict) -> set[str]:
for core in entry.get("cores") or []
}
if names - {n for n in names if n.startswith(CORE_DIR_PREFIX)}:
from launchers import handed_directories # noqa: PLC0415
from launchers import executed_files, handed_directories # noqa: PLC0415
handed = handed_directories(config)
# The record's position travels too: a search list is read in order.
names |= {
HANDED_PREFIX + json.dumps(
[h.emulator, h.folder, h.directory, sorted(h.handles), h.source_ref]
[position, h.emulator, h.folder, h.directory, sorted(h.handles), h.source_ref,
h.roms_place, sorted(h.systems), h.holds_declared]
)
for h in handed_directories(config)
for position, h in enumerate(handed)
}
named = {system for h in handed for system in h.systems}
names |= {
SYSTEM_FILE_PREFIX + json.dumps([slug, str(f["name"]).lower()])
for slug, system in (config.get("systems") or {}).items()
if slug in named
for f in system.get("files") or []
if f.get("name")
}
# The machines a record offers under its systems, by normalized id.
from launchers import native_systems # noqa: PLC0415
natives = native_systems(config)
names |= {
SYSTEM_FILE_PREFIX + json.dumps(
[natives.get(str(native), str(native)), MACHINE_KEY + _norm_system_id(str(m))]
)
for record in config.get("launchers") or []
for native in record.get("systems") or []
for m in record.get("machines") or []
}
names |= {
EXECUTED_PREFIX + json.dumps([run.emulator, run.name, run.place, run.source_ref])
for run in executed_files(config)
}
return names
@@ -2724,38 +2878,165 @@ def _handed(standalone_cores: Iterable[str]) -> list:
for tag in standalone_cores:
if not tag.startswith(HANDED_PREFIX):
continue
emulator, folder, directory, handles, ref = json.loads(tag[len(HANDED_PREFIX):])
found.append(HandedDirectory(emulator, folder, directory, frozenset(handles), ref))
position, emulator, folder, directory, handles, ref, roms, systems, held = json.loads(
tag[len(HANDED_PREFIX):]
)
found.append(((position, tag), HandedDirectory(
emulator, folder, directory, frozenset(handles), ref,
tuple(roms) if roms else None, frozenset(systems), held,
)))
return [h for _key, h in sorted(found, key=lambda pair: pair[0])]
def system_file_index(standalone_cores: Iterable[str]) -> dict[str, set[str]]:
"""File name, lower case -> the platform systems declaring it (SYSTEM_FILE_PREFIX)."""
index: dict[str, set[str]] = {}
for tag in standalone_cores:
if tag.startswith(SYSTEM_FILE_PREFIX):
slug, name = json.loads(tag[len(SYSTEM_FILE_PREFIX):])
index.setdefault(name, set()).add(slug)
return index
def entry_launch_systems(entry: dict, index: Mapping[str, set[str]]) -> frozenset[str]:
"""The platform systems, by our ids, that launch the emulator for an entry.
The systems the entry names, and those under which the platform declares
a file of one of its names (``index``, system_file_index): Recalbox lists
XRoar's coco3.rom under trs80coco, which our coco is.
"""
systems = {str(s) for s in entry.get("systems") or []}
if entry.get("system"):
systems.add(str(entry["system"]))
for name in [entry.get("name"), *(entry.get("aliases") or [])]:
if name:
systems |= index.get(str(name).lower(), set())
return frozenset(systems)
def _executed(standalone_cores: Iterable[str]) -> list:
"""The files a standalone set's launchers execute, as launchers read them."""
from launchers import ExecutedFile # noqa: PLC0415
found = []
for tag in standalone_cores:
if tag.startswith(EXECUTED_PREFIX):
emulator, name, place, ref = json.loads(tag[len(EXECUTED_PREFIX):])
found.append(ExecutedFile(emulator, name, tuple(place) if place else None, ref))
return found
def standalone_destination(entry: dict, root: str | None) -> str:
"""Where a standalone build reads an entry, relative to the BIOS root.
# Why a standalone entry has no destination on a platform that relocates its
# layout: the manifest's omission reason, named as the installer words it.
OUTSIDE_BIOS_FOLDER = "outside_bios_folder"
# The entry root of a file read below the platform's roms directory, and of
# one read in an emulator's folder outside the BIOS tree that the registry
# describes. Each is located by the registry entry <root>_directory.
ROMS_ROOT = "roms"
EMULATORS_ROOT = "emulators"
CONFIGS_ROOT = "configs"
OUTER_ROOTS = (ROMS_ROOT, EMULATORS_ROOT, CONFIGS_ROOT)
``standalone_path`` is relative to the directories the build resolves
it against (``standalone_directories``). A platform that hands one of
them a folder of its BIOS tree puts the file under that folder:
RetroPie and ROCKNIX give Flycast bios/dc, EmuDeck bios/flycast/bios.
An entry with no ``standalone_path`` keeps ``path``, the layout both
builds share.
def outer_layout(config: dict, root: str) -> dict | None:
"""Where a platform keeps the folder an outer root names, or None.
roms: the roms root its rom folders file records (``path``, the OS's
spelling, and ``from_bios``, relative to the BIOS folder), with the
BIOS folder's own name, base_destination, that a run's folder must
carry before ``from_bios`` is followed from it. emulators: the
registry's ``emulators_directory``, the folders a launcher searches
for an emulator by key, which no rom folders file describes.
"""
if root == ROMS_ROOT:
found = config.get("_roms_root") or {}
base = str(config.get("base_destination") or "")
path, relation = found.get("path"), found.get("from_bios")
if not (path and relation) or "<" in str(path):
return None
if base:
return {"path": str(path), "from_bios": str(relation), "bios_folder": base}
# A list whose dests start above the BIOS folder (AmberELEC's
# bios/...) installs into the folder holding it: a roms root there
# or below is part of the install tree, written as a plain dest.
declared = declared_root(config)
if not declared:
return None
inside = os.path.normpath(f"{declared}/{relation}")
if inside == ".." or inside.startswith("../"):
return None
return {"path": str(path), "install_prefix": "" if inside == "." else inside}
if root == EMULATORS_ROOT:
return config.get("emulators_directory") or None
if root == CONFIGS_ROOT:
# The frontend's configs folder, as its path code builds it
# (configs_root, Batocera's batoceraPaths CONFIGS).
found = config.get("configs_root") or {}
base = str(config.get("base_destination") or "")
if not (found.get("path") and found.get("from_bios") and base):
return None
return {"path": str(found["path"]), "from_bios": str(found["from_bios"]),
"bios_folder": base}
return None
def standalone_layout_path(entry: dict, profile: dict) -> str | None:
"""The path of an entry relative to the directories its standalone build declares.
``standalone_path`` when the entry has one. A profile with no libretro
build has one layout, so its ``path`` is that layout: UAE4All2 reads
kickstarts/kick13.rom below its working directory.
"""
own = entry.get("standalone_path")
if own and root:
return f"{root}/{own}"
return own or entry.get("path") or entry.get("name", "")
def standalone_root_for(
emu_name: str, profile: dict, standalone_cores: Iterable[str]
) -> str | None:
"""The folder a platform gives this emulator's standalone layout, or None."""
declared = profile.get("standalone_directories") or []
if not declared:
if own:
return own
if "libretro" in str(profile.get("type", "")):
return None
from launchers import standalone_root # noqa: PLC0415
return entry.get("path") or None
def standalone_destination(
entry: dict, profile: dict, bindings: dict[str, tuple[str, str]]
) -> tuple[str, str] | None:
"""Where a platform puts a standalone entry: (tree, path).
``bindings`` are the places the platform's launchers give the layout
(launchers.standalone_bindings): RetroPie and ROCKNIX give Flycast bios/dc,
EmuDeck bios/flycast/bios, RetroPie gives UAE4All2's kickstarts/ bios/amiga,
ROCKNIX runs PICO-8 from roms/pico-8. The tree is launchers.BIOS_TREE or
launchers.ROMS_TREE. None when no binding covers the entry.
"""
layout = standalone_layout_path(entry, profile)
if layout is None or not bindings:
return None
from launchers import bound_path # noqa: PLC0415
return bound_path(layout, bindings)
def standalone_bindings_for(
emu_name: str, profile: dict, standalone_cores: Iterable[str],
systems: Iterable[str] = (),
) -> dict[str, tuple[str, str]]:
"""The places a platform gives this emulator's standalone layout, by subfolder.
``systems`` are those the entry is launched for (entry_launch_systems).
"""
declared = profile.get("standalone_directories") or []
pools = profile.get("standalone_pools") or {}
if not declared and not pools:
return {}
from launchers import standalone_bindings # noqa: PLC0415
names = {emu_name, *(str(c) for c in profile.get("cores") or [])}
return standalone_root(names, declared, _handed(standalone_cores))
layout = [
path for f in profile.get("files") or []
if isinstance(f, dict) and (path := standalone_layout_path(f, profile))
]
return standalone_bindings(
names, declared, _handed(standalone_cores), _executed(standalone_cores), layout,
pools, systems,
)
def platform_layouts(
@@ -2774,19 +3055,78 @@ def platform_layouts(
return [(profile, False)]
return [(system_dir_view(profile, d), False) for d in directories]
standalone = profile
root = standalone_root_for(emu_name, profile, standalone_cores)
if root:
# Bindings can depend on the system an entry is launched for, so each
# set of launch systems gets its own.
by_systems: dict[frozenset[str], dict[str, tuple[str, str]]] = {}
names = {emu_name, *(str(c) for c in profile.get("cores") or [])}
per_system = bool(
profile.get("standalone_directories") or profile.get("standalone_pools")
) and any(h.systems and h.emulator in names for h in _handed(standalone_cores))
index = system_file_index(standalone_cores) if per_system else {}
# A profile system the platform launches under a system of its own, as
# its list says for one entry, is launched there for every entry:
# Recalbox lists mc10.rom under trs80coco, so alice.rom is read there.
family: dict[str, set[str]] = {}
for f in (profile.get("files") or []) if per_system else []:
if isinstance(f, dict):
own = entry_launch_systems(f, {})
for system in own:
family.setdefault(system, set()).update(entry_launch_systems(f, index) - own)
def bindings_of(entry: dict) -> dict[str, tuple[str, str]]:
systems: frozenset[str] = frozenset()
if per_system:
own = entry_launch_systems(entry, {})
systems = entry_launch_systems(entry, index)
if systems == own:
systems = systems.union(*(family.get(s, set()) for s in own))
if systems == own:
# The platform's list names none of the entry's names: its
# own system selects the launch that offers it as a machine
# (RetroBat runs XRoar's coco3 under coco).
systems = systems.union(*(
index.get(MACHINE_KEY + _norm_system_id(s), set()) for s in own
))
if systems not in by_systems:
by_systems[systems] = standalone_bindings_for(
emu_name, profile, standalone_cores, systems
)
return by_systems[systems]
layout_entries = [
f for f in profile.get("files") or []
if isinstance(f, dict) and standalone_layout_path(f, profile) is not None
]
if any(bindings_of(f) for f in layout_entries):
# The standalone view carries the platform's destination, so every
# reader of standalone_path (builder, manifest, verify, slots) places
# the file in the folder the platform hands the emulator.
standalone = {
**profile,
"files": [
{**f, "standalone_path": standalone_destination(f, root)}
if isinstance(f, dict) and f.get("standalone_path") else f
for f in profile.get("files") or []
],
}
# An entry of the layout no binding covers is read in a folder the
# platform keeps outside the BIOS tree: UAE4All2's kick.rom in
# RetroPie's $md_inst. It is marked, never left at the BIOS root
# where the emulator does not read it.
files = []
for f in profile.get("files") or []:
if not isinstance(f, dict) or standalone_layout_path(f, profile) is None:
files.append(f)
continue
placed = standalone_destination(f, profile, bindings_of(f))
if placed is None or placed[0] == "outside":
files.append({**f, "unplaced": OUTSIDE_BIOS_FOLDER})
continue
tree, path = placed
moved = {**f, "standalone_path": path,
"layout_path": standalone_layout_path(f, profile)}
if tree == "install":
# Below the roms root inside the install tree (AmberELEC's
# /storage/roms): a plain dest the platform prefix skips.
files.append({**moved, "unprefixed": True})
continue
# A file the launcher runs from the roms directory (ROCKNIX's
# roms/pico-8) or an emulator folder (RetroBat's emulators/pico8)
# is relative to it, and says so.
files.append({**moved, "root": tree} if tree else moved)
standalone = {**profile, "files": files}
dual = {c.removeprefix(DUAL_MODE_PREFIX) for c in standalone_cores
if c.startswith(DUAL_MODE_PREFIX)}
if "libretro" in str(profile.get("type", "")) and dual & {
@@ -2890,51 +3230,126 @@ def resolve_platform_cores(
Alias profiles are always excluded (they point to another profile).
If target_cores is provided, result is intersected with it.
"""
result, downloaded_only = _platform_profiles(config, profiles)
if target_cores is not None:
result = result & designated_profiles(profiles, target_cores)
result -= {
profile for profile, names in downloaded_only.items()
if not downloadable_on(target_cores, names)
}
return result
def downloaded_only_profiles(
config: dict, profiles: dict[str, dict]
) -> dict[str, frozenset[str]]:
"""The profiles a platform reaches only through its RetroArch core
downloader, each with the installable names that reach it."""
return _platform_profiles(config, profiles)[1]
def _platform_profiles(
config: dict, profiles: dict[str, dict]
) -> tuple[set[str], dict[str, frozenset[str]]]:
cores_config = config.get("cores")
platform_key = str(config.get("_key") or "")
downloaded_only: dict[str, set[str]] = {}
# The builds the platform compiles itself, by the names they answer to.
# Such a name means that build there, and another revision of the same
# project is not what the platform runs: ROCKNIX and RetroPie build only
# standalone Amiberry, Recalbox its standalone and libretro Amiberry 8.2.2.
# Only a platform listing the name both ways (dual_mode_cores) beside a
# standalone-only build of its own runs the buildbot core too (RetroBat).
# A core of another project under the name stays: RetroPie builds
# minivmac37 standalone beside the minivmac core.
own_builds = {
name: p for name, p in profiles.items()
if platform_key and platform_key in (p.get("built_by") or [])
}
def replaced_by_own(name: str, p: dict, libretro_only: bool = False) -> bool:
if platform_key in (p.get("built_by") or []):
return False
names = {name, *(str(c) for c in p.get("cores") or [])}
return any(
(not libretro_only or "libretro" in str(q.get("type", "")))
and q.get("source") == p.get("source")
and names & {key, *(str(c) for c in q.get("cores") or [])}
for key, q in own_builds.items()
)
def every_libretro() -> set[str]:
# A build only some platforms compile (built_by) is reached by name:
# the buildbot RetroArch installs from does not publish it.
# A build only some platforms compile (built_by) is absent from the
# buildbot RetroArch installs from, so it is reached by name, or as one
# of the libretro cores of a platform that compiles it: ROCKNIX ships
# yabadabadoo with its RetroArch and names it nowhere else.
return {
name
for name, p in profiles.items()
if "libretro" in p.get("type", "") and p.get("type") != "alias"
and not p.get("built_by")
and (
not p.get("built_by")
or (platform_key and platform_key in p["built_by"])
)
and not replaced_by_own(name, p)
}
if cores_config == "all_libretro":
result = every_libretro()
elif isinstance(cores_config, list):
core_set = {str(c) for c in cores_config}
# An emulator the platform launches standalone runs there even when
# its own list names only the cores: Batocera's es_systems.yml files
# lexaloffle and gsplus as standalone emulators and nowhere else. A
# core its RetroArch installs from the buildbot is a libretro build
# and designates libretro profiles only (below): Batocera downloads
# the linuxloader core and runs no standalone Linux Loader.
core_set = {str(c) for c in cores_config} | {
str(c) for c in config.get("standalone_cores") or []
}
index = upstream_profile_index(profiles)
platform = str(config.get("_key") or "")
def designated(name: str) -> str | None:
def designated(name: str) -> set[str]:
# A build the platform compiles itself (built_by) is what its
# name means there: Recalbox's uae4all is Chips-fr/uae4all-rpi,
# RetroPie's the standalone uae4all2 under the same name.
own = sorted(
# RetroPie's the standalone uae4all2 under the same name. A
# platform building one name in several revisions, one per board,
# runs all of them: RetroPie's amiberry is 5.7.2 and 6.3.3 on x86.
own = {
p for p in index.get(name, ())
if platform and platform in (profiles[p].get("built_by") or [])
)
return own[0] if own else preferred_profile(index, name)
}
if own:
return own
preferred = preferred_profile(index, name)
return {preferred} if preferred is not None else set()
result = {
profile
for c in core_set
if (profile := designated(c)) is not None
}
result = {profile for c in core_set for profile in designated(c)}
# A name the platform launches both ways designates its libretro
# build too. Batocera, Recalbox and RetroBat run pcsx2 standalone and
# as LRPS2, two profiles: the key match kept the standalone one and
# LRPS2's files never reached their packs.
result |= {
profile
for c in config.get("dual_mode_cores") or []
for profile in index.get(str(c), set())
if "libretro" in str(profiles[profile].get("type", ""))
and not profiles[profile].get("built_by")
}
# LRPS2's files never reached their packs. A core its RetroArch
# installs is a libretro build as well (amiberry's buildbot core
# beside Batocera's standalone amiberry).
def libretro_builds(name: object) -> set[str]:
return {
profile
for profile in index.get(str(name), set())
if "libretro" in str(profiles[profile].get("type", ""))
and not profiles[profile].get("built_by")
and not replaced_by_own(profile, profiles[profile], libretro_only=True)
}
dual = {p for c in config.get("dual_mode_cores") or [] for p in libretro_builds(c)}
# A core reached only through the RetroArch downloader runs on the
# boards whose downloader offers it: Batocera's x86_64 RetroArch
# installs the amiberry core, its armv7 and aarch64 boards cannot.
# Every installable name counts: Batocera's x86_64 downloader offers
# nside_sfc_balanced, not the higan_sfc_balanced it also lists.
for c in config.get("installable_cores") or []:
for profile in libretro_builds(c) - dual - result:
downloaded_only.setdefault(profile, set()).add(str(c))
result |= dual | set(downloaded_only)
# Support "all_libretro" as a list element: combines all libretro
# profiles with explicitly listed standalone cores (e.g. RetroDECK
# ships RetroArch + standalone emulators)
@@ -2951,9 +3366,31 @@ def resolve_platform_cores(
and not p.get("built_by")
}
if target_cores is not None:
result = result & designated_profiles(profiles, target_cores)
return result
return result, {p: frozenset(names) for p, names in downloaded_only.items()}
@functools.lru_cache(maxsize=1)
def _retroarch_target_sets() -> tuple[frozenset[str], ...]:
platforms = str(Path(__file__).resolve().parent.parent / "platforms")
return tuple(
frozenset(found)
for t in list_available_targets("retroarch", platforms)
if (found := load_target_config("retroarch", t["name"], platforms))
)
def downloadable_on(target_cores: set[str], names: Collection[str]) -> bool:
"""Whether a board's core set holds a RetroArch download list offering
one of `names`.
A board with a downloader carries a whole RetroArch targets entry in its
set (load_target_config, `downloads`), a board without one only what it
builds.
"""
return any(
found <= target_cores and not found.isdisjoint(names)
for found in _retroarch_target_sets()
)
def unresolved_platform_cores(config: dict, profiles: dict[str, dict]) -> set[str]:
@@ -3508,6 +3945,50 @@ def declared_root(config: dict) -> str:
return root if count / len(roots) > 0.9 else ""
def platform_declared_contents(config: dict, db: dict) -> dict[str, set[str]]:
"""The folders each declared file's content sits in, keyed by its hashes.
A core that scans a folder and identifies a ROM by its checksum reads a
declared file whatever its name: RetroDECK lists atari800's Atari BASIC
Rev A as ROM_BASIC_A, and the core takes it by CRC32. Keys are
"md5:", "sha1:" and "crc32:" values of the database file the
declaration resolves to, each with the declared slot's folder.
"""
prefix = declared_root(config).lower()
by_md5 = db.get("indexes", {}).get("by_md5", {})
files_db = db.get("files", {})
contents: dict[str, set[str]] = {}
for system in config.get("systems", {}).values():
for fe in system.get("files", []):
if fe.get("zipped_file"):
continue
record = files_db.get((fe.get("sha1") or "").lower())
md5 = (fe.get("md5") or "").lower()
if record is None and md5 and "," not in md5:
sha1 = by_md5.get(md5, "")
record = files_db.get(sha1[0] if isinstance(sha1, list) else sha1)
if not record:
continue
slot = sanitize_pack_path(fe.get("destination") or fe.get("name", "")).lower()
if prefix and slot.startswith(prefix + "/"):
slot = slot[len(prefix) + 1:]
folder = slot.rpartition("/")[0]
for field in ("md5", "sha1", "crc32"):
value = str(record.get(field) or "").lower()
if value:
contents.setdefault(f"{field}:{value}", set()).add(folder)
return contents
def merged_platform_declared_contents(configs: list[dict], db: dict) -> dict[str, set[str]]:
"""`platform_declared_contents` of several platforms, one index."""
merged: dict[str, set[str]] = {}
for config in configs:
for key, folders in platform_declared_contents(config, db).items():
merged.setdefault(key, set()).update(folders)
return merged
def merged_platform_declarations(
configs: list[dict], db: dict
) -> dict[str, list[tuple[int | None, str]]]:
@@ -3552,7 +4033,7 @@ def list_emulator_profiles(emulators_dir: str, skip_aliases: bool = True) -> Non
profiles = load_emulator_profiles(emulators_dir, skip_aliases=False)
for name in sorted(profiles):
p = profiles[name]
if p.get("type") in ("alias", "test"):
if p.get("type") == "alias" or p.get("role") == "test":
continue
display = p.get("emulator", name)
ptype = p.get("type", "libretro")
@@ -3573,7 +4054,7 @@ def system_index(profiles: dict) -> dict[str, tuple[str, set[str]]]:
"""
index: dict[str, tuple[str, set[str]]] = {}
for name, p in sorted(profiles.items()):
if p.get("type") in ("alias", "test", "launcher"):
if p.get("type") in ("alias", "launcher") or p.get("role") == "test":
continue
for sys_id in p.get("systems", []):
key = _norm_system_id(sys_id)
+370 -10
View File
@@ -23,7 +23,9 @@ from collections import Counter
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
from alternatives import set_keys, set_requirements, settle_sets
from common import (
OUTER_ROOTS,
platform_arg,
ProfileSelectionError,
_foreign_tree,
@@ -35,6 +37,7 @@ from common import (
get_mame_clone_map,
list_emulator_profiles,
list_registered_platforms,
merged_platform_declared_contents,
load_data_dir_registry,
load_database,
load_emulator_profiles,
@@ -50,7 +53,18 @@ from common import (
select_emulator_profiles,
)
from hashing import parse_hash_list
from launchers import LauncherLayer, declared_destinations, held_record
from launchers import (
LauncherLayer,
catalogued_model_files,
declared_destinations,
held_record,
name_settings,
set_by,
setting_readers,
suffixed_stem,
)
from launchers import _directories as launcher_directories
from launchers import _names as launcher_names
from validation import (
case_folded_slots,
declaration_settles,
@@ -695,7 +709,7 @@ def slot_key(f: dict, standalone: bool = False) -> tuple:
f.get("archive"),
system_dir_path(f, where or fname),
f.get("system"),
f.get("variant_group"),
f.get("selection_slot"),
f.get("mode", "both"),
read_from_save_dir(f, standalone),
)
@@ -796,6 +810,10 @@ def _cross_reference_profile(
# the profile's entries in their order, so a position names one entry.
position = {id(f): i for view, _ in layouts for i, f in enumerate(view.get("files", []))}
fates: dict[int, str] = {}
# Grouped entries met, as (set keys, entry, fills, the list holding
# its row, the row): settled once the profile is read.
set_records: list[tuple[tuple, dict, bool, list | None, dict | None]] = []
archive_rows: list[dict] = []
def fate(f: dict, what: str) -> None:
key = position[id(f)]
@@ -833,15 +851,18 @@ def _cross_reference_profile(
# Collect unsourceable files separately (documented, not a gap)
unsourceable_reason = f.get("unsourceable", "")
keys = set_keys((emu_name, is_standalone), f)
if unsourceable_reason:
seen_files.add(seen_key)
fate(f, "unsourceable")
unsourceable_list.append({
row = {
"name": fname,
"required": f.get("required", False),
"reason": unsourceable_reason,
"source_ref": f.get("source_ref", ""),
})
}
unsourceable_list.append(row)
set_records.append((keys, f, False, unsourceable_list, row))
continue
if outside_gap_scope(f, is_standalone):
@@ -855,13 +876,15 @@ def _cross_reference_profile(
# archive.
# A platform declares files of its BIOS tree: none answers for one
# the core reads below the save directory.
in_platform = not save_root and declaration_settles(
f, fname, platform_names, effective_path, index.get("folded_slots", frozenset())
in_platform = not save_root and f.get("root") not in OUTER_ROOTS and declaration_settles(
f, fname, platform_names, effective_path, index.get("folded_slots", frozenset()),
index.get("declared_contents"),
)
if in_platform:
seen_files.add(seen_key)
fate(f, "declared")
set_records.append((keys, f, True, None, None))
covered.append({
"name": fname,
"path": effective_path,
@@ -877,7 +900,7 @@ def _cross_reference_profile(
archive_key = (
archive,
f.get("system"),
f.get("variant_group"),
f.get("selection_slot"),
f.get("mode", "both"),
)
if archive_key not in archive_gaps:
@@ -899,6 +922,15 @@ def _cross_reference_profile(
"archive_required_count": 0,
}
entry = archive_gaps[archive_key]
if entry["archive_file_count"] == 0:
# The archive's own declaration names its sets: an archive
# is a member like a loose file (Kronos' stvbios.zip).
own = archive_entry(archive, emu_files)
set_records.append((
set_keys((emu_name, is_standalone), own), own,
entry["in_repo"], archive_rows, entry,
))
archive_rows.append(entry)
entry["archive_file_count"] += 1
if f.get("required", False):
entry["archive_required_count"] += 1
@@ -940,6 +972,10 @@ def _cross_reference_profile(
}
if save_root:
entry["root"] = "save"
elif f.get("unplaced"):
entry["unplaced"] = f["unplaced"]
elif f.get("root") in OUTER_ROOTS:
entry["root"] = f["root"]
elif index.get("launcher_candidates") is not None:
# What verify hands the launcher layer for the same entry.
probe = {
@@ -954,9 +990,34 @@ def _cross_reference_profile(
(emu_name, is_standalone, f, probe, entry)
)
gaps.append(entry)
set_records.append((keys, f, in_repo, gaps, entry))
# One need per set: a member another one fills is not a gap, and a set
# nobody holds is one gap under its first member (alternatives.py), as
# verify and the packs count it.
# Archive units rank first among equals, as the builder orders them.
grouped = sorted(
(r for r in set_records if r[0]), key=lambda r: r[3] is not archive_rows
)
requirements = set_requirements(profile)
excused, standing = settle_sets(
[(keys, f, fills) for keys, f, fills, _l, _r in grouped],
lambda key: requirements.get(key[1], True),
)
for at, (_keys, member, _fills, rows, set_row) in enumerate(grouped):
if set_row is None or rows is None:
continue
if at in standing:
set_row["alternatives"] = standing[at].others
set_row["required"] = standing[at].required
if at in excused:
rows.remove(set_row)
if id(member) in position:
fates[position[id(member)]] = "other_versions"
for ag in sorted(archive_gaps.values(), key=lambda e: e["name"]):
gaps.append(ag)
if any(ag is row for row in archive_rows):
gaps.append(ag)
tally = Counter(fates.values())
report[emu_name] = {
@@ -1030,6 +1091,280 @@ def _settle_launcher_places(
data.update(_gap_counts(data["gap_details"]))
# The --json key of the launcher reads category, beside the emulator keys.
LAUNCHER_READS_KEY = "_launcher_reads_not_held"
# A package record names no file: it records what a package builds.
_NAMELESS_KINDS = frozenset({"package"})
# The folder of the platform's own launcher, as its scraper spells it: a
# program the launcher runs from there (RetroBat's xbox-iso-vfs.exe) is part
# of the platform, never a file to collect.
PLATFORM_OWN_FOLDERS = frozenset({"<emulatorLauncher folder>"})
def launcher_file_names(record: dict) -> list[str]:
"""The concrete file names a launcher record reads, in code order.
The names are read as the launcher layer reads them (`launchers._names`):
a label after the name is dropped ("neocd_z.rom (CDZ)"), and a name with
`<` or without an extension is not a file name (a folder the record
links, an option value such as hatari2014's "default"). A name with a
`{X}` placeholder is a template: Batocera's hatari tries tos{L}.img for
every language and ROCKNIX links one memory card per slot and region,
so its expansions say which variants the code accepts, not which file is
owed. A wildcard is a pattern the emulator matches. `search_dir` is a
folder and never a name, whatever the kind.
"""
if record.get("kind") in _NAMELESS_KINDS:
return []
return [
name for name in launcher_names({**record, "expands": {}}) if "*" not in name
]
def _profile_names(profiles: dict[str, dict]) -> tuple[dict[str, list[dict]], dict[str, list[dict]]]:
"""Profile entries by each name and alias they answer to, exact and folded."""
exact: dict[str, list[dict]] = {}
folded: dict[str, list[dict]] = {}
for profile in profiles.values():
if profile.get("type") in ("launcher", "alias"):
continue
for entry in profile.get("files") or []:
names = {str(entry.get("name") or "")} | {str(a) for a in entry.get("aliases") or []}
for name in names - {""}:
base = name.rsplit("/", 1)[-1]
exact.setdefault(base, []).append(entry)
folded.setdefault(base.casefold(), []).append(entry)
return exact, folded
class _NameHolder:
"""Whether the collection holds a file under a launcher name.
A name is held when database.json carries a file under it or under an
alias it indexes, when a profile entry that answers to the name (as its
name or an alias) resolves, or when resolve_local_file finds it in a data
directory. The database answers for files kept as release assets, absent
from a checkout. A copy known only under another casing is returned
apart: a launcher that matches names exactly does not open it.
"""
def __init__(
self, db: dict, profiles: dict[str, dict], data_dir_registry: dict | None
) -> None:
self.db = db
self.registry = data_dir_registry
files = db.get("files", {})
self.by_name = {
name: sha1s
for name, sha1s in db.get("indexes", {}).get("by_name", {}).items()
if any(sha1 in files for sha1 in sha1s)
}
self.by_name_folded: dict[str, list[str]] = {}
for name in self.by_name:
self.by_name_folded.setdefault(name.casefold(), []).append(name)
self.exact, self.folded = _profile_names(profiles)
self._cache: dict[str, tuple[str, bool] | None] = {}
def _entry_held(self, entry: dict) -> bool:
names = [str(entry.get("name") or "")] + [str(a) for a in entry.get("aliases") or []]
if any(n.rsplit("/", 1)[-1] in self.by_name for n in names if n):
return True
path, _status = resolve_local_file(entry, self.db, data_dir_registry=self.registry)
return path is not None
def held(self, name: str) -> tuple[str, bool] | None:
"""The name the collection holds *name* under, and whether its casing is exact."""
if name in self._cache:
return self._cache[name]
base = name.rsplit("/", 1)[-1]
found: tuple[str, bool] | None = None
stem = suffixed_stem(base)
if base in self.by_name or any(map(self._entry_held, self.exact.get(base, []))):
found = (base, True)
elif stem is not None and any(map(self._entry_held, self.exact.get(stem, []))):
found = (base, True)
else:
path, _status = resolve_local_file(
{"name": name}, self.db, data_dir_registry=self.registry
)
if path is not None:
found = (Path(path).name, Path(path).name == base)
elif base.casefold() in self.by_name_folded:
found = (self.by_name_folded[base.casefold()][0], False)
else:
entry = next(
(e for e in self.folded.get(base.casefold(), []) if self._entry_held(e)),
None,
)
if entry is not None:
found = (str(entry.get("name") or ""), False)
self._cache[name] = found
return found
def launcher_reads_not_held(
configs: dict[str, dict],
db: dict,
profiles: dict[str, dict],
data_dir_registry: dict | None = None,
emulator: str = "",
) -> list[dict]:
"""The file names a platform's launch code reads that the collection lacks.
One row per platform and name, with every record that reads it. A
launcher that matches case-insensitively, or runs on a filesystem that
folds case, is satisfied by a copy under another casing. Elsewhere such
a copy is reported with `held_as`, since the launcher does not open it
under the name it asks for. *emulator* keeps the records whose emulator
name designates that profile.
Three records ask for nothing the collection lacks. A search keeps the
first name it finds, so one held name fills it: RetroBat's Amiberry
launcher returns the first of kick34005.A500, kick34005.A500.rom and
amiga-os-130.rom that exists (Amiberry.Generator.cs:674-699). A copy
whose source lies outside the BIOS directory brings a file the platform
installs itself, from its own package or written by its own script
(RetroPie's lr-vecx copies $md_inst/system.bin), and so does a program
the launcher runs from its own folder. A file handed to the
setting a reader's profile entry is read from is that entry's file,
which the pack places under the launcher's name.
"""
holder = _NameHolder(db, profiles, data_dir_registry)
every_profile = set(profiles)
rows: list[dict] = []
for platform, config in configs.items():
by_key: dict[str, dict] = {}
for record in config.get("launchers") or []:
names = launcher_file_names(record)
if not names:
continue
readers = sorted({
key
for part in str(record.get("emulator", "")).split(",")
for key in LauncherLayer._profiles_named(part.strip(), profiles, every_profile)
})
if not readers:
# RetroDECK names its frontend and the core option names the core.
readers = setting_readers(
str(record.get("passes_to") or ""), profiles, every_profile
)
if emulator and emulator not in readers:
continue
folds = bool(record.get("case_insensitive") or config.get("case_insensitive_fs"))
base = str(config.get("base_destination") or "bios")
if record.get("kind") == "copies" and not launcher_directories(record, base):
continue
if str(record.get("search_dir") or "") in PLATFORM_OWN_FOLDERS:
continue
settings = name_settings(record, names)
def by_setting(name: str) -> bool:
"""Whether the setting the name is written to reads a held file.
A placeholder read from the setting takes the held dump the
catalogues name after the model the launcher's name spells.
"""
entries = [
entry
for key in readers
for entry in profiles[key].get("files") or []
if settings[name] and set_by(entry, settings[name])
]
return any(holder._entry_held(entry) for entry in entries) or any(
"<" in str(entry.get("name", ""))
and any(
name_match_size_ok(entry, found.get("size"))
for found in catalogued_model_files(name, db)
)
for entry in entries
)
by_model = {name for name in names if by_setting(name)}
if record.get("kind") == "names_searched" and any(
(found := holder.held(name)) and (found[1] or folds) for name in names
):
continue
reader = {
"emulator": str(record.get("emulator", "")),
"build": str(record.get("build", "")),
"kind": str(record.get("kind", "")),
"source_ref": str(record.get("source_ref", "")),
"profiles": readers,
}
# A name a reader's entry declares unsourceable is no gap: its
# row carries the reason and is counted apart.
unsourceable = {
str(n).rsplit("/", 1)[-1]: str(entry["unsourceable"])
for key in readers
for entry in profiles[key].get("files") or []
if entry.get("unsourceable")
for n in (entry.get("name"), *(entry.get("aliases") or []))
if n
}
for name in names:
found = holder.held(name)
if found and (found[1] or folds) or name in by_model:
continue
key = name.casefold() if folds else name
row = by_key.get(key)
if row is None:
row = by_key[key] = {
"platform": platform,
"name": name,
"emulators": [],
"readers": [],
}
if found:
row["held_as"] = found[0]
reason = unsourceable.get(name.rsplit("/", 1)[-1])
if reason and "unsourceable" not in row:
row["unsourceable"] = reason
if reader["emulator"] not in row["emulators"]:
row["emulators"].append(reader["emulator"])
if reader not in row["readers"]:
row["readers"].append(reader)
rows.extend(by_key[key] for key in sorted(by_key, key=str.casefold))
return rows
def print_launcher_reads(rows: list[dict]) -> None:
"""The launcher reads gap category, after the profile gaps.
A name its reader declares unsourceable is listed apart, with the
reason: it is no file left to find.
"""
unsourceable = [r for r in rows if r.get("unsourceable")]
rows = [r for r in rows if not r.get("unsourceable")]
platforms = sorted({r["platform"] for r in rows})
noun = "platform" if len(platforms) == 1 else "platforms"
print(f"\nLauncher reads, not held: {len(rows)} names across {len(platforms)} {noun}")
for platform in platforms:
print(f"\n{platform}")
for row in (r for r in rows if r["platform"] == platform):
first = row["readers"][0]
more = f" (+{len(row['readers']) - 1} records)" if len(row["readers"]) > 1 else ""
casing = f", held as {row['held_as']}" if row.get("held_as") else ""
print(
f" {row['name']} [{', '.join(row['emulators'])}, {first['kind']}, "
f"{first['source_ref']}]{more}{casing}"
)
if not unsourceable:
return
platforms = sorted({r["platform"] for r in unsourceable})
noun = "platform" if len(platforms) == 1 else "platforms"
print(
f"\nLauncher reads, unsourceable: {len(unsourceable)} names "
f"across {len(platforms)} {noun}"
)
for row in unsourceable:
print(
f" {row['platform']}: {row['name']} [{', '.join(row['emulators'])}, "
f"{row['readers'][0]['source_ref']}]: {row['unsourceable']}"
)
def cross_reference(
profiles: dict[str, dict],
declared: dict[str, set[str]],
@@ -1041,6 +1376,7 @@ def cross_reference(
folded_slots: frozenset[str] = frozenset(),
platform: str = "",
launcher_layer: LauncherLayer | None = None,
declared_contents: dict[str, set[str]] | None = None,
) -> dict:
"""Compare emulator profiles against platform declarations.
@@ -1078,6 +1414,7 @@ def cross_reference(
"data_names": data_names,
"all_declared": all_declared,
"folded_slots": folded_slots,
"declared_contents": declared_contents,
"bios_dir": db.get("bios_dir"),
"launcher_candidates": [] if launcher_layer else None,
}
@@ -1147,6 +1484,8 @@ def print_report(
fc = g.get("archive_file_count", 0)
rc = g.get("archive_required_count", 0)
archive_info = f" ({fc} files, {rc} required)"
if g.get("alternatives") and not g.get("in_repo"):
archive_info += f" (or {', '.join(g['alternatives'])})"
print(f" {req} {g['name']} [{src}]{archive_info}{note}")
total_gaps += gaps
@@ -1210,9 +1549,12 @@ def main() -> None:
# Files resolve against data/: the trees stay put until the process
# ends, which releases the hold.
held = contextlib.ExitStack()
hold_data_dirs(held, load_data_dir_registry(args.platforms_dir))
registry = load_data_dir_registry(args.platforms_dir)
hold_data_dirs(held, registry)
profiles = load_emulator_profiles(args.emulators_dir)
# Every profile's names and aliases, whatever --emulator or --platform keeps.
all_profiles = profiles
emulator = args.emulator
if emulator:
# A typo answered "No emulator profiles found" with exit 0.
@@ -1281,6 +1623,13 @@ def main() -> None:
all_declared=declared_names(args.platforms_dir, selected, db),
standalone_cores=standalone_cores,
folded_slots=folded_declared_slots(args.platforms_dir, selected, db),
declared_contents=merged_platform_declared_contents(
[
load_platform_config(name, args.platforms_dir)
for name in selected or list_registered_platforms(args.platforms_dir)
],
db,
),
platform=config.get("_key", args.platform) if args.platform else "",
# What the platform's launchers open, as verify and the builder read it.
launcher_layer=LauncherLayer(
@@ -1288,8 +1637,18 @@ def main() -> None:
) if args.platform else None,
)
# Counted apart from the profile gaps: a name only a launcher reads is
# reported, not yet judged.
launcher_reads = launcher_reads_not_held(
{
name: load_platform_config(name, args.platforms_dir)
for name in (selected or list_registered_platforms(args.platforms_dir))
},
db, all_profiles, registry, emulator or "",
)
if args.json:
print(json.dumps(report, indent=2))
print(json.dumps({**report, LAUNCHER_READS_KEY: launcher_reads}, indent=2))
else:
if emulator:
scope = emulator
@@ -1298,6 +1657,7 @@ def main() -> None:
else:
scope = "all emulators"
print_report(report, scope, show_clean=bool(args.emulator))
print_launcher_reads(launcher_reads)
if __name__ == "__main__":
+7
View File
@@ -264,6 +264,8 @@ class NativeFile:
return bool(self.truth["required"])
if self.platform is not None and self.platform.get("required") is not None:
return bool(self.platform["required"])
if self.truth is not None and self.truth.get("one_of_required") is not None:
return bool(self.truth["one_of_required"])
return True
@property
@@ -645,10 +647,15 @@ def build_native_model(
matched.filled.append(field_name)
t_req = truth_entry.get("required")
p_req = (matched.platform or {}).get("required")
# A file a core takes as one of a set has no requirement of
# its own: a platform line cannot say "one of these", so
# truth leaves its value as written rather than claim
# every member optional or every member needed.
if (
t_req is not None
and p_req is not None
and bool(t_req) != bool(p_req)
and not (truth_entry.get("one_of_required") and not t_req)
):
matched.corrections.append("required")
report.required_corrected.append(
+6 -1
View File
@@ -189,16 +189,21 @@ class Exporter(BaseExporter):
} - {""}
readers: set[str] = set()
requirers: set[str] = set()
set_readers: set[str] = set()
for entry in entries:
cores = set(entry.get("_cores") or ()) & named
readers |= cores
if entry.get("one_of_required"):
set_readers |= cores & set(entry.get("one_of") or ())
required_by = entry.get("_required_by")
if required_by is None:
if entry.get("required") is not False:
requirers |= cores
else:
requirers |= cores & set(required_by)
if not readers:
# A core that needs one file of a set needs none of them on its own,
# which a mandatory flag cannot say: the line keeps its value.
if not readers or (not requirers and set_readers):
if fe.platform is not None and fe.platform.get("required") is not None:
return bool(fe.platform["required"])
return fe.required
+9 -2
View File
@@ -131,7 +131,7 @@ class Exporter(BaseExporter):
output[key] = base[key]
continue
system, files = exportable[key]
ours: OrderedDict[str, OrderedDict[str, str]] = OrderedDict()
ours: OrderedDict[str, OrderedDict[str, object]] = OrderedDict()
# The checker reads one md5 per biosFiles item and reports the
# item by its path, so a path holds one item. An addition whose
# path the platform or an earlier addition holds is not written.
@@ -141,9 +141,16 @@ class Exporter(BaseExporter):
if fe.platform is None and (path in claimed or path in ours):
self._merged.add(id(fe))
continue
entry: OrderedDict[str, str] = OrderedDict()
entry: OrderedDict[str, object] = OrderedDict()
entry["md5"] = self.single_value(fe, "md5")
entry["file"] = path
# The two fields Program.cs reads besides md5 and file, as the
# scraper took them: group (one held file is enough) and
# optional (required: false).
if fe.platform is not None and fe.platform.get("group"):
entry["group"] = str(fe.platform["group"])
if fe.platform is not None and fe.platform.get("required") is False:
entry["optional"] = True
ours.setdefault(path, entry)
bios_files = []
for theirs in (base.get(key) or {}).get("biosFiles", []):
+2 -2
View File
@@ -37,7 +37,7 @@ KEY_TYPES: dict[str, dict] = {
**dict.fromkeys((
"installer", "os", "os_name", "stage", "reason", "platform", "message",
"folder", "dest", "system", "name", "bios_dir", "path", "member",
"list_version", "installed_version", "board",
"list_version", "installed_version", "board", "core",
), _TEXT),
"mode": {"enum": ["install", "check", "dry-run", "list"]},
"state": {"enum": ["present", "wrong_hash", "missing"]},
@@ -47,7 +47,7 @@ KEY_TYPES: dict[str, dict] = {
"bytes_needed", "data_missing", "not_installable", "installed", "up_to_date",
"pending", "code", "games", "recognised", "not_games",
), _COUNT),
**dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied"), _FLAG),
**dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied", "info"), _FLAG),
**dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES),
**dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept", "found",
"fallback"), _RECORDS),
+228 -43
View File
@@ -36,8 +36,11 @@ import zipfile
from pathlib import Path, PurePosixPath
sys.path.insert(0, os.path.dirname(__file__))
from alternatives import core_file_spares, settle_sets, slot_set_keys
from common import resolve_platform_cores as _platform_cores
from common import (
OUTER_ROOTS,
outer_layout,
installed_data_refs,
platform_arg,
require_directory,
@@ -83,6 +86,9 @@ from common import (
ProfileSelectionError,
core_system_dirs,
declared_root,
downloadable_on,
downloaded_only_profiles,
list_available_targets,
entry_serves_systems,
entry_system_ids,
platform_layouts,
@@ -1306,14 +1312,24 @@ def generate_pack(
db, bios_dir, zip_contents, data_registry, validation_index,
validation_owners, offline, profiles=emu_profiles,
)
spares = core_file_spares(
core_files,
lambda fe: bool(region_drops)
and extra_region_key(fe, declared_root(config)) in region_drops,
) if required_only else set()
for fe in core_files:
if required_only and fe.get("required") is False:
if required_only and (fe.get("required") is False or id(fe) in spares):
continue
dest = sanitize_pack_path(fe.get("destination", fe["name"]))
if region_drops and extra_region_key(fe, declared_root(config)) in region_drops:
continue
if not dest:
continue
if fe.get("root") in OUTER_ROOTS:
# Read below the roms directory or in an emulator folder: the
# pack is extracted into the BIOS folder and cannot reach
# them, the installer places these files.
continue
# Core extras: _collect_emulator_extras already adjusted
# destinations for slug-based platforms. Apply the effective
# prefix (base_dest, or inferred from YAML when base_dest is
@@ -1445,8 +1461,13 @@ def generate_pack(
if system_filter:
unfolded = _extras_for_systems(unfolded, system_filter)
prefix = _detect_extras_prefix(config, base_dest)
unfolded_spares = core_file_spares(
unfolded,
lambda fe: bool(region_drops)
and extra_region_key(fe, declared_root(config)) in region_drops,
) if required_only else set()
for fe in unfolded:
if required_only and fe.get("required") is False:
if required_only and (fe.get("required") is False or id(fe) in unfolded_spares):
continue
if region_drops and extra_region_key(fe, declared_root(config)) in region_drops:
continue
@@ -1791,6 +1812,16 @@ def generate_emulator_pack(
if archive:
archives.add(archive)
# What each destination came to, settled by set once the
# profile is read: a member another member fills is not missing,
# and a set nobody holds is reported once (alternatives.py). An
# archive unit is a member like a loose file.
outcomes: list[tuple[list[dict], bool, Callable[[], None] | None]] = []
spares = emulator_required_spares(
emu_name, profile, files, standalone, region_drops, db,
validation_index, zip_contents, data_registry,
) if required_only else set()
# Pack archives as units
archive_prefix = profile.get("archive_prefix", "")
for archive_name in sorted(archives):
@@ -1801,6 +1832,8 @@ def generate_emulator_pack(
own_entry = archive_entry(archive_name, files)
if required_only and archive_is_optional(own_entry, archive_name, files):
continue
if archive_dest in spares:
continue
local_path, status = resolve_file(
own_entry,
db,
@@ -1819,7 +1852,11 @@ def generate_emulator_pack(
if not (taken and local_path and _same_bytes(
local_path, packed_at.get(archive_dest.lower())
)):
report_missing(archive_name, archive_dest)
outcomes.append(([own_entry], False, functools.partial(
report_missing, archive_name, archive_dest
)))
else:
outcomes.append(([own_entry], True, None))
continue
if local_path and status not in ("not_found",):
if local_path.endswith(".zip"):
@@ -1833,8 +1870,11 @@ def generate_emulator_pack(
total_files += 1
if record is not None:
record.files.append(archive_dest)
outcomes.append(([own_entry], True, None))
else:
report_missing(archive_name, archive_dest)
outcomes.append(([own_entry], False, functools.partial(
report_missing, archive_name, archive_dest
)))
# Pack individual files (skip archive members). The entries of one
# profile on one destination are the releases the code accepts
@@ -1848,7 +1888,7 @@ def generate_emulator_pack(
if fe.get("archive"):
continue
dest = _resolve_destination(fe, standalone)
if not dest:
if not dest or dest in spares:
continue
if region_drops and dest in region_drops:
continue
@@ -1939,17 +1979,25 @@ def generate_emulator_pack(
held, packed_at.get(dest.lower())
):
# The file already at this path, asked for twice.
outcomes.append((alternatives, True, None))
continue
report_missing(alternatives[0]["name"], dest)
outcomes.append((alternatives, False, functools.partial(
report_missing, alternatives[0]["name"], dest
)))
continue
if chosen is None:
if refusal:
rejected_files.append(refusal)
outcomes.append((alternatives, False, None))
elif all(fe.get("unsourceable") for fe in alternatives):
unsourceable_files.append(alternatives[0]["name"])
outcomes.append((alternatives, False, functools.partial(
unsourceable_files.append, alternatives[0]["name"]
)))
else:
report_missing(alternatives[0]["name"], dest)
outcomes.append((alternatives, False, functools.partial(
report_missing, alternatives[0]["name"], dest
)))
continue
fe, local_path, how = chosen
@@ -1970,8 +2018,11 @@ def generate_emulator_pack(
total_files += 1
if record is not None:
record.files.append(dest)
outcomes.append((alternatives, True, None))
else:
missing_files.append(fe["name"])
outcomes.append((alternatives, False, functools.partial(
missing_files.append, fe["name"]
)))
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
@@ -1985,6 +2036,7 @@ def generate_emulator_pack(
# (but allow same file to be packed under different destinations,
# e.g., IPL.bin in GC/USA/ and GC/EUR/ from same source)
dedup_key_hash = f"{os.path.realpath(local_path)}:{dest}"
outcomes.append((alternatives, True, None))
if dedup_key_hash in seen_hashes:
continue
seen_hashes.add(dedup_key_hash)
@@ -2001,6 +2053,14 @@ def generate_emulator_pack(
if record is not None:
record.files.append(dest)
excused, _standing = settle_sets([
(slot_set_keys(emu_name, entries), entries[0], fills)
for entries, fills, _report in outcomes
])
for position, (_entries, _fills, report) in enumerate(outcomes):
if report is not None and position not in excused:
report()
# The trees after the files, as in a platform pack: a dump the
# profile declares wins over the copy a tree bundles at the same
# path (Dolphin's real dsp_rom.bin over Sys/GC's free one).
@@ -4272,7 +4332,8 @@ def _attach_claims(
})
continue
claim_path, _ = validated_choice(
claim, claim_path, db, validation_index or {}, bios_dir, None, dest, owners,
claim, claim_path, db, validation_index or {}, bios_dir, None,
claim.get("layout_path") or dest, owners,
)
copy, _size = _manifest_entry(
claim_path, manifest_dest, [claim_emu], db, repo_root, {}
@@ -4389,8 +4450,13 @@ def _manifest_core_entries(
db, bios_dir, zip_contents, data_registry, validation_index or {},
owners, offline, profiles=profiles,
)
spares = core_file_spares(
core_files,
lambda fe: bool(region_drops)
and extra_region_key(fe, declared_root(config)) in region_drops,
) if required_only else set()
for fe in core_files:
if required_only and fe.get("required") is False:
if required_only and (fe.get("required") is False or id(fe) in spares):
continue
dest = sanitize_pack_path(fe.get("destination", fe["name"]))
if not dest:
@@ -4449,11 +4515,7 @@ def _manifest_core_entries(
):
systems = _extra_system_ids(fe)
record_omission(
full_dest,
fe,
systems[0] if systems else "",
status,
[source_emu] if source_emu else [],
full_dest, fe, systems[0] if systems else "", status, _claim_cores(fe),
)
continue
@@ -4463,9 +4525,14 @@ def _manifest_core_entries(
# extras prefix when it is an internal layout dir (RetroDECK bios/).
# A file read below the save directory is written relative to the
# directory the core is handed, and says so (save_entry_fields).
manifest_dest = (
save_manifest_dest(fe) if save_root else _manifest_dest_of(full_dest, base_dest)
)
outer_root = fe.get("root") if fe.get("root") in OUTER_ROOTS else None
if save_root:
manifest_dest = save_manifest_dest(fe)
elif outer_root:
# Relative to the roms or emulators folder the entry names.
manifest_dest = dest
else:
manifest_dest = _manifest_dest_of(full_dest, base_dest)
entry, file_size = _manifest_entry(
local_path, manifest_dest, [source_emu] if source_emu else [],
@@ -4474,6 +4541,8 @@ def _manifest_core_entries(
)
if entry is not None and save_root:
entry.update(save_entry_fields(fe))
elif entry is not None and outer_root:
entry["root"] = outer_root
if entry is None:
if not _claim_platform_omission(
omitted_by_destination.get(full_dest), source_emu
@@ -4481,7 +4550,7 @@ def _manifest_core_entries(
systems = _extra_system_ids(fe)
record_omission(
full_dest, fe, systems[0] if systems else "", "not_found",
[source_emu] if source_emu else [],
_claim_cores(fe),
)
if file_size:
pack_only_sizes.append(file_size)
@@ -4595,6 +4664,17 @@ def _manifest_selection(
), held
def _claim_cores(fe: dict) -> list[str]:
"""The cores an extra and the claims folded onto it name: an omission
names every core reading the file, as a list built for one of them
would."""
names = {
claim.get("source_profile") or claim.get("source_emulator", "")
for claim in (fe, *fe.get("other_claims", []))
}
return sorted(name for name in names if name)
def _record_absent(
absent: list[dict],
config: dict,
@@ -4621,11 +4701,18 @@ def _record_absent(
):
continue
full_dest = extra_full_destination(fe, extras_pfx)
if not full_dest:
continue
if full_dest in seen_destinations or full_dest in omitted_by_destination:
if not full_dest or full_dest in seen_destinations:
continue
source_emu = fe.get("source_profile") or fe.get("source_emulator", "")
held = omitted_by_destination.get(full_dest)
if held is not None:
# Another core reads the same missing file there (Raze and
# EDuke32 both open NAPALM.GRP): one omission names them all.
claimed = held.get("cores")
if (held.get("reason") == "not_found" and claimed and source_emu
and source_emu not in claimed):
held["cores"] = sorted([*claimed, source_emu])
continue
systems = _extra_system_ids(fe)
record_omission(
full_dest, fe, systems[0] if systems else "", "not_found",
@@ -4633,22 +4720,62 @@ def _record_absent(
)
def _record_unplaceable(unplaceable: list[dict], record_omission: Callable[..., None]) -> None:
# What a manifest's roms_directory and emulators_directory carry: the BIOS
# folder's name, for the roms root its place relative to the BIOS folder
# (romfolders roms_root.from_bios), and for emulator folders the places the
# launcher searches, the one a missing folder is created at, and the config
# file whose key moves a folder.
MANIFEST_OUTER_FIELDS = ("bios_folder", "from_bios", "searched", "default", "config")
def _record_unplaceable(
unplaceable: list[dict],
record_omission: Callable[..., None],
config: dict,
profiles: dict[str, dict],
) -> None:
"""Held by the collection, nowhere to put it on this platform.
Said in the manifest, where RomM's DOSBox Pure ROMs were simply absent.
One place read by several cores is one omission naming them all.
"""
# The emulator's own folder is the one the platform installs the name
# it lists into: RetroPie puts amiberry 5.7.2 and 6.3.3, one per board,
# both in $md_inst of its amiberry package.
platform_names = {
str(c) for key in ("cores", "standalone_cores")
if isinstance(config.get(key), list) for c in config[key]
}
def folder(profile: str) -> str:
names = {profile, *map(str, (profiles.get(profile) or {}).get("cores") or [])}
return min(names & platform_names, default=profile)
grouped: dict[str, tuple[dict, list[str]]] = {}
for u in unplaceable:
where = u.get("path") or u.get("name", "")
# The profile key, the name a target's core list and the
# installer's narrowing match: the display name matched none.
core = u.get("profile") or u.get("emulator", "")
if u.get("unplaced"):
key = f"emulator:{folder(core)}:{where}"
elif u.get("root") in OUTER_ROOTS:
key = f"{u['root']}:{where}"
elif u.get("root") == "save":
key = f"save:{save_manifest_dest(u)}"
else:
key = where
held, cores = grouped.setdefault(key, (u, []))
if core and core not in cores:
cores.append(core)
if u.get("required", True) and not held.get("required", True):
grouped[key] = ({**held, "required": True}, cores)
for key, (u, cores) in grouped.items():
record_omission(
# Keyed apart from the BIOS tree's paths, which a file read below
# the save directory can repeat.
f"save:{save_manifest_dest(u)}" if u.get("root") == "save" else where,
u, u.get("system") or "",
# The profile key, the name a target's core list and the
# installer's narrowing match: the display name matched none.
u.get("unplaceable", "no_platform_slug"),
[u.get("profile") or u.get("emulator", "")],
# the save directory or in the emulator's folder can repeat.
key, u, u.get("system") or "",
u.get("unplaceable", "no_platform_slug"), sorted(cores),
)
@@ -4821,12 +4948,22 @@ def generate_manifest(
if cores is None else extra_region_key(file_entry, declared_root(config))
)
save_root = file_entry.get("root") == "save"
# Read in the emulator's own folder, outside the BIOS tree: dest is
# the path the emulator reads, relative to that folder.
outside = bool(file_entry.get("unplaced"))
if outside:
dest_fields: dict = {"dest": sanitize_pack_path(file_entry.get("path") or ""),
"root": "emulator"}
elif file_entry.get("root") in OUTER_ROOTS:
dest_fields = {"dest": sanitize_pack_path(
file_entry.get("destination") or file_entry.get("path") or ""
), "root": file_entry["root"]}
elif save_root:
dest_fields = {"dest": save_manifest_dest(file_entry), **save_entry_fields(file_entry)}
else:
dest_fields = {"dest": manifest_destination(full_destination)}
omitted_by_destination[full_destination] = {
"dest": (
save_manifest_dest(file_entry) if save_root
else manifest_destination(full_destination)
),
**(save_entry_fields(file_entry) if save_root else {}),
**dest_fields,
"name": str(file_entry.get("name") or ""),
"system": system_id,
"required": bool(file_entry.get("required", True)),
@@ -5064,7 +5201,7 @@ def generate_manifest(
)
else:
core_files = []
_record_unplaceable(unplaceable, record_omission)
_record_unplaceable(unplaceable, record_omission, config, emu_profiles)
_record_absent(
absent, config, base_dest, region_drops, required_only,
seen_destinations, omitted_by_destination, record_omission,
@@ -5171,6 +5308,9 @@ def generate_manifest(
omitted_by_destination.values(), key=lambda entry: entry["dest"]
),
"core_names": core_names,
**({"downloaded_cores": downloaded} if (downloaded := _downloaded_cores(
config, emu_profiles, core_names, platforms_dir,
)) else {}),
"system_cores": system_cores,
# The names the platform itself gives its systems (Batocera's psx),
# which --system accepts beside the ids.
@@ -5180,6 +5320,15 @@ def generate_manifest(
}
if data_directories:
result["data_directories"] = data_directories
for root in OUTER_ROOTS:
layout = outer_layout(config, root) or {}
if layout and any(entry.get("root") == root
for entry in [*manifest_files, *result["omitted_files"]]):
# Where the installer finds the folder: relative to the folder
# holding the BIOS folder, which the platform names bios_folder.
result[f"{root}_directory"] = {
key: layout[key] for key in MANIFEST_OUTER_FIELDS if key in layout
}
saved = [entry for entry in manifest_files if entry.get("root") == "save"]
if saved:
layout = save_layout(config)
@@ -5316,6 +5465,39 @@ def _manifest_data_directories(
return trees
def _downloaded_cores(
config: dict,
profiles: dict[str, dict],
core_names: dict[str, list[str]],
platforms_dir: str,
) -> dict[str, list[str]]:
"""The cores only the platform's RetroArch downloader brings, each with
the boards, aliases included, whose downloader offers it.
A target list names the board's standalone amiberry and the buildbot's
amiberry core alike, so the installer cannot tell from the name whether
the board reaches the core. `resolve_platform_cores` decides it, and
`install.py --target` reads its answer here.
"""
reached = {
profile: names
for profile, names in downloaded_only_profiles(config, profiles).items()
if profile in core_names
}
if not reached:
return {}
platform = str(config.get("_key") or "")
boards: dict[str, list[str]] = {profile: [] for profile in reached}
for target in list_available_targets(platform, platforms_dir):
cores = load_target_config(platform, target["name"], platforms_dir)
if cores is None:
continue
for profile, names in reached.items():
if downloadable_on(cores, names):
boards[profile] += [target["name"], *target.get("aliases", [])]
return {profile: sorted(names) for profile, names in sorted(boards.items())}
def _manifest_core_index(
config: dict,
profiles: dict[str, dict],
@@ -5326,14 +5508,15 @@ def _manifest_core_index(
A target list, or the cores found installed, names cores the way the
buildbot does, and a file names the profile that reads it: `core_names`
gives each profile the other names it goes by. `system_cores` gives each
platform system the cores whose presence keeps it, as
gives each profile the other names it goes by, and names every core the
platform runs, one that reads no file included: a core found installed
that the list does not name is one it has no profile for. `system_cores`
gives each platform system the cores whose presence keeps it, as
`filter_systems_by_target` decides. A system absent from it is kept
whatever is available.
"""
reach = system_core_reach(
config.get("systems", {}), profiles, _platform_cores(config, profiles)
)
runs = _platform_cores(config, profiles)
reach = system_core_reach(config.get("systems", {}), profiles, runs)
system_cores = {
sys_id: sorted(cores) for sys_id, cores in sorted(reach.items())
if cores is not None
@@ -5344,7 +5527,8 @@ def _manifest_core_index(
for c in copy.get("cores") or []
}
named |= {c for cores in system_cores.values() for c in cores}
aliases: dict[str, set[str]] = {}
named |= set(runs)
aliases: dict[str, set[str]] = {profile: set() for profile in named & set(runs)}
for name, claimants in upstream_profile_index(profiles, include_aliases=True).items():
for profile in claimants & named:
if name != profile:
@@ -5772,6 +5956,7 @@ from packextras import ( # noqa: E402,F401
extras_for_systems as _extras_for_systems,
platform_region_groups,
emulator_region_drops,
emulator_required_spares,
_emulator_region_group,
expand_agnostic_entries,
undeclared_report,
+125 -43
View File
@@ -1116,7 +1116,7 @@ def _platform_export_rows(
),
"required": bool(entry.get("required", True)),
"region": _json_text(entry.get("region")),
"variant_group": _json_text(entry.get("variant_group")),
"selection_slot": _json_text(entry.get("selection_slot")),
"size": entry.get("size"),
"sha1": _json_text(entry.get("sha1")),
"sha256": _json_text(entry.get("sha256")),
@@ -1174,7 +1174,7 @@ def build_emulator_gap_report(
unique_profiles = {
k: v
for k, v in profiles.items()
if v.get("type") not in ("alias", "test")
if v.get("type") != "alias" and v.get("role") != "test"
}
from common import merged_standalone_set, platform_declarations
from validation import case_folded_slots
@@ -1306,7 +1306,7 @@ def _cross_reference_export_rows(coverages: dict, profiles: dict) -> list[dict]:
unique = {
key: value
for key, value in profiles.items()
if value.get("type") not in ("alias", "test")
if value.get("type") != "alias" and value.get("role") != "test"
}
rows: list[dict] = []
for platform_id, coverage in sorted(coverages.items()):
@@ -1372,7 +1372,7 @@ def _write_sqlite_export(
CREATE TABLE platform_files (
platform_id TEXT NOT NULL, system TEXT NOT NULL, name TEXT NOT NULL,
destination TEXT NOT NULL, required INTEGER NOT NULL,
region TEXT, variant_group TEXT, size INTEGER,
region TEXT, selection_slot TEXT, size INTEGER,
sha1 TEXT, sha256 TEXT, md5 TEXT, crc32 TEXT,
status TEXT, category TEXT, collection_path TEXT, resolved_sha1 TEXT,
zipped_file TEXT, hash_match_mandatory INTEGER, mandatory_declared INTEGER,
@@ -1455,7 +1455,7 @@ def _write_sqlite_export(
connection.executemany(
"INSERT INTO platform_files VALUES "
"(:platform_id, :system, :name, :destination, :required, :region, "
":variant_group, :size, :sha1, :sha256, :md5, :crc32, :status, :category, "
":selection_slot, :size, :sha1, :sha256, :md5, :crc32, :status, :category, "
":collection_path, :resolved_sha1, :zipped_file, :hash_match_mandatory, "
":mandatory_declared, :curated, :shared_group, :mirror_of, :zip_digest)",
platform_files,
@@ -1699,7 +1699,7 @@ def generate_data_exports(
_csv_document(
[
"platform_id", "platform", "system", "name", "destination",
"required", "region", "variant_group", "size", "sha1",
"required", "region", "selection_slot", "size", "sha1",
"sha256", "md5", "crc32", "status", "category",
"collection_path", "resolved_sha1", *ENTRY_EXPORT_FIELDS,
],
@@ -1922,7 +1922,7 @@ def system_index_items(db: dict, coverages: dict, profiles: dict) -> list[dict]:
if sha1:
held.setdefault(str(detail.get("system", "")), set()).add(sha1)
for profile in profiles.values():
if profile.get("type") in ("alias", "test"):
if profile.get("type") == "alias" or profile.get("role") == "test":
continue
own = [str(s) for s in profile.get("systems") or []]
for entry in profile.get("files") or []:
@@ -1973,7 +1973,7 @@ def _first_file_place(
return parts[1], _site_path(f"systems/{page}.md#sha1-{sha1[:12]}")
for key in sorted(profiles):
profile = profiles[key]
if profile.get("type") not in ("alias", "test") and sys_id in (profile.get("systems") or []):
if profile.get("type") != "alias" and profile.get("role") != "test" and sys_id in (profile.get("systems") or []):
return "", _site_path(f"emulators/{key}.md")
return "", None
@@ -1988,7 +1988,7 @@ def core_index_items(profiles: dict) -> list[dict]:
"""
from common import preferred_profile, upstream_profile_index
published = {name for name, p in profiles.items() if p.get("type") not in ("alias", "test")}
published = {name for name, p in profiles.items() if p.get("type") != "alias" and p.get("role") != "test"}
index = upstream_profile_index(profiles)
for name, profile in profiles.items():
target = str(profile.get("alias_of") or "")
@@ -3602,7 +3602,7 @@ def _emulator_named_link(key: str, prefix: str = "") -> str:
profile with no page (an alias, a test) is named only."""
profile = _profiles_on_disk().get(key, {})
label = _escape_tags(str(profile.get("emulator") or key))
if profile.get("type") in ("alias", "test"):
if profile.get("type") == "alias" or profile.get("role") == "test":
return label
return f"[{label}]({prefix}emulators/{key}.md)"
@@ -4069,7 +4069,10 @@ def _category_evidence_md(evidence: dict, launched: list[str] | None = None) ->
text += ", also named by " + ", ".join(bare)
return text
examined = evidence.get("cores_examined") or []
cores = ("cores examined: " + ", ".join(_emulator_link(c, "../") for c in examined)
# A core the site gives no page (a test core) is named, not linked.
with_page = _profiles_with_page(str(siteinputs.current().emulators))
cores = ("cores examined: " + ", ".join(
_emulator_link(c, "../") if c in with_page else c for c in examined)
if examined else "no core examined")
if evidence.get("unsettled"):
return f"No reader found, not settled: {evidence['unsettled']} ({cores})"
@@ -4084,10 +4087,19 @@ def _profiles_by_core_name() -> dict[str, list[str]]:
return _core_names_in(str(siteinputs.current().emulators))
@functools.lru_cache(maxsize=None)
def _profiles_with_page(emulators_dir: str) -> frozenset[str]:
"""The profiles the site writes a page for: no alias, no test core."""
profiles = _profiles_in(emulators_dir, skip_aliases=True)
return frozenset(
k for k, v in profiles.items() if v.get("type") != "alias" and v.get("role") != "test"
)
@functools.lru_cache(maxsize=None)
def _core_names_in(emulators_dir: str) -> dict[str, list[str]]:
profiles = _profiles_in(emulators_dir, skip_aliases=True)
with_page = {k for k, v in profiles.items() if v.get("type") not in ("alias", "test")}
with_page = _profiles_with_page(emulators_dir)
return {
core: sorted(k for k in keys if k in with_page)
for core, keys in upstream_profile_index(profiles).items()
@@ -4920,7 +4932,7 @@ def _data_member_owners(profiles: dict) -> dict[str, str]:
"""
owners: dict[str, str] = {}
for name in sorted(profiles):
if profiles[name].get("type") in ("alias", "test"):
if profiles[name].get("type") == "alias" or profiles[name].get("role") == "test":
continue
for tree in page_data_directories(name, profiles):
owners.setdefault(str(tree.get("ref", "")), name)
@@ -5145,7 +5157,7 @@ def file_lookup_records(
**({"sha1": archive} if archive else {}),
})
for key, profile in sorted(profiles.items()):
if profile.get("type") in ("alias", "test"):
if profile.get("type") == "alias" or profile.get("role") == "test":
continue
entries = profile.get("files") or []
own_systems = [str(s) for s in profile.get("systems") or []]
@@ -5396,11 +5408,11 @@ def _folder_emulator(folder: str) -> tuple[str, str]:
named after, by its profile key or its name, else the folder and "".
"""
profiles = _profiles_on_disk()
if folder in profiles and profiles[folder].get("type") not in ("alias", "test"):
if folder in profiles and profiles[folder].get("type") != "alias" and profiles[folder].get("role") != "test":
return str(profiles[folder].get("emulator") or folder), folder
for key in sorted(profiles):
profile = profiles[key]
if profile.get("type") in ("alias", "test"):
if profile.get("type") == "alias" or profile.get("role") == "test":
continue
names = {str(profile.get(field) or "").casefold() for field in ("emulator", "display_name")}
if folder.casefold() in names:
@@ -6587,11 +6599,16 @@ START_WITH_NAMES = 6
def _region_order() -> dict[str, int]:
"""The regions a start line lists, the territories first. A file read
for every region is a region-free stand-in for the BIOS of each one:
the PlayStation section opened on psxonpsp660.bin, which one emulator
requires among the BIOS it accepts, above the scph files most read."""
from region import REGION_TREE, WORLD # noqa: PLC0415
ordered = [WORLD]
ordered = []
for parent, members in REGION_TREE.items():
ordered += [parent, *sorted(members)]
ordered.append(WORLD)
return {region: index for index, region in enumerate(ordered)}
@@ -7857,7 +7874,7 @@ def emulator_initial(display: str) -> str:
def generate_emulators_index(profiles: dict) -> str:
unique = {
k: v for k, v in profiles.items()
if v.get("type") not in ("alias", "test")
if v.get("type") != "alias" and v.get("role") != "test"
}
aliases = {k: v for k, v in profiles.items() if v.get("type") == "alias"}
@@ -8660,7 +8677,7 @@ def emulator_romset(profile: dict) -> str:
1.38.1 on MAME 0.289), else the version of a MAME build or of a
FinalBurn core, whose sets follow its own release.
"""
if profile.get("type") in ("alias", "launcher", "test"):
if profile.get("type") in ("alias", "launcher") or profile.get("role") == "test":
return ""
emulator = str(profile.get("emulator", ""))
version = str(profile.get("core_version") or "")
@@ -8939,7 +8956,7 @@ def emulator_install_route(name: str, profile: dict) -> tuple[str, list[str], tu
the systems both run on: RetroArch's list names AetherSX2, and the
command filled RetroArch's system folder, which the app never reads.
"""
if profile.get("type") in ("alias", "launcher", "test"):
if profile.get("type") in ("alias", "launcher") or profile.get("role") == "test":
return "", [], ()
if not [f for f in profile.get("files") or [] if isinstance(f, dict) and not comes_with_app(f)]:
return "", [], ()
@@ -9517,18 +9534,65 @@ def _emulator_slots(files: list[dict]) -> list[list[dict]]:
return [group for group in slots.values() if not all(comes_with_app(f) for f in group)]
def _emulator_file_summary(files: list[dict]) -> list[str]:
def _set_names(value: object) -> list[str]:
"""The sets an entry belongs to: group takes a name, a list or a map."""
if isinstance(value, str):
return [value]
if isinstance(value, (list, dict)):
return [str(name) for name in value]
return []
def _requirements(files: list[dict], groups: dict | None) -> list[list[dict]]:
"""Each thing the emulator needs: the members of a required set, any one
of which fills it (DuckStation's ps-bios, 105 images), or a required
file of its own. load_emulator_profiles marks every member of a
required set required, so counted per file DuckStation read "105
required" for one BIOS."""
required_sets = {name for name, spec in (groups or {}).items()
if isinstance(spec, dict) and spec.get("required")}
sets: dict[str, list[dict]] = {}
alone: list[list[dict]] = []
for f in files:
if f.get("required") is not True or comes_with_app(f):
continue
names = [name for name in _set_names(f.get("group")) if name in required_sets]
if names:
for name in names:
sets.setdefault(name, []).append(f)
else:
alone.append([f])
return [*sets.values(), *alone]
def _regional_set(requirements: list[list[dict]]) -> list[dict]:
"""The one requirement, a set whose members the code picks by the game's
region, or [] when there is no such single choice."""
if len(requirements) != 1 or len(requirements[0]) < 2:
return []
members = requirements[0]
if not all(f.get("region") for f in members):
return []
return members if len({_region_words(f["region"]) for f in members}) >= 2 else []
def _emulator_file_summary(files: list[dict], groups: dict | None = None) -> list[str]:
"""What the emulator reads, above its file table, and its categories.
Counted by slot, as verify --emulator and the gaps page count: raze
lists every GRP its code accepts under one name, and holding one fills
the slot. Counted per entry, the page announced 46 missing where 12
slots are empty. The files the app ships are counted apart: NetherSX2
said "4 required" for one BIOS and three fonts in its package.
said "4 required" for one BIOS and three fonts in its package. A
required set counts once (`groups`).
"""
slots = _emulator_slots(files)
shipped = sum(1 for f in files if comes_with_app(f))
req_count = sum(1 for group in slots if any(f.get("required") for f in group))
requirements = _requirements(files, groups)
in_sets = {id(f) for members in requirements for f in members}
req_slots = sum(1 for group in slots if any(id(f) in in_sets for f in group))
req_count = len(requirements)
optional = len(slots) - req_slots
hle_count = sum(1 for group in slots if any(f.get("hle_fallback") for f in group))
# Sentences, one number format: the line read "1,563 files | 164
@@ -9536,13 +9600,18 @@ def _emulator_file_summary(files: list[dict]) -> list[str]:
total = f"**{_count(len(slots), 'file')}**"
if sum(len(group) for group in slots) != len(slots):
total += f" ({sum(len(group) for group in slots):,} accepted versions)"
line = f"{total}: {req_count:,} required, {len(slots) - req_count:,} optional."
if _regional_choice([f for f in files if f.get("required") is True and not comes_with_app(f)]):
line = f"{total}: {req_count:,} required, {optional:,} optional."
if any(len(members) > 1 for members in requirements):
line += " Any one file of a required set fills it."
regional = _regional_set(requirements) or (
[f for members in requirements for f in members]
if _regional_choice([f for members in requirements for f in members]) else [])
if regional:
# DuckStation takes the BIOS of the game's region: "3 required"
# read as "get all three".
line = (f"{total}. One of {req_count:,} is required, for the games' region. "
f"{len(slots) - req_count:,} {'is' if len(slots) - req_count == 1 else 'are'} "
"optional.")
line = f"{total}. One of {len(regional):,} is required, for the games' region."
if optional:
line += f" {optional:,} {'is' if optional == 1 else 'are'} optional."
if hle_count:
line += f" {hle_count:,} {'has' if hle_count == 1 else 'have'} an HLE fallback."
if shipped:
@@ -9873,7 +9942,7 @@ def plan_emulator_pages(profiles: dict,
``available`` is the test the pages use for a held file."""
stems = set()
for name, profile in sorted(profiles.items()):
if profile.get("type") in ("alias", "test"):
if profile.get("type") == "alias" or profile.get("role") == "test":
continue
files = profile.get("files") or []
anchors = emulator_file_anchors(files)
@@ -10023,28 +10092,41 @@ def _emulator_intro(emu_name: str, profile: dict, href: str = "", name: str = ""
names.append(label)
# What the app installs itself is no file for the reader to get.
files = [f for f in profile.get("files") or [] if isinstance(f, dict) and not comes_with_app(f)]
required = [f for f in files if f.get("required") is True]
requirements = _requirements(files, profile.get("groups"))
required = [f for members in requirements for f in members]
sentences = []
if names:
shown = names if len(names) <= 4 else names[:3] + [f"{len(names) - 3} more systems"]
sentences.append(f"{emu_name} emulates {_said_as_list(shown)}.")
by_region = _regional_choice(required)
if by_region:
# DuckStation needs the BIOS of the game's region, not all three: the
# page said "3 of them required" and read as "get all of these".
others = len(files) - len(required)
regional = _regional_set(requirements)
by_region = "" if regional else _regional_choice(required)
others = len(files) - len(required)
if regional:
# DuckStation needs one BIOS of the game's region and accepts 105
# images: every member of its ps-bios set is marked required, and
# the page said "105 of them required".
sentences.append(
f"It needs one BIOS for the game's region, among the {len(regional):,} it accepts.")
if others:
sentences.append(
f"The {_count(others, 'other file')} {'is' if others == 1 else 'are'} optional.")
elif by_region:
# Three required BIOS of three regions: the page said "3 of them
# required" and read as "get all of these".
sentences.append(f"It picks the BIOS for the game's region: {by_region}.")
if others:
sentences.append(
f"The {_count(others, 'other file')} {'is' if others == 1 else 'are'} optional.")
elif files:
sentences.append(
f"It reads {_count(len(files), 'file')}, {len(required):,} of them required: "
f"It reads {_count(len(files), 'file')}, {len(requirements):,} of them required: "
"without a required file it does not start games."
if required else
if requirements else
f"It reads {_count(len(files), 'file')}, none of them required: it starts "
"games without them."
)
if any(len(members) > 1 for members in requirements):
sentences.append("Any one file of a required set fills it.")
if required and len(names) > 1:
sentences.append("A required file is needed only by the machines that use it.")
if profile.get("found_by") == "content":
@@ -10237,7 +10319,7 @@ def emulator_pages(
_file_available = _availability_check(db, data_names, name)
lines.extend(["## Files", ""])
lines.extend(_emulator_file_summary(files))
lines.extend(_emulator_file_summary(files, profile.get("groups")))
held = _emulator_held_line(files, _file_available)
anchors = emulator_file_anchors(files)
@@ -11046,7 +11128,7 @@ def generate_gap_analysis(
unique_profiles = {
k: v
for k, v in profiles.items()
if v.get("type") not in ("alias", "test")
if v.get("type") != "alias" and v.get("role") != "test"
}
relevant_set: set[str] = set()
for _name, cov in coverages.items():
@@ -11139,7 +11221,7 @@ def generate_gap_analysis(
unique_profiles = {
k: v
for k, v in profiles.items()
if v.get("type") not in ("alias", "test")
if v.get("type") != "alias" and v.get("role") != "test"
}
for pname in coverages:
config = coverages[pname]["config"]
@@ -11547,7 +11629,7 @@ def generate_cross_reference(
``moved`` collects one page per platform with its core table.
"""
unique = {
k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test")
k: v for k, v in profiles.items() if v.get("type") != "alias" and v.get("role") != "test"
}
total_cores = len(unique)
@@ -12591,7 +12673,7 @@ def _generate(args: argparse.Namespace) -> None:
_DATA_MEMBERS.update(data_directory_members())
_DATA_REVISIONS.update(data_directory_revisions())
for key in sorted(profiles):
if profiles[key].get("type") in ("alias", "test"):
if profiles[key].get("type") == "alias" or profiles[key].get("role") == "test":
continue
display = str(profiles[key].get("emulator", key))
for tree in page_data_directories(key, profiles):
@@ -12607,7 +12689,7 @@ def _generate(args: argparse.Namespace) -> None:
public_profiles = {
name: profile
for name, profile in profiles.items()
if profile.get("type") not in ("alias", "test")
if profile.get("type") != "alias" and profile.get("role") != "test"
}
(public / "api" / "v1" / "emulators").mkdir(parents=True, exist_ok=True)
for name, profile in public_profiles.items():
+675 -52
View File
@@ -17,7 +17,7 @@ under the launcher's name, a file the core accepts by content.
from __future__ import annotations
import re
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from common import declared_root, name_match_size_ok, resolve_local_file, sanitize_pack_path
@@ -65,6 +65,7 @@ class LauncherPlace:
anchored: bool
conditional: bool
source_ref: str
passes_to: str = ""
def fold(self, name: str) -> str:
return name.lower() if self.case_insensitive else name
@@ -87,6 +88,103 @@ class LauncherPlace:
)
def setting_tokens(passes_to: str) -> set[str]:
"""The words of a record's ``passes_to``, as an emulator setting is spelled.
"command line -romfile4p" hands the file to sdltrs's romfile4p option,
"settings.bml NeoGeoPocket/Firmware/BIOS.World" to an ares setting.
"""
return {
word.strip("()\"',;").lstrip("-")
for word in passes_to.split()
} - {""}
def suffixed_stem(name: str) -> str | None:
"""The file name a launcher name carries before an extension it appended.
ROCKNIX's Amiberry config passes kick34005.A500.rom, the name Amiberry
gives Kickstart 1.3 (amiberry_whdbooter.cpp:370) followed by ".rom". A
stem that is no file name itself (scph1001 of scph1001.bin) is none.
"""
base = name.rsplit("/", 1)[-1]
stem = base.rsplit(".", 1)[0]
return stem if "." in stem else None
_MODEL = re.compile(r"\b([A-Z]{2,5})-?(\d{3,6}[A-Z]?)\b")
def catalogued_model_files(name: str, db: dict) -> list[dict]:
"""Held files a dump catalogue describes as the model a launcher name spells.
RetroDECK sets LRPS2's pcsx2_bios option to scph10000.bin, a name no code
hashes: the SCPH-10000 is the model whose BIOS it means. Redump and TOSEC
describe two revisions for it. Primaries come before .variants copies,
then the Redump name, which carries the version (ps2-0100j before
ps2-0101j), then the No-Intro and TOSEC names.
"""
stem = name.rsplit("/", 1)[-1].rsplit(".", 1)[0].casefold()
if not re.fullmatch(r"[a-z]{2,5}\d{3,6}[a-z]?", stem):
return []
found = []
for record in db.get("files", {}).values():
catalogues = record.get("provenance") or {}
models = {
(a + b).casefold()
for entry in catalogues.values()
for a, b in _MODEL.findall(str(entry.get("description") or ""))
}
if stem in models:
names = [
str((catalogues.get(source) or {}).get("name") or "")
for source in ("redump", "no-intro", "tosec")
]
found.append(("/.variants/" in str(record.get("path", "")), names, record))
# The core accepts every revision of the model, and neither priority nor
# region separates two primaries. The pick must still be the same on
# every run, so it goes to the original revision: the Redump name carries
# the version, and ps2-0100j sorts before ps2-0101j.
return [record for *_key, record in sorted(found, key=lambda f: (f[0], f[1]))]
def name_settings(record: dict, names: list[str]) -> dict[str, str]:
"""The setting each name of a record is written to.
A record naming several files of one config spells passes_to as
describe_keys does (shell_launchers.py), "atari800.cfg ROM_OS_A_PAL,
ROM_5200_A, ...", one key per name and in the order of the names. When
the counts differ, each name keeps the whole description.
"""
passes_to = str(record.get("passes_to") or "")
head, _, rest = passes_to.partition(" ")
keys = [
key.strip()
for key in re.sub(r"\[[^\]]*\]", ",", rest).split(",")
if key.strip()
]
if len(names) > 1 and len(keys) == len(names) and all(" " not in k for k in keys):
return {name: f"{head} {key}" for name, key in zip(names, keys)}
return {name: passes_to for name in names}
def setting_readers(passes_to: str, profiles: dict, keys: Iterable[str]) -> list[str]:
"""The profiles with an entry read from the setting a launcher writes."""
if not passes_to:
return []
return sorted(
key for key in keys
if (profile := profiles.get(key)) is not None
and any(set_by(entry, passes_to) for entry in profile.get("files") or [])
)
def set_by(entry: dict, passes_to: str) -> bool:
"""Whether a launcher hands its file to the setting a profile entry is read from."""
key = str(entry.get("config_key") or "")
return bool(key) and key in setting_tokens(passes_to)
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("/")
@@ -190,15 +288,21 @@ def launcher_places(config: dict) -> list[LauncherPlace]:
"conditional": bool(record.get("depends_on_option")),
"source_ref": str(record.get("source_ref", "")),
}
settings = name_settings(record, names)
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))
places.append(LauncherPlace(
paths=ordered, passes_to=str(record.get("passes_to") or ""), **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)
places.extend(
LauncherPlace(paths=paths_of(name), passes_to=settings[name], **common)
for name in names
)
return places
@@ -211,6 +315,31 @@ BIOS_ROOT_VARIABLES = ("$biosdir", "$home/RetroPie/BIOS", "${biosPath}", "$bios_
# The kinds that hand an emulator a whole directory.
HANDED_KINDS = ("links", "passes_path")
# How a launcher record says the platform starts the emulator in a directory
# (retropie_launchers._working_directory), and the handle a profile gives
# that directory in standalone_directories: the call its code reads it with.
WORKING_DIRECTORY_PASSED = "working directory of "
WORKING_DIRECTORY = "getcwd"
# The handle a profile declares when its program reads its files beside its
# own executable: a launcher record executing one of the profile's files
# places the folder holding that file where the launcher runs it from.
EXECUTABLE_DIRECTORY = "executable directory"
# The trees a placed folder belongs to: the BIOS tree every destination is
# relative to, and the roms directory of a platform whose registry names it.
BIOS_TREE = ""
ROMS_TREE = "roms"
# The emulator folders of a platform whose registry describes them.
EMULATORS_TREE = "emulators"
# The frontend's configs folder of a platform whose file records it.
CONFIGS_TREE = "configs"
# The tree a platform's list installs into when it starts above the BIOS
# folder: a plain dest, with no prefix.
INSTALL_TREE = "install"
# A folder of neither tree, which no pack and no installer can fill.
OUTSIDE_TREE = "outside"
Place = tuple[str, str]
_HOME = re.compile(r"^(?:\$\{HOME\}|\$HOME|\$home|~)(?=/|$)")
# Flatpak sets XDG_DATA_HOME to ~/.var/app/<id>/data inside the sandbox.
_FLATPAK_DATA = re.compile(r"^~/\.var/app/[^/]+/data(?=/|$)")
@@ -258,6 +387,55 @@ class HandedDirectory:
directory: str
handles: frozenset[str]
source_ref: str
roms_place: Place | None = None
# The platform systems, by our ids, the record applies to. Empty when
# the launcher hands the directory whatever the system.
systems: frozenset[str] = frozenset()
# Whether the platform's own list declares files in the folder.
holds_declared: bool = False
def place(self) -> Place | None:
"""The folder as a place, None when it lies outside both roots."""
if self.folder is not None:
return (BIOS_TREE, self.folder)
return self.roms_place
# The placeholder a launcher record writes for the system it is launched
# for, as Recalbox's extractor spells it: Recalbox passes XRoar `-rompath`
# `os.path.join(BIOS, args.system)`.
SYSTEM_PLACEHOLDER = "<system>"
def native_systems(config: dict) -> dict[str, str]:
"""The platform's own system names, each to our id for it.
A system states its name (native_id) or its files do (native_system).
Without either, our id is the platform's name.
"""
found: dict[str, str] = {}
for slug, system in (config.get("systems") or {}).items():
names = {str(system["native_id"])} if system.get("native_id") else {
str(f["native_system"]) for f in system.get("files") or [] if f.get("native_system")
}
for name in names or {slug}:
found.setdefault(name, slug)
return found
def _listed_directories(raw: str) -> list[str] | None:
"""The directories of a colon-separated search path, None for one directory.
XRoar's rompath is such a list (RetroDECK writes four directories), each
entry a path or a variable spelling one. A Windows drive letter or a
backslash means a single path.
"""
if ":" not in raw or "\\" in raw or re.match(r"^[A-Za-z]:", raw):
return None
parts = [p.strip() for p in raw.split(":")]
if all(p and p[0] in "/$~" and not re.search(r"[\s,;]", p) for p in parts):
return parts
return None
def handed_directories(config: dict) -> list[HandedDirectory]:
@@ -265,9 +443,14 @@ def handed_directories(config: dict) -> list[HandedDirectory]:
A record naming files (gpsp's gba_bios.bin) or one slot of a directory
(RetroDECK's GC/EUR) hands over no whole directory, and a search_dir
holding several directories names no single one.
describing several directories in prose names no single one. A search
path (XRoar's rompath) gives each of its directories, in order. A record
naming systems applies to those systems only, and one whose search_dir
holds <system> gives one directory per system the platform declares.
"""
base = str(config.get("base_destination") or "bios")
natives = native_systems(config)
declared = {d.lower() for d in declared_destinations(config)}
found: list[HandedDirectory] = []
for record in config.get("launchers") or []:
if record.get("build") != "standalone" or record.get("kind") not in HANDED_KINDS:
@@ -275,59 +458,386 @@ def handed_directories(config: dict) -> list[HandedDirectory]:
if record.get("names") or record.get("slot"):
continue
raw = str(record.get("search_dir") or "").strip()
if not raw or re.search(r"[\s,:;]", raw):
if not raw:
continue
directory = normalize_handle(raw)
handles = frozenset(
handle
for token in str(record.get("passes_to") or "").split()
if (handle := normalize_handle(token)) and handle != directory
)
for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")):
found.append(HandedDirectory(
emulator=emulator,
folder=_folder_in_bios(raw, base),
directory=directory,
handles=handles,
source_ref=str(record.get("source_ref", "")),
))
named = frozenset(natives.get(str(s), str(s)) for s in record.get("systems") or [])
if SYSTEM_PLACEHOLDER in raw:
variants = [
(raw.replace(SYSTEM_PLACEHOLDER, native), frozenset({slug}))
for native, slug in sorted(natives.items())
if not named or slug in named
]
else:
variants = [(raw, named)]
passes_to = str(record.get("passes_to") or "")
for spelled, systems in variants:
parts = _listed_directories(spelled) or [spelled]
if any(re.search(r"[\s,:;]", part) for part in parts):
continue
for part in parts:
directory = normalize_handle(part)
if passes_to.startswith(WORKING_DIRECTORY_PASSED):
# The program runs from the directory: what its code
# reads through getcwd(), or relative to it, is below it.
handles = frozenset({WORKING_DIRECTORY})
else:
handles = frozenset(
handle
for token in passes_to.split()
if (handle := normalize_handle(token)) and handle != directory
)
folder = _folder_in_bios(part, base)
for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")):
found.append(HandedDirectory(
emulator=emulator,
folder=folder,
directory=directory,
handles=handles,
source_ref=str(record.get("source_ref", "")),
roms_place=None if folder is not None else (
_roms_place(config, folder_in_roms(part, config))
or _configs_place(folder_in_configs(part, config))
or _emulators_place(folder_in_emulators(part, config))
),
systems=systems,
holds_declared=bool(folder) and any(
d.startswith(f"{(folder or '').lower()}/") for d in declared
),
))
return found
def standalone_root(
names: Iterable[str], declared: Iterable[str], handed: Iterable[HandedDirectory]
) -> str | None:
"""The folder of the BIOS tree a platform gives a standalone build's layout.
def _configs_place(path: str | None) -> Place | None:
"""A folder below the configs root, as a place of the configs tree."""
return None if path is None else (CONFIGS_TREE, path)
``declared`` are the directories the build resolves ``standalone_path``
against, as its profile names them (``standalone_directories``). A
record handing a folder of the BIOS tree under one of them gives it
directly: RetroPie points Flycast's ~/.local/share/flycast at bios/dc. A
record handing a directory outside the tree under one of them is
followed through a link to the tree: ROCKNIX's patched Flycast uses
/storage/roms/dreamcast/data, which its start script links to bios/dc.
None when nothing gives a folder, or when two records give two: the
layout is then the profile's, relative to the BIOS root.
def _emulators_place(path: str | None) -> Place | None:
"""A folder an emulator key names, as a place of the emulators tree."""
return None if path is None else (EMULATORS_TREE, path)
def _roms_place(config: dict, path: str | None) -> Place | None:
"""A path below the roms root as a place: in the install tree when the
platform installs above its BIOS folder (AmberELEC), else the roms tree."""
if path is None:
return None
from common import ROMS_ROOT, outer_layout # noqa: PLC0415
layout = outer_layout(config, ROMS_ROOT) or {}
if "install_prefix" in layout:
prefix = layout["install_prefix"]
return (INSTALL_TREE, sanitize_pack_path(f"{prefix}/{path}" if prefix else path))
return (ROMS_TREE, path)
def folder_in_configs(directory: str, config: dict) -> str | None:
"""A directory relative to the frontend's configs folder, None outside it.
Only a platform whose file records its configs root has one
(``configs_root.path``): Batocera's /userdata/system/configs, where its
generators start shadPS4 from configs/shadps4.
"""
from common import CONFIGS_ROOT # noqa: PLC0415
return folder_in_root(directory, config, CONFIGS_ROOT)
def folder_in_roms(directory: str, config: dict) -> str | None:
"""A directory relative to the platform's roms directory, None outside it.
Only a platform whose rom folders file records its roms root has one
(``roms_root.path``): ROCKNIX's /storage/roms, which holds its BIOS
folder and the folders its launchers run programs from.
"""
from common import ROMS_ROOT # noqa: PLC0415
return folder_in_root(directory, config, ROMS_ROOT)
def folder_in_root(directory: str, config: dict, root: str) -> str | None:
"""A directory relative to an outer root's path, None outside it."""
from common import outer_layout # noqa: PLC0415
roms = str((outer_layout(config, root) or {}).get("path") or "").rstrip("/")
directory = directory.strip().rstrip("/")
if not roms or not directory.startswith(roms + "/"):
return None
rest = directory[len(roms) + 1:]
return None if re.search(r"[\s<>$:;]", rest) else rest
def folder_in_emulators(directory: str, config: dict) -> str | None:
"""The emulator folder a launcher names by its key, None for any other.
Only a platform whose registry describes its emulator folders has one
(``emulators_directory.launcher_spelling``): RetroBat's generators take
each from AppConfig.GetFullPath("<key>"), a folder of the RetroBat
install named after the key.
"""
spelling = str((config.get("emulators_directory") or {}).get("launcher_spelling") or "")
if "{key}" not in spelling:
return None
# The key, then any folder below it: Citron's generator creates
# <folder>/user, its portable data folder.
pattern = re.escape(spelling).replace(re.escape("{key}"), r"([\w.-]+)") + r"((?:/[\w.-]+)*)"
found = re.fullmatch(pattern, directory.strip())
return found.group(1) + found.group(2) if found else None
@dataclass(frozen=True)
class ExecutedFile:
"""A file a platform's launcher executes, and where it lies.
``place`` is (root, path): the BIOS tree or the roms directory, None
when the file lies outside both (RetroBat's emulator folders).
"""
emulator: str
name: str
place: Place | None
source_ref: str
def executed_files(config: dict) -> list[ExecutedFile]:
"""Every file a platform's launchers execute, from records passing it to exec."""
base = str(config.get("base_destination") or "bios")
found: list[ExecutedFile] = []
for record in config.get("launchers") or []:
if record.get("kind") not in ("fixed_name", "names_searched"):
continue
if str(record.get("passes_to") or "").strip() != "exec":
continue
raw = str(record.get("search_dir") or "").strip()
directories = _directories(record, base)
roms = folder_in_roms(raw, config) if raw and not directories else None
emulator_folder = folder_in_emulators(raw, config) if raw and not directories else None
# A program file needs no extension (pico8, vox): the names are
# taken as written, placeholders left out.
for name in (str(n).strip() for n in record.get("names") or []):
if not name or re.search(r"[<>{}]", name):
continue
if directories:
place: Place | None = (BIOS_TREE, sanitize_pack_path(
f"{directories[0]}/{name}" if directories[0] else name
))
elif roms is not None:
place = _roms_place(config, sanitize_pack_path(f"{roms}/{name}" if roms else name))
elif emulator_folder is not None:
place = (EMULATORS_TREE, sanitize_pack_path(f"{emulator_folder}/{name}"))
else:
place = None
for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")):
found.append(ExecutedFile(
emulator, name.rsplit("/", 1)[-1], place, str(record.get("source_ref", ""))
))
return found
def _below(handle: str, directory: str) -> str | None:
"""``handle`` relative to ``directory``, None when it is not inside it."""
if handle == directory:
return ""
if handle.startswith(directory + "/"):
return handle[len(directory) + 1:]
return None
def _join(*parts: str) -> str:
return "/".join(p for p in parts if p)
def _record_places(
record: HandedDirectory, inner: str, records: list[HandedDirectory]
) -> list[tuple[str, Place]]:
"""(subfolder, place) for the directory ``inner`` below what a record hands.
A record handing a folder of a tree gives it directly. One handing a
directory outside the trees is followed through the links that put a
folder at that directory, below it or above it.
"""
if record.folder is not None:
return [("", (BIOS_TREE, _join(record.folder, inner)))]
base = _join(record.directory, inner)
found: list[tuple[str, Place]] = []
for link in records:
target = link.place()
if target is None:
continue
for handle in link.handles:
rest = _below(handle, base)
if rest is not None:
found.append((rest, target))
continue
up = _below(base, handle) if inner else None
if up:
found.append(("", (target[0], _join(target[1], up))))
if not found and record.roms_place is not None:
root, folder = record.roms_place
found.append(("", (root, _join(folder, inner))))
return found
def _bind(
records: list[HandedDirectory],
links: list[HandedDirectory],
wanted: set[str],
named: Mapping[str, str],
pools: Mapping[str, str],
) -> dict[str, set[Place]]:
"""The places each subfolder of the layout is given by ``records``.
A record whose handle is a declared directory, or lies below one, gives
that subfolder, and a handle ``named`` gives the one folder it stands
for. One whose handle lies above a declared directory, or is the
environment variable the declared directory is spelled from
($XDG_DATA_HOME/suyu), gives the declared directory where it lands:
RetroPie configures XRoar with --prefix=$md_inst, so
${prefix}/share/xroar/roms is $md_inst/share/xroar/roms. When it lands
in no folder a pack fills, the layout is read outside the trees.
``links`` are the records followed from a directory outside the trees.
A handle in ``pools`` names a list of directories the program searches
one after the other: openMSX's filepool, XRoar's rompath. A file in any
of them is read, so the pool gives one: the directory the platform's own
list already fills (Batocera's bios/openmsx beside bios/Machines), else
the first of the BIOS tree, else the first a pack can fill.
"""
found: dict[str, set[Place]] = {}
pooled: dict[str, list[tuple[bool, Place]]] = {}
for record in records:
for handle in record.handles:
if handle in pools:
for rest, place in _record_places(record, "", links):
pooled.setdefault(_join(pools[handle], rest), []).append(
(record.holds_declared, place)
)
continue
if handle in named:
for rest, place in _record_places(record, "", links):
found.setdefault(_join(named[handle], rest), set()).add(place)
for directory in wanted:
sub = _below(handle, directory)
if sub is not None:
for rest, place in _record_places(record, "", links):
found.setdefault(_join(sub, rest), set()).add(place)
continue
if handle == directory:
continue
inner = next(
(up for spelled in (f"${handle}", handle)
if (up := _below(directory, spelled)) is not None),
None,
)
if inner is None:
continue
places = _record_places(record, inner, links)
for rest, place in places or [("", (OUTSIDE_TREE, ""))]:
found.setdefault(rest, set()).add(place)
for sub, entries in pooled.items():
# The directory the platform's own list fills, else the first of the
# BIOS tree, else the first a pack fills.
filled = [p for held, p in entries if held]
in_bios = [p for _held, p in entries if p[0] == BIOS_TREE]
found.setdefault(sub, set()).add((filled or in_bios or [p for _h, p in entries])[0])
return found
def standalone_bindings(
names: Iterable[str],
declared: Iterable[str],
handed: Iterable[HandedDirectory],
executed: Iterable[ExecutedFile] = (),
layout: Iterable[str] = (),
pools: Mapping[str, str] | None = None,
systems: Iterable[str] = (),
) -> dict[str, Place]:
"""The folders a platform gives a standalone build's layout.
``declared`` are the directories the build resolves its paths against,
as its profile names them (``standalone_directories``), and ``pools``
the settings it reads as search lists, each to the subfolder of the
layout its directories stand for (``standalone_pools``). The result maps
a subfolder of that layout ("" for the whole of it) to the place, a
folder of the BIOS tree or of the roms directory, the emulator reads it
from.
A record handing a folder under a declared directory gives it directly:
RetroPie points Flycast's ~/.local/share/flycast at bios/dc. A record
handing a directory outside the trees is followed through the links that
put a folder at it or below it: ROCKNIX's patched Flycast uses
/storage/roms/dreamcast/data, which its start script links to bios/dc,
and RetroPie starts UAE4All2 in $md_inst, whose kickstarts folder it
links to bios/amiga.
``systems`` are the platform systems, by our ids, an entry is launched
for. A record naming systems replaces, for those systems, what the
records naming none give: RetroBat passes XRoar `-rompath bios/coco` for
coco and bios/dragon for every other system.
A profile whose program reads its files beside its executable declares
EXECUTABLE_DIRECTORY, and ``layout`` holds its paths: a launcher that
executes one of them gives the folder holding it. Batocera runs
pico-8/pico8 from its BIOS folder, ROCKNIX pico8_64 from
/storage/roms/pico-8.
A subfolder two records give two places is left out.
"""
names = set(names)
wanted = {normalize_handle(d) for d in declared}
if not wanted:
return None
pools = {normalize_handle(k): str(v).strip("/") for k, v in (pools or {}).items()}
# "<handle> = <subfolder>" names the code's handle for one folder of the
# layout: Citron's CitronPath::KeysDir is its keys/ folder, and a patch
# that rewrites the handle moves that folder alone.
named: dict[str, str] = {}
wanted: set[str] = set()
for raw in declared:
handle, sep, sub = str(raw).partition(" = ")
if sep:
named[normalize_handle(handle)] = sanitize_pack_path(sub.strip())
else:
wanted.add(normalize_handle(handle))
wanted -= set(pools)
if not wanted and not named and not pools:
return {}
systems = set(systems)
records = [h for h in handed if h.emulator in names]
folders: set[str] = set()
for record in records:
if not record.handles & wanted:
generic = [h for h in records if not h.systems]
specific = [h for h in records if h.systems & systems]
found = _bind(generic, generic, wanted, named, pools)
# A link is followed whatever system it was written for.
found.update(_bind(specific, specific + generic, wanted, named, pools))
if EXECUTABLE_DIRECTORY in wanted:
paths = list(layout)
for run in executed:
if run.emulator not in names:
continue
# A program run from a folder outside both trees (RetroBat's
# emulators/pico8) binds its folder to no place a pack fills.
root, path = run.place or (OUTSIDE_TREE, run.name)
folder = path.rsplit("/", 1)[0] if "/" in path else ""
for entry_path in paths:
if entry_path.rsplit("/", 1)[-1] != run.name:
continue
sub = entry_path.rsplit("/", 1)[0] if "/" in entry_path else ""
found.setdefault(sub, set()).add((root, folder))
return {sub: next(iter(places)) for sub, places in found.items() if len(places) == 1}
def bound_path(path: str, bindings: dict[str, Place]) -> Place | None:
"""Where a platform holds a layout path, None when no binding covers it.
The longest subfolder that contains the path wins: a link of
kickstarts/ places kickstarts/kick13.rom even when the whole layout is
bound elsewhere.
"""
for sub in sorted(bindings, key=len, reverse=True):
if sub and not path.startswith(sub + "/"):
continue
if record.folder is not None:
folders.add(record.folder)
continue
folders.update(
link.folder
for link in records
if link.folder is not None and record.directory in link.handles
)
return next(iter(folders)) if len(folders) == 1 else None
rest = path[len(sub) + 1:] if sub else path
root, folder = bindings[sub]
return (root, f"{folder}/{rest}" if folder else rest)
return None
def held_record(report: dict, db: dict) -> dict | None:
@@ -390,6 +900,8 @@ class _Candidate:
by_content: bool
# Matched by the size the code checks, the entry naming no hash.
by_shape: bool = False
# Matched through the setting the launcher writes or the name it extends.
by_setting_or_stem: bool = False
class LauncherLayer:
@@ -419,12 +931,42 @@ class LauncherLayer:
self._by_name_folded.setdefault(name.lower(), []).extend(sha1s)
self._files = db.get("files", {})
self._owners: dict[tuple[str, bool], list[LauncherPlace]] = {}
self._unoffered: dict[tuple[str, bool], list[tuple[dict, dict]]] = {}
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)
owners = {
key
for emulator in (e.strip() for e in place.emulator.split(","))
for key in self._profiles_named(emulator, profiles, relevant)
}
# RetroDECK names its frontend, retroarch, and the core option it
# writes, pcsx2_bios, names the core.
if not owners:
owners = set(setting_readers(place.passes_to, profiles, relevant))
for key in sorted(owners):
self._owners.setdefault((key, standalone), []).append(place)
# The report never offers a placeholder, which stands for every
# file a scan finds, nor an entry the platform's list settles
# (AmberELEC declares kick34005.A500): the launcher still reads
# its file under its own name.
self._unoffered.setdefault((key, standalone), [
(entry, _placeholder_report(key, profiles[key], entry))
for entry in profiles[key].get("files") or []
])
self._db = db
self._declared_elsewhere: set[tuple[str, bool, str, str]] = set()
def declared_elsewhere(self, emulator: str, standalone: bool, entry: dict) -> None:
"""Record a core entry the platform's list settles under another
name. A launcher opening the entry's own name finds nothing there:
RetroPie lists KS 1.3 as kick34005.A500, and its fs-uae launcher
passes $biosdir/kick13.rom. A name the list holds somewhere is
copied to the launcher's place from that declaration."""
name = str(entry.get("name", ""))
if name.rsplit("/", 1)[-1].lower() in self.declared_names_folded:
return
self._declared_elsewhere.add((emulator, standalone, name, str(entry.get("path") or "")))
@staticmethod
def _profiles_named(name: str, profiles: dict, relevant: set[str]) -> list[str]:
@@ -461,6 +1003,17 @@ class LauncherLayer:
names = own & basenames
if names:
return _Candidate(entry, names, False)
# RetroPie runs sdl2trs with `-romfile4p $biosdir/level4p.rom`: the
# file under that name is the one the romfile4p slot reads.
if set_by(entry, place.passes_to):
return _Candidate(entry, basenames, False, by_setting_or_stem=True)
if place.kind == "fixed_name":
stems = {
base for base in basenames
if (stem := suffixed_stem(base)) is not None and place.fold(stem) in own
}
if stems:
return _Candidate(entry, stems, False, by_setting_or_stem=True)
content = {
place.fold(path.rsplit("/", 1)[-1])
for path in place.paths
@@ -525,13 +1078,32 @@ class LauncherLayer:
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))
offered = {id(entry) for _e, _s, entry, _r in candidates}
for layout, members in self._unoffered.items():
for entry, report in members:
if id(entry) in offered:
continue
if "<" not in str(entry.get("name", "")):
report["in_repo"] = resolve_held(report) is not None
report["unoffered"] = True
if (*layout, str(entry.get("name", "")),
str(entry.get("path") or "")) in self._declared_elsewhere:
report["declared_elsewhere"] = True
by_layout.setdefault(layout, []).append((entry, report))
placed: set[str] = set()
for (emulator, standalone), members in sorted(by_layout.items()):
for place in self.places_for(emulator, standalone):
# An entry the report left out answers only through the
# setting or the extended name: by its own name it was left
# out for a reason (a region variant, a settled duplicate).
# One the platform settles under another name still answers
# by its own: the place reads a name the list does not hold.
matched = [
(candidate, report)
for entry, report in members
if (candidate := self.match(place, entry)) is not None
and (candidate.by_setting_or_stem or not report.get("unoffered")
or report.get("declared_elsewhere"))
]
# A size names one entry only when no other of the core's
# entries takes that size too.
@@ -584,7 +1156,31 @@ class LauncherLayer:
):
return True
if not place.anchored:
continue
# A setting holds a name the core resolves in the directory
# of the entry read from it: LRPS2 joins pcsx2_bios under
# pcsx2/bios.
setting = [
(candidate, report) for candidate, report in matched
if set_by(candidate.entry, place.passes_to)
]
directory = next(
(
str(c.entry.get("path") or "").rpartition("/")[0]
for c, _report in setting
if "/" in str(c.entry.get("path") or "")
),
None,
)
if directory is None:
continue
path = f"{directory}/{name}"
if place.fold(path) in folded_reported:
return True
source = self._content_for(place, name, key, setting, resolve_held)
if source is None or place.fold(path) in placed:
continue
placed.add(place.fold(path))
return _launcher_entry(source[0], name, path, source[1], place)
# The emulator as this platform installs it brings the file: the
# place is filled, nothing is packed (ROCKNIX's xemu hdd image).
if any(key in candidate.names and report.get("bundled")
@@ -633,9 +1229,36 @@ class LauncherLayer:
record = resolve_held(report)
if record is not None:
return report, record
for candidate, report in matched:
if key not in candidate.names or not set_by(candidate.entry, place.passes_to):
continue
for record in catalogued_model_files(name, self._db):
if name_match_size_ok(candidate.entry, record.get("size")):
return report, record
return None
def _placeholder_report(key: str, profile: dict, entry: dict) -> dict:
"""The report entry of an entry the gap report never builds."""
identity = {
field: entry[field]
for field in ("aliases", "md5", "sha1", "sha256", "crc32", "size", "min_size", "max_size")
if entry.get(field) is not None
}
return {
**identity,
"profile": key,
"emulator": profile.get("emulator", key),
"name": entry.get("name", ""),
"path": entry.get("path") or entry.get("name", ""),
"system": entry.get("system"),
"systems": profile.get("systems"),
"required": entry.get("required", False),
"category": entry.get("category"),
"in_repo": False,
}
def _launcher_entry(
report: dict, name: str, path: str, record: dict, place: LauncherPlace
) -> dict:
@@ -643,7 +1266,7 @@ def _launcher_entry(
entry = {
key: report.get(key)
for key in (
"profile", "emulator", "systems", "system", "region", "variant_group",
"profile", "emulator", "systems", "system", "region", "selection_slot",
"required", "hle_fallback", "category",
)
}
+137 -20
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
from collections.abc import Iterable
from common import PROFILE_IDENTITY_FIELDS, _norm_system_id, entry_system_ids
from alternatives import core_file_spares, required_only_spares, set_fields
from common import archive_members, holds_required_members
from common import declared_root, expand_platform_declared_names, platform_declarations
from common import load_emulator_profiles
@@ -17,7 +18,7 @@ from common import resolve_local_file
from common import resolve_platform_cores
from common import core_system_dirs, platform_layouts, system_dir_path
from common import standalone_set as platform_standalone_set
from common import sanitize_pack_path
from common import OUTER_ROOTS, outer_layout, sanitize_pack_path
from common import size_fits
from validation import deliverable, read_from_save_dir, read_from_system_dir
from packpaths import libretro_systems, save_destination, save_layout
@@ -43,11 +44,29 @@ def extra_full_destination(fe: dict, prefix: str) -> str:
"""
if fe.get("root") == "save":
return str(fe.get("pack_path") or "")
if fe.get("unprefixed"):
# A path of the install tree outside the BIOS folder (AmberELEC's
# pico-8/ beside bios/): no platform prefix in front.
return sanitize_pack_path(fe.get("destination", fe.get("name", "")))
if fe.get("root") in OUTER_ROOTS:
return outer_destination(
fe["root"], sanitize_pack_path(fe.get("destination", fe.get("name", "")))
)
return prefixed_destination(
sanitize_pack_path(fe.get("destination", fe.get("name", ""))), prefix
)
def outer_destination(root: str, dest: str) -> str:
"""The key of a destination below the roms or an emulator folder.
Kept apart from the BIOS tree's paths and never written into a pack:
the pack is extracted into the BIOS folder, and the installer places
these files (``root: roms``, ``root: emulators``).
"""
return f"{root}:{dest}"
def prefixed_destination(dest: str, prefix: str) -> str:
"""A core extra's destination from the pack root.
@@ -309,9 +328,11 @@ class ExtraResolver:
return asset, "release_asset", ""
if status in ("not_found", "external", "user_provided") or not local_path:
return None, status, ""
# The cores reading a moved layout's file are found under the
# emulator's own path, not the folder the platform put it in.
local_path, disagreement = validated_choice(
fe, local_path, self.db, self.validation_index, self.bios_dir, None,
dest, self.validation_owners,
fe.get("layout_path") or dest, self.validation_owners,
)
# The platform never reads the bytes, the core does: a copy it turns
# away by its own size check is not its file, on any platform.
@@ -388,12 +409,21 @@ def hidden_extras(
)
if system_ids:
extras = extras_for_systems(extras, system_ids)
spares = core_file_spares(
extras,
lambda fe: extra_region_key(fe, region_prefix) in (region_drops or set()),
) if required_only else set()
for fe in extras:
if required_only and fe.get("required") is False:
if required_only and (fe.get("required") is False or id(fe) in spares):
continue
dest = sanitize_pack_path(fe.get("destination", fe.get("name", "")))
if not dest or (region_drops and extra_region_key(fe, region_prefix) in region_drops):
continue
if fe.get("root") in OUTER_ROOTS:
# As the builder does: the pack is extracted into the BIOS folder
# and cannot reach the roms, emulator or configs folder, the
# installer places these files.
continue
full = extra_full_destination(fe, prefix)
if not full:
continue
@@ -462,9 +492,10 @@ def unplaceable_extras(
placement = ExtraPlacement(config, "", profiles)
unplaced: dict[tuple[str, str, str], str] = {}
for u in undeclared:
if not u.get("in_repo"):
if not u.get("in_repo") and not u.get("unplaced"):
continue
if not placement.slug_based and u.get("root") != "save":
if (not placement.slug_based and u.get("root") not in ("save", *OUTER_ROOTS)
and not u.get("unplaced")):
continue
dest = _extra_dest(u)
if not placement.places(u, dest):
@@ -550,7 +581,8 @@ def _agnostic_scan_extras(
continue
# The scan walks the BIOS tree: a file read below the save
# directory is placed by the gap report's pass.
if read_from_save_dir(f, standalone):
if (read_from_save_dir(f, standalone) or f.get("unplaced")
or f.get("root") in OUTER_ROOTS or f.get("unprefixed")):
continue
# An agnostic BIOS mode says the BIOS filename is free, not that
# every file the emulator loads is. A free filename still has a
@@ -640,7 +672,7 @@ def _agnostic_scan_extras(
"source_system": f.get("system"),
"source_systems": entry_system_ids(f, profile),
"region": f.get("region"),
"variant_group": f.get("variant_group"),
"selection_slot": f.get("selection_slot"),
"agnostic_scan": True,
}
extra["claim_order"] = (4, 0, emu_name)
@@ -796,7 +828,7 @@ def _companion_extras(
"source_system": seed.get("system"),
"source_systems": entry_system_ids(seed, profile),
"region": seed.get("region"),
"variant_group": seed.get("variant_group"),
"selection_slot": seed.get("selection_slot"),
"agnostic_scan": True,
}
extra["claim_order"] = (4, 0, emu_name)
@@ -970,7 +1002,7 @@ def _archive_prefix_copies(
"source_system": archive_entry.get("system"),
"source_systems": entry_system_ids(archive_entry, profile),
"region": archive_entry.get("region"),
"variant_group": archive_entry.get("variant_group"),
"selection_slot": archive_entry.get("selection_slot"),
}
members = archive_members(archive_name, profile.get("files", []))
if members:
@@ -1001,6 +1033,10 @@ class ExtraPlacement:
self.native_systems = native_slug_systems(config)
self.flat = bool(config.get("firmware_folder_flat"))
self.save = save_layout(config)
# The roms root and emulator folders the platform's files describe,
# for a file a launcher runs from them (ROCKNIX's roms/pico-8,
# RetroBat's emulators/pico8).
self.outer = {root for root in OUTER_ROOTS if outer_layout(config, root)}
self.config = config
self.norm_map = (
{_norm_system_id(sid): sid for sid in self.platform_systems}
@@ -1019,6 +1055,13 @@ class ExtraPlacement:
directory and goes below the platform's save folder, which a
platform keeping saves beside the content does not have.
"""
if entry.get("unplaced"):
return None
if entry.get("unprefixed"):
return dest, dest
if entry.get("root") in OUTER_ROOTS:
root = entry["root"]
return (dest, outer_destination(root, dest)) if root in self.outer else None
if entry.get("root") == "save":
placed = self.save_places(entry, dest)
return placed[0] if placed else None
@@ -1039,6 +1082,8 @@ class ExtraPlacement:
platform that hands each system a save folder of its own goes once
below each system that runs the core.
"""
if entry.get("unplaced"):
return []
if entry.get("root") == "save":
return self.save_places(entry, dest)
placed = self.place(entry, dest)
@@ -1075,6 +1120,10 @@ class ExtraPlacement:
def unplaced_reason(self, entry: dict, dest: str) -> str:
"""Why ``place`` returned None for this entry."""
if entry.get("unplaced"):
return str(entry["unplaced"])
if entry.get("root") in OUTER_ROOTS:
return f"no_{entry['root']}_directory"
if entry.get("root") == "save":
if self.save is None:
return "no_save_directory"
@@ -1196,6 +1245,12 @@ def _collect_emulator_extras(
# user's machine. The mode a file is bundled for was settled
# upstream, an entry scoped to another build never gets here.
continue
if u.get("unplaced"):
# Read in a folder outside the BIOS tree: named, held or not,
# never written where the emulator does not read it.
if unplaceable is not None:
unplaceable.append({**u, "unplaceable": u["unplaced"]})
continue
if not u["in_repo"] and absent is None:
continue
# For archive entries, use the archive name for resolution
@@ -1208,11 +1263,14 @@ def _collect_emulator_extras(
placements = placement.places(u, dest)
save_root = u.get("root") == "save"
if not u["in_repo"]:
# Each core reading it is named on the omission: a list built
# without the first reader still has to say the file is missing.
for placed in placements:
missing = _report_extra(u, name, placed[0])
if save_root:
_save_fields(missing, placed[1], placement)
absent.append(missing)
for claim in (u, *u.get("other_claims", [])):
missing = _report_extra(claim, name, placed[0])
if save_root:
_save_fields(missing, placed[1], placement)
absent.append(missing)
continue
if not placements:
if unplaceable is not None:
@@ -1291,7 +1349,9 @@ def _collect_emulator_extras(
# Only duplicate files already covered at another destination.
# An unsourceable entry is a file no collection holds: the name
# covered elsewhere belongs to another program's file.
if fname not in covered_names or f.get("unsourceable"):
# Nor is one placed outside the BIOS tree by the layout.
if (fname not in covered_names or f.get("unsourceable") or f.get("unplaced")
or f.get("root") in OUTER_ROOTS or f.get("unprefixed")):
continue
# Installed with the emulator or by the platform's package,
# as the first pass already decided for the same entry.
@@ -1354,7 +1414,8 @@ def _collect_emulator_extras(
"source_system": f.get("system"),
"source_systems": entry_system_ids(f, profile),
"region": f.get("region"),
"variant_group": f.get("variant_group"),
"selection_slot": f.get("selection_slot"),
**set_fields(f, profile),
}
# The same identity the first pass carries: without it the copy
# resolves on its name alone and a same-named file of another
@@ -1522,7 +1583,8 @@ def _report_extra(u: dict, name: str, dest: str) -> dict:
"source_system": u.get("system"),
"source_systems": u.get("systems", []),
"region": u.get("region"),
"variant_group": u.get("variant_group"),
"selection_slot": u.get("selection_slot"),
**{k: u[k] for k in ("required_groups", "set_declared") if k in u},
}
if u.get("members"):
extra["members"] = u["members"]
@@ -1530,8 +1592,14 @@ def _report_extra(u: dict, name: str, dest: str) -> dict:
extra["root"] = "save"
if u.get("core_folder") is not None:
extra["core_folder"] = u["core_folder"]
elif u.get("root") in OUTER_ROOTS:
extra["root"] = u["root"]
if u.get("system_dir"):
extra["system_dir"] = u["system_dir"]
if u.get("layout_path"):
extra["layout_path"] = u["layout_path"]
if u.get("unprefixed"):
extra["unprefixed"] = True
# The archive layout the core opens ranks same-named copies by what
# they hold. Without it the first copy indexed won.
if u.get("contents"):
@@ -1795,9 +1863,9 @@ def extra_region_groups(
return (spellings or {}).get(_norm_system_id(sys_id), sys_id)
emulator = extra.get("source_emulator", "")
variant = extra.get("variant_group")
variant = extra.get("selection_slot")
if variant:
# A variant group is one slot of one core, whatever system each
# A selection slot is one slot of one core, whatever system each
# member is filed under: PicoDrive files its US Mega CD BIOS under
# sega-segacd and the EU/JP ones under sega-megacd, and find_bios
# picks one of the twelve. Keyed as emulator mode keys it.
@@ -1826,7 +1894,7 @@ def emulator_region_drops(
) -> set[str]:
"""Destinations an emulator-mode `--region` withdraws.
One group per system (or explicit variant_group) inside each profile, so
One group per system (or explicit selection_slot) inside each profile, so
a multi-system core such as O2EM keeps a fallback BIOS for every system
instead of letting one matching system empty another. The emulator pack
and `verify --emulator` both read this, keyed by pack destination.
@@ -1864,6 +1932,55 @@ def emulator_region_drops(
return region_mod.resolve_region_drops(region_groups, region_index, regions, held)
def emulator_required_spares(
emu_name: str,
profile: dict,
files: list[dict],
standalone: bool,
region_drops: set[str],
db: dict,
validation_index: dict,
zip_contents: dict | None,
data_registry: dict | None,
) -> set[str]:
"""The destinations an emulator --required-only pack leaves out: of each
required set, one held member stays, the first the code loads among
those the pack still carries after the region filter. An archive unit
is a member like a loose file: Kronos reads kronos/stvbios.zip, else
stvbios.zip. The pack and verify --emulator both read this."""
from alternatives import groups_of, rank_in, required_groups
from common import archive_entry
from packpaths import _archive_destination, _resolve_destination
candidates: list[dict] = []
archives: list[str] = []
for fe in files:
archive = fe.get("archive")
if archive and archive not in archives:
archives.append(archive)
own = archive_entry(archive, files)
if groups_of(own):
candidates.append({"dest": _archive_destination(
archive, profile.get("archive_prefix", ""),
profile.get("pack_structure"), standalone,
), "entry": own})
for fe in files:
if not fe.get("archive") and groups_of(fe):
candidates.append({"dest": _resolve_destination(fe, standalone), "entry": fe})
if not candidates:
return set()
spares = required_only_spares(
candidates,
owner=lambda _c: emu_name,
held=lambda c: _emulator_ships(
c["entry"], emu_name, profile, db, validation_index, zip_contents, data_registry,
),
gone=lambda c: c["dest"] in region_drops,
sets=lambda c: {g: rank_in(c["entry"], g) for g in required_groups(c["entry"], profile)},
)
return {c["dest"] for c in candidates if id(c) in spares}
def _emulator_ships(
file_entry: dict, emu_name: str, profile: dict, db: dict,
validation_index: dict, zip_contents: dict | None, data_registry: dict | None,
@@ -1904,7 +2021,7 @@ def _emulator_region_group(
Grouped together, geargrafx's LaserActive pac-n1.bin was dropped by
--emulator --region us and kept by --platform --region us.
"""
variant = file_entry.get("variant_group")
variant = file_entry.get("selection_slot")
if variant:
return f"{emu_name}:variant:{variant}"
system = file_entry.get("system")
+14
View File
@@ -782,6 +782,14 @@ def verify_pack_against_platform(
baseline_groups.setdefault(expected, []).append(fe)
baseline_checked = len(baseline_groups)
# RetroBat's group: one packed file of it is enough, as verify counts it.
packed_groups = {
(system_of[id(fe)], fe["group"])
for expected, declarations in baseline_groups.items()
if expected in zip_set or expected.lower() in zip_lower
for fe in declarations
if fe.get("group")
}
exclusion_index = build_zip_contents_index(db) if db is not None else {}
categories = EntryCategories(config, emu_profiles, data_registry)
for expected, declarations in baseline_groups.items():
@@ -803,6 +811,12 @@ def verify_pack_against_platform(
):
baseline_excluded += 1
continue
elif all(
fe.get("group") and (system_of[id(fe)], fe["group"]) in packed_groups
for fe in declarations
):
baseline_absent += 1
continue
elif db is not None and all(
categories.category(fe, fe.get("destination", fe.get("name", "")),
system_of[id(fe)]) != BIOS
+8 -3
View File
@@ -77,6 +77,7 @@ from common import (
publish_hold,
)
from refresh_data_dirs import hold_data_dirs
from packpaths import MEDIA_MARKER
class StepResults(dict):
@@ -432,8 +433,12 @@ def parse_pack_paths(output: str) -> dict[str, str]:
return paths
# Members the builder writes for the reader rather than for a core.
_PACK_NOTES = re.compile(r"(^README\.txt$|^manifest\.json$|(^|/)(INSTRUCTIONS|RENAMED)_[^/]*\.txt$)")
# Members the builder writes for the reader or the device rather than for a
# core: the documents, and the empty marker an Android pack carries.
_PACK_NOTES = re.compile(
r"(^README\.txt$|^manifest\.json$|(^|/)(INSTRUCTIONS|RENAMED)_[^/]*\.txt$"
rf"|(^|/){re.escape(MEDIA_MARKER)}$)"
)
def _tree_members(
@@ -627,7 +632,7 @@ def check_emulator_packs(
systems = sorted({
_norm_system_id(str(sid))
for profile in profiles.values()
if profile.get("type") not in ("launcher", "alias", "test")
if profile.get("type") not in ("launcher", "alias") and profile.get("role") != "test"
for sid in profile.get("systems") or []
})
systems_ok = True
+2 -2
View File
@@ -50,8 +50,8 @@ STEPS: tuple[Step, ...] = (
needs="vermin", provider="the pip line of CONTRIBUTING.md"),
Step("shellcheck --version", "the launcher test runs shellcheck", needs="shellcheck",
provider="the shellcheck package"),
Step("python scripts/export_native.py --all --fetch --output-dir tmp/native",
"files the exporter tests compare with", may_fail=True),
Step("python scripts/export_native.py --all --refresh-cache",
"files the exporter tests compare with"),
Step("python scripts/refresh_data_dirs.py", "data directories the pack-building tests read"),
Step("python scripts/generate_site.py", "the pages the site tests read"),
Step("npm ci --prefix website", "the site's packages, Node 24",
+19 -7
View File
@@ -1097,11 +1097,19 @@ def triage_state(report: ProfileReport) -> str:
def select_repo(profile: dict) -> upstream.Repo | None:
"""Repository the profile was read from: source first, then upstream."""
"""Repository the profile was read from: source first, then upstream.
A source keyed by build mode with no upstream (uzdoom) names its
repositories only in the dict, which a plain string read never parses.
"""
for field in ("source", "upstream"):
repo = upstream.parse_repo(str(profile.get(field) or ""))
if repo is not None:
return repo
for _, _, url in declared_repositories(profile):
repo = upstream.parse_repo(url)
if repo is not None:
return repo
return None
@@ -1496,14 +1504,18 @@ def detect_pinned_tag(
def tag_pinned_views(
profile: dict, views: list[RepoView], cache_dir: str, offline: bool
) -> dict[tuple[str, str], str]:
"""Views whose pin is the tag `core_version` names, keyed by repository."""
"""Views whose pin is the tag `core_version` names, keyed by repository.
A core_version keyed by build mode names the tag of the view's own mode:
uzdoom's standalone sits on 5.0.3 while its libretro branch moves.
"""
tagged: dict[tuple[str, str], str] = {}
declared = profile.get("core_version")
for view in views:
if view.pin == view.head:
continue
for tag in version_tag_candidates(
profile.get("core_version"), view.repo.name
):
version = declared.get(view.mode) if isinstance(declared, dict) else declared
for tag in version_tag_candidates(version, view.repo.name):
if upstream.tag_commit(view.repo, tag, cache_dir, offline) == view.pin:
tagged[(view.repo.host, view.repo.slug)] = tag
break
@@ -4441,7 +4453,7 @@ def select_profiles(profiles: dict, args: argparse.Namespace) -> dict:
# alias and test profiles too, "lists all 553" pointed at 530 lines.
listed = sorted(
name for name, profile in profiles.items()
if profile.get("type") not in ("alias", "test")
if profile.get("type") != "alias" and profile.get("role") != "test"
)
raise SystemExit(
f"ERROR: unknown profile {', '.join(unknown)}. "
@@ -4454,7 +4466,7 @@ def select_profiles(profiles: dict, args: argparse.Namespace) -> dict:
wanted_system = _norm_system_id(args.system) if args.system else None
selected = {}
for name, profile in sorted(profiles.items()):
if profile.get("type") in ("alias", "test"):
if profile.get("type") == "alias" or profile.get("role") == "test":
continue
if wanted_system and wanted_system not in {
_norm_system_id(str(s)) for s in profile.get("systems") or []
+429
View File
@@ -0,0 +1,429 @@
"""AmberELEC's launcher layer, read from runemu.py and its emulator scripts.
EmulationStation hands every launch to runemu.py. A libretro core runs in
RetroArch or retrorun with the system directory their configs name, MAME
machines get a .cmd file with a `-rp` search path, and every other emulator
runs the script its standalone_emulators table names (/usr/bin/<name>.sh,
installed from the package). Those scripts, and the configs their packages
install, say which file under /storage/roms/bios is opened: yabasanshiro.sh
tests saturn_bios.bin and passes it with -b, Hatari's default config names
tos.img, the atari800 core is built to read a config naming its OS ROMs.
runemu.py is read with ast, the scripts with shell_launchers' readers. An
extractor whose construct is gone raises instead of returning nothing.
"""
from __future__ import annotations
import ast
import posixpath
import re
from collections.abc import Callable
from typing import Any
try:
from .configgen_launchers import Launcher, Source
from .shell_launchers import (
LauncherPatternMissing,
Script,
describe_keys,
settings,
source_ref,
)
except ImportError:
from configgen_launchers import Launcher, Source # type: ignore[no-redef]
from shell_launchers import ( # type: ignore[no-redef]
LauncherPatternMissing,
Script,
describe_keys,
settings,
source_ref,
)
BIOS = "/storage/roms/bios"
RUNEMU = "packages/amberelec/sources/scripts/runemu.py"
RETROARCH_CFG = "packages/games/tools/retroarch/sources/retroarch.cfg"
RETRORUN = "packages/games/tools/retrorun/retrorun.sh"
HATARI_CONFIG = "packages/games/emulators/hatarisa/config/conf/Atari-ST-default.cfg"
ATARI800_CONFIG = "packages/amberelec/config/distribution/configs/atari800.cfg"
ATARI800_PATCH = "packages/games/libretro/atari800/patches/atari800-01-add-emuelec-platform.patch"
AMIBERRY_PACKAGE = "packages/games/emulators/amiberry/package.mk"
AMIBERRY_CONFIGS = "packages/games/emulators/amiberry/config/conf"
FREEJ2ME = (
("freej2me", "packages/games/libretro/freej2me/freej2me.sh"),
("freej2me-plus", "packages/games/libretro/freej2me-plus/freej2me-plus.sh"),
)
EASYRPG = "packages/games/libretro/easyrpg/easyrpg.sh"
# The script each standalone_emulators entry runs, as the package installs it
# to /usr/bin. scripts() checks each against runemu's table.
SCRIPTS = {
"AMIBERRY": "packages/games/emulators/amiberry/scripts/amiberry.sh",
"HATARISA": "packages/games/emulators/hatarisa/scripts/hatari.sh",
"SCUMMVMSA": "packages/games/emulators/scummvmsa/scummvm.sh",
"pico8": "packages/amberelec/sources/pico-8.sh",
"piemu": "packages/games/emulators/piemu/bin/piemu.sh",
"yabasanshiroSA": "packages/games/emulators/yabasanshiroSA/yabasanshiro.sh",
}
Read = Callable[[str], str]
class Runemu:
"""runemu.py, parsed: the standalone table and the RetroArch command."""
def __init__(self, read: Read) -> None:
self.src = Source(RUNEMU, read(RUNEMU))
node = self.src.assignment("standalone_emulators")
if not isinstance(node.value, ast.Dict):
self.src.fail("standalone_emulators is not a dict display")
self.table: dict[str, tuple[str, int]] = {}
for key, value in zip(node.value.keys, node.value.values):
if not (
isinstance(key, ast.Constant)
and isinstance(value, ast.Call)
and len(value.args) >= 2
and isinstance(value.args[1], ast.List)
):
self.src.fail(f"entry at line {getattr(key, 'lineno', '?')} is not StandaloneEmulator(name, [args])")
args = [a.value for a in value.args[1].elts if isinstance(a, ast.Constant)]
script = next((a for a in args if str(a).endswith(".sh")), None)
if script is None:
self.src.fail(f"{ast.unparse(key)} runs no .sh script")
self.table[str(key.value)] = (str(script), value.lineno)
def script(self, emulator: str) -> tuple[str, int]:
"""The repository script runemu runs for emulator, and the table line."""
if emulator not in self.table:
self.src.fail(f"standalone_emulators has no {emulator}")
installed, line = self.table[emulator]
path = SCRIPTS[emulator]
if posixpath.basename(installed) != posixpath.basename(path):
self.src.fail(f"{emulator} runs {installed}, not {posixpath.basename(path)}")
return path, line
def retroarch(read: Read) -> list[Launcher]:
"""system_directory of the retroarch.cfg the retroarch package installs."""
found = [s for s in settings(read(RETROARCH_CFG)) if s.key == "system_directory"]
if len(found) != 1:
raise LauncherPatternMissing(RETROARCH_CFG, "one system_directory")
return [Launcher(
emulator="retroarch", build="libretro", kind="passes_path", names=[],
search_dir=found[0].value, passes_to="retroarch.cfg system_directory",
fails_when_missing=False, source_ref=source_ref([(RETROARCH_CFG, [found[0].line])]),
)]
def retrorun(read: Read) -> list[Launcher]:
"""The -d directory retrorun.sh passes to retrorun."""
script = Script(RETRORUN, read(RETRORUN))
line, found = script.find(r"^\s*/usr/bin/retrorun\s.*\s-d\s+(\S+)", "retrorun -d")
return [Launcher(
emulator="retrorun", build="libretro", kind="passes_path", names=[],
search_dir=found.group(1).strip('"'), passes_to="command line -d",
fails_when_missing=False, source_ref=source_ref([(RETRORUN, [line])]),
)]
def mame(runemu: Runemu) -> list[Launcher]:
"""The `-rp` search path of the .cmd file runemu writes for a MAME machine."""
src = runemu.src
commands = [
node for node in ast.walk(src.function("get_retroarch_command"))
if isinstance(node, ast.JoinedStr)
and any(isinstance(p, ast.Constant) and " -rp " in str(p.value) for p in node.values)
]
if not commands:
src.fail("get_retroarch_command writes no -rp search path")
paths = []
for node in commands:
text = "".join(str(p.value) for p in node.values if isinstance(p, ast.Constant))
paths.append(text.split(" -rp ", 1)[1].split()[0])
if len(set(paths)) != 1:
src.fail(f"the MAME commands differ in their search path: {sorted(set(paths))}")
guard = next(
node for node in ast.walk(src.function("get_retroarch_command"))
if isinstance(node, ast.If) and ast.unparse(node.test) == "self.emulator == 'mame'"
)
return [Launcher(
emulator="mame", build="libretro", kind="passes_path", names=[],
search_dir=paths[0], passes_to=".cmd -rp", fails_when_missing=False,
depends_on_option=ast.unparse(guard.test),
source_ref=src.ref([
src.span(guard.test), *((n.lineno, n.end_lineno or n.lineno) for n in commands),
]),
)]
def _bios_files(path: str, text: str) -> tuple[list[tuple[tuple[str, str], str]], set[int]]:
"""((section, key), name under BIOS) for each config key whose value is a BIOS file."""
prefix = BIOS + "/"
found = [s for s in settings(text) if s.value.startswith(prefix)]
if not found:
raise LauncherPatternMissing(path, f"key naming a file under {BIOS}")
return [((s.section, s.key), s.value[len(prefix):]) for s in found], {s.line for s in found}
def hatari(read: Read, runemu: Runemu) -> list[Launcher]:
"""szTosImageFileName of the default config hatari.sh copies and passes."""
path, table_line = runemu.script("HATARISA")
script = Script(path, read(path))
name = posixpath.basename(HATARI_CONFIG)
copied, _ = script.find(rf"^\s*cp\s+\S*/{re.escape(name)}\s", f"cp of {name}")
passed, _ = script.find(r"\shatari\s+--configfile\s", "hatari --configfile")
files, lines = _bios_files(HATARI_CONFIG, read(HATARI_CONFIG))
return [Launcher(
emulator="HATARISA", build="standalone", kind="fixed_name",
names=[n for _k, n in files], case_insensitive=False, search_dir=BIOS,
passes_to=describe_keys(name, tuple(k for k, _n in files)),
fails_when_missing=False,
source_ref=source_ref([
(RUNEMU, [table_line]), (path, [copied, passed]), (HATARI_CONFIG, lines),
]),
)]
def atari800(read: Read) -> list[Launcher]:
"""The ROM keys of the config the atari800 core is built to read."""
patch = Script(ATARI800_PATCH, read(ATARI800_PATCH))
line, found = patch.find(r'DEFAULT_CFG_NAME="\\"([^"\\]+)\\""', "DEFAULT_CFG_NAME")
built = found.group(1)
if not built.endswith("/" + ATARI800_CONFIG.split("/config/", 1)[1]):
raise LauncherPatternMissing(ATARI800_PATCH, f"DEFAULT_CFG_NAME naming {ATARI800_CONFIG}")
files, lines = _bios_files(ATARI800_CONFIG, read(ATARI800_CONFIG))
return [Launcher(
emulator="atari800", build="libretro", kind="fixed_name",
names=[n for _k, n in files], case_insensitive=False, search_dir=BIOS,
passes_to=describe_keys(posixpath.basename(ATARI800_CONFIG), tuple(k for k, _n in files)),
fails_when_missing=False,
source_ref=source_ref([(ATARI800_PATCH, [line]), (ATARI800_CONFIG, lines)]),
)]
def yabasanshiro(read: Read, runemu: Runemu) -> list[Launcher]:
"""`if [ -e <bios> ]; then BIOS="-b <bios>"`, skipped when HLE is chosen."""
path, table_line = runemu.script("yabasanshiroSA")
script = Script(path, read(path))
tested, test = script.find(rf'if \[ -e "({re.escape(BIOS)}/[^"]+)" \]', "BIOS presence test")
flag_line, flag = script.find(
rf'BIOS="(-\w+) {re.escape(test.group(1))}"', "BIOS flag", tested
)
hle_line, hle = script.find(r'get_setting "(\w*hle\w*)"', "HLE setting")
guard_line, _ = script.find(r'if \[ "\$\{HLEBIOS\}" != 1 \]', "HLE test")
used_line, _ = script.find(r"\$\{BIOS\}", "BIOS on the command line", flag_line + 1)
return [Launcher(
emulator="yabasanshiroSA", build="standalone", kind="fixed_name",
names=[test.group(1)[len(BIOS) + 1:]], case_insensitive=False, search_dir=BIOS,
passes_to=f"command line {flag.group(1)}", fails_when_missing=False,
depends_on_option=hle.group(1),
source_ref=source_ref([
(RUNEMU, [table_line]),
(path, [hle_line, guard_line, tested, flag_line, used_line]),
]),
)]
def pico8(read: Read, runemu: Runemu) -> list[Launcher]:
"""`if [ ! -f A ] || [ ! -f B ]; then text_viewer ...; else A ...`."""
path, table_line = runemu.script("pico8")
script = Script(path, read(path))
line, test = script.find(r'if \[ ! -f "([^"]+)" \] \|\| \[ ! -f "([^"]+)" \]', "binary test")
message_line, message = script.find(r'text_viewer .*-t "([^"]+)"', "missing message", line)
binary = test.group(1)
run_line, _ = script.find(rf"^\s*{re.escape(binary)}\s", f"{binary} run", message_line)
folders = {posixpath.dirname(test.group(1)), posixpath.dirname(test.group(2))}
if len(folders) != 1:
raise script.missing("both binaries in one folder")
return [Launcher(
emulator="pico8", build="standalone", kind="fixed_name",
names=[posixpath.basename(test.group(1)), posixpath.basename(test.group(2))],
case_insensitive=False, search_dir=folders.pop(), passes_to="exec",
fails_when_missing=message.group(1),
source_ref=source_ref([(RUNEMU, [table_line]), (path, [line, message_line, run_line])]),
)]
def piemu(read: Read, runemu: Runemu) -> list[Launcher]:
"""The BIOS file piemu.sh hands to mkpfi to build the flash image."""
path, table_line = runemu.script("piemu")
script = Script(path, read(path))
names: list[str] = []
lines: set[int] = set()
for number, line in script.code():
found = re.search(r'/usr/bin/mkpfi\s+"([^"]+)"', line)
if found is None:
continue
used: set[int] = {number}
value = script.expand(found.group(1), number, used)
if value.startswith(BIOS + "/") and value[len(BIOS) + 1:] not in names:
names.append(value[len(BIOS) + 1:])
lines |= used
if not names:
raise script.missing(f"mkpfi of a file under {BIOS}")
return [Launcher(
emulator="piemu", build="standalone", kind="fixed_name", names=names,
case_insensitive=False, search_dir=BIOS, passes_to="mkpfi argument",
fails_when_missing=False,
source_ref=source_ref([(RUNEMU, [table_line]), (path, lines)]),
)]
def amiberry(read: Read, runemu: Runemu) -> list[Launcher]:
"""The kickstarts link of the package, and the ROM each default config names."""
package = Script(AMIBERRY_PACKAGE, read(AMIBERRY_PACKAGE))
links = [link for link in package.links(BIOS) if link.bios_is_target]
if len(links) != 1 or links[0].bios_side.rstrip("/") != BIOS:
raise package.missing(f"ln -s {BIOS} to the kickstarts folder")
link = links[0]
installed = re.sub(r"^\$\{INSTALL\}", "", link.link)
path, table_line = runemu.script("AMIBERRY")
script = Script(path, read(path))
launchers = [Launcher(
emulator="AMIBERRY", build="standalone", kind="links", names=[],
case_insensitive=False, search_dir=BIOS,
passes_to=f"{installed} is a symlink to it", fails_when_missing=False,
source_ref=source_ref([(AMIBERRY_PACKAGE, link.lines)]),
)]
for number, line in script.code():
copied = re.search(r"cp\s+\$AMIBERRY_CONFIG_DIR/(\S+\.uae)\s", line)
if copied is None:
continue
config = f"{AMIBERRY_CONFIGS}/{copied.group(1)}"
found = [
s for s in settings(read(config))
if s.key == "kickstart_rom_file" and s.value.startswith("$(FILE_PATH)/")
]
rom_path = [s for s in settings(read(config)) if s.key == "amiberry.rom_path"]
if len(found) != 1 or len(rom_path) != 1:
raise LauncherPatternMissing(config, "kickstart_rom_file under $(FILE_PATH)")
if rom_path[0].value.rstrip("/").rsplit("/", 1)[-1] != installed.rstrip("/").rsplit("/", 1)[-1]:
raise LauncherPatternMissing(config, f"amiberry.rom_path naming {installed}")
condition_line = max(
n for n, text in script.code()
if n < number and re.match(r"\s*(if|else)\b", text)
)
branch = script.lines[condition_line - 1].strip()
if branch == "else":
opened = max(
n for n, text in script.code()
if n < condition_line and re.match(r"\s*if\b", text)
)
test = _shell_test(script, opened)
condition = f"not ({test})"
else:
test = _shell_test(script, condition_line)
condition = test
launchers.append(Launcher(
emulator="AMIBERRY", build="standalone", kind="fixed_name",
slot=copied.group(1).removesuffix("-default.uae").removeprefix("Amiga"),
names=[found[0].value.removeprefix("$(FILE_PATH)/")], case_insensitive=False,
search_dir=BIOS,
passes_to=f"{copied.group(1)} kickstart_rom_file, $(FILE_PATH) = {rom_path[0].value}",
fails_when_missing=False,
depends_on_option=condition,
source_ref=source_ref([
(RUNEMU, [table_line]), (path, [condition_line, number]),
(config, [rom_path[0].line, found[0].line]),
]),
))
if len(launchers) == 1:
raise script.missing("cp of a default .uae config")
return launchers
def _shell_test(script: Script, line: int) -> str:
"""The test of `if <test>; then` at a line."""
found = re.match(r"\s*if\s+(.*?);\s*then\s*$", script.lines[line - 1])
if found is None:
raise script.missing(f"if ...; then at line {line}")
return found.group(1)
def scummvm(read: Read) -> list[Launcher]:
"""The paths scummvm.sh writes into the libretro core's scummvm.ini."""
path = SCRIPTS["SCUMMVMSA"]
script = Script(path, read(path))
branch, _ = script.find(r'^\s*"libretro"\)', "libretro branch")
launchers = []
for number, line in script.code():
if number < branch:
continue
written = re.search(r"s/\^/(\w*path)=((?:\\/[^\\/n]+)+)\\n/", line)
if written is None:
continue
folder = written.group(2).replace("\\/", "/")
if "/roms/bios/" not in folder + "/" or written.group(1) == "browser_lastpath":
continue
launchers.append(Launcher(
emulator="scummvm", build="libretro", kind="passes_path", names=[],
search_dir=folder, passes_to=f"scummvm.ini {written.group(1)}",
fails_when_missing=False, depends_on_option="scummvm.sh libretro",
source_ref=source_ref([(path, [branch, number])]),
))
if not launchers:
raise script.missing("a scummvm.ini path written under /roms/bios")
return launchers
def freej2me(read: Read) -> list[Launcher]:
"""`if [ ! -f <bios>/<jar> ]; then cp <shipped jar> <bios>`."""
launchers = []
for core, path in FREEJ2ME:
script = Script(path, read(path))
tested, test = script.find(
rf'if \[ ! -f "{re.escape(BIOS)}/([^"]+)" \]', "jar presence test"
)
jar = test.group(1)
lines = [tested]
source = None
for number, line in script.code():
copied = re.match(rf"\s*cp\s+(?:-\S+\s+)*(\S+/{re.escape(jar)})\s+{re.escape(BIOS)}/?\s*$", line)
if copied:
lines.append(number)
source = copied.group(1)
if source is None:
raise script.missing(f"cp of {jar} into {BIOS}")
launchers.append(Launcher(
emulator=core, build="libretro", kind="copies", names=[jar],
case_insensitive=False, search_dir=posixpath.dirname(source), passes_to=BIOS,
fails_when_missing=False, source_ref=source_ref([(path, lines)]),
))
return launchers
def easyrpg(read: Read) -> list[Launcher]:
"""Each install_bios call: a runtime package downloaded and unpacked under BIOS."""
script = Script(EASYRPG, read(EASYRPG))
url_line, url = script.find(r'curl -Lo "\$\{BIOS\}" "([^"]+)"', "curl of the package")
launchers = []
for number, line in script.code():
call = re.match(r"\s*install_bios\s+(\S+)\s+(\S+)\s*$", line)
if call is None:
continue
used: set[int] = {number, url_line}
source = script.expand(url.group(1), number, used, {"BIOS": call.group(1)})
install = script.value("INSTALLPATH", number)
if install is None or install.value.rstrip("/") != BIOS:
raise script.missing(f"INSTALLPATH {BIOS}")
used.add(install.line)
launchers.append(Launcher(
emulator="easyrpg", build="libretro", kind="copies", names=[],
search_dir=source, passes_to=f"{BIOS}/{call.group(2)}",
fails_when_missing=False, source_ref=source_ref([(EASYRPG, used)]),
))
if not launchers:
raise script.missing("install_bios call")
return launchers
def extract_launchers(read: Read) -> list[dict[str, Any]]:
"""Every launcher record, read through `read(repo-relative path)`."""
runemu = Runemu(read)
launchers = [
*retroarch(read), *retrorun(read), *mame(runemu),
*atari800(read), *scummvm(read), *freej2me(read), *easyrpg(read),
*amiberry(read, runemu), *hatari(read, runemu), *pico8(read, runemu),
*piemu(read, runemu), *yabasanshiro(read, runemu),
]
return [launcher.entry() for launcher in launchers]
+7
View File
@@ -23,6 +23,9 @@ Verification (batocera-systems at that commit):
(packages/amberelec/profile.d/99-distribution.conf:320) passes
`--strictfilter`, which the script's argument switch (lines 665-687) does
not handle: that call prints nothing and exits 0.
The launcher layer is read at the same commit (amberelec_launchers): runemu.py,
the scripts its standalone table runs and the configs their packages install.
"""
from __future__ import annotations
@@ -32,6 +35,7 @@ import re
import urllib.error
import urllib.request
from .amberelec_launchers import extract_launchers
from .base_scraper import BaseScraper, BiosRequirement, github_headers
from .batocera_scraper import (
SYSTEM_SLUG_MAP as BATOCERA_SLUGS,
@@ -155,6 +159,9 @@ class Scraper(BaseScraper):
"standalone_cores": standalone,
"dual_mode_cores": dual,
"systems": systems,
"launchers": extract_launchers(
lambda path: self._fetch_text(_RAW.format(ref=self.commit) + path)
),
}
+7
View File
@@ -345,8 +345,15 @@ class _PlatformDumper(yaml.SafeDumper):
Names such as "7800 BIOS (E).rom" or "Bandai - WonderSwan: x" are plain
text, and a bare "01" would be read back as an integer.
Two records sharing one list are written out twice, never as an anchor
and its alias: RetroBat's amiberry search and copy records share their
name list, and the platform file turned into &id001 and *id001.
"""
def ignore_aliases(self, data: object) -> bool:
return True
def _quoted_str(dumper: yaml.SafeDumper, data: str) -> yaml.ScalarNode:
style = None
+415 -32
View File
@@ -15,7 +15,8 @@ launcher looks for nothing".
from __future__ import annotations
import ast
from collections.abc import Callable
import re
from collections.abc import Callable, Iterable
from typing import Any
from .configgen_launchers import (
@@ -58,6 +59,29 @@ def _config_option(node: ast.AST) -> tuple[str, Any] | None:
return node.args[0].value, default
def _option_names(node: ast.AST) -> list[str]:
"""Options a node reads: system.config.get*(o), system.config[o], system.isOptSet(o)."""
found: list[str] = []
for child in ast.walk(node):
option = _config_option(child)
if option is None and (
isinstance(child, ast.Subscript)
and ast.unparse(child.value) == "system.config"
and isinstance(child.slice, ast.Constant)
):
option = (str(child.slice.value), None)
if option is None and (
isinstance(child, ast.Call)
and ast.unparse(child.func) == "system.isOptSet"
and child.args
and isinstance(child.args[0], ast.Constant)
):
option = (str(child.args[0].value), None)
if option is not None and option[0] not in found:
found.append(option[0])
return found
def path_constants(source: Source) -> dict[str, str]:
"""batoceraPaths' module constants that are plain paths."""
constants: dict[str, str] = {}
@@ -73,6 +97,32 @@ def path_constants(source: Source) -> dict[str, str]:
return constants
def configs_root(cg: Configgen) -> dict[str, str]:
"""Where configgen keeps the emulators' configs, relative to the BIOS folder.
batoceraPaths' CONFIGS (/userdata/system/configs), the folder a generator
starts an emulator from (shadPS4's configs/shadps4), with the lines that
build it and BIOS.
"""
import posixpath # noqa: PLC0415
src = cg.source(f"{cg.generators.rsplit('/', 1)[0]}/batoceraPaths.py")
if "CONFIGS" not in cg.paths:
src.fail("no CONFIGS path constant")
wanted: set[str] = {"CONFIGS", "BIOS"}
spans = []
for node in reversed(src.tree.body):
name = _assigned_name(node)
if name in wanted and isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value:
spans.append((node.lineno, node.end_lineno or node.lineno))
wanted |= {n.id for n in ast.walk(node.value) if isinstance(n, ast.Name)}
return {
"path": cg.paths["CONFIGS"],
"from_bios": posixpath.relpath(cg.paths["CONFIGS"], cg.bios),
"source_ref": src.ref(spans),
}
def _path_value(node: ast.AST | None, constants: dict[str, str]) -> str | None:
if isinstance(node, ast.Name):
return constants.get(node.id)
@@ -81,9 +131,10 @@ def _path_value(node: ast.AST | None, constants: dict[str, str]) -> str | None:
and isinstance(node.func, ast.Name)
and node.func.id == "Path"
and len(node.args) == 1
and isinstance(node.args[0], ast.Constant)
):
return str(node.args[0].value)
if isinstance(node.args[0], ast.Constant):
return str(node.args[0].value)
return _path_value(node.args[0], constants)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
left = _path_value(node.left, constants)
right = node.right.value if isinstance(node.right, ast.Constant) else None
@@ -92,12 +143,18 @@ def _path_value(node: ast.AST | None, constants: dict[str, str]) -> str | None:
return None
def generator_names(source: Source) -> dict[str, list[str]]:
"""Generator module -> the emulator or core names configgen loads it for."""
def generator_names(source: Source, legacy_map: bool = True) -> dict[str, list[str]]:
"""Generator module -> the emulator or core names configgen loads it for.
legacy_map is false for a configgen written before _LEGACY_GENERATOR_MAP
(KNULLI's), whose importer has the plain map only.
"""
names: dict[str, list[str]] = {}
plain = source.literal(source.assignment("_GENERATOR_MAP").value, "_GENERATOR_MAP")
for emulator, (module, _cls) in plain.items():
names.setdefault(module, []).append(emulator)
if not legacy_map:
return names
legacy = source.literal(
source.assignment("_LEGACY_GENERATOR_MAP").value, "_LEGACY_GENERATOR_MAP"
)
@@ -108,14 +165,23 @@ def generator_names(source: Source) -> dict[str, list[str]]:
class Configgen:
"""The configgen tree at one revision, read through `read(path)`."""
"""The configgen tree at one revision, read through `read(path)`.
def __init__(self, read: Callable[[str], str]) -> None:
root is the configgen package in the repository: Batocera's by default,
KNULLI keeps the same package under its own path.
"""
def __init__(
self, read: Callable[[str], str], root: str = CONFIGGEN, legacy_map: bool = True
) -> None:
self._read = read
self._sources: dict[str, Source] = {}
self.paths = path_constants(self.source(PATHS_MODULE))
self.generators = f"{root}/generators"
self.importer = f"{self.generators}/importer.py"
self.libretro_options = f"{self.generators}/libretro/libretroOptions.py"
self.paths = path_constants(self.source(f"{root}/batoceraPaths.py"))
self.bios = self.paths["BIOS"]
self._generators = generator_names(self.source(IMPORTER))
self._generators = generator_names(self.source(self.importer), legacy_map)
def source(self, path: str) -> Source:
if path not in self._sources:
@@ -123,7 +189,7 @@ class Configgen:
return self._sources[path]
def generator(self, module: str) -> Source:
return self.source(f"{GENERATORS}/{module.replace('.', '/')}.py")
return self.source(f"{self.generators}/{module.replace('.', '/')}.py")
def helper(self, module: str, helper: str) -> Source:
"""A module the generator imports with `from . import helper`."""
@@ -137,7 +203,7 @@ class Configgen:
)
if not imported:
generator.fail(f"does not import {helper}")
return self.source(f"{GENERATORS}/{module.split('.')[0]}/{helper}.py")
return self.source(f"{self.generators}/{module.split('.')[0]}/{helper}.py")
def emulator(self, module: str) -> str:
"""The name configgen loads `module` for (importer.get_generator)."""
@@ -146,7 +212,7 @@ class Configgen:
package, _, leaf = module.partition(".")
if leaf == f"{package}Generator":
return package
raise LauncherShapeError(f"{IMPORTER}: no emulator loads {module}")
raise LauncherShapeError(f"{self.importer}: no emulator loads {module}")
def bios_name(self, node: ast.AST) -> str | None:
"""The name under /userdata/bios that an expression spells, if any."""
@@ -386,15 +452,28 @@ def _loaded(stmt: ast.stmt) -> set[str]:
return {n.id for n in ast.walk(value) if isinstance(n, ast.Name)} if value else set()
def _branch_assigns(stmt: ast.stmt, needed: set[str]) -> bool:
"""Whether an if statement assigns one of the names in any branch."""
return isinstance(stmt, ast.If) and any(
_assigned_name(node) in needed for node in ast.walk(stmt)
)
def _trace(body: list[ast.stmt], index: int) -> list[ast.stmt]:
"""body[index], the assignments before it that bind what it reads, and
the command-line additions that read any of those names."""
the command-line additions that read any of those names.
An if statement that assigns a name read is kept with what its test
reads (KNULLI's `if system.isOptSet("model") ...: machine = ...`)."""
kept = [body[index]]
needed = _loaded(body[index])
for stmt in reversed(body[:index]):
if _assigned_name(stmt) in needed:
kept.append(stmt)
needed |= _loaded(stmt)
elif _branch_assigns(stmt, needed):
kept.append(stmt)
needed |= {n.id for n in ast.walk(stmt) if isinstance(n, ast.Name)}
produced = {name for stmt in kept if (name := _assigned_name(stmt)) is not None}
kept += [
stmt for stmt in body
@@ -435,15 +514,23 @@ def hatari(cg: Configgen) -> list[Launcher]:
and isinstance(parts[1], ast.FormattedValue)
):
marker, prefix = node.test.left.value, parts[0].value
tos_path = src.assignment("tos_path", finder).value
if isinstance(tos_path, ast.BinOp) and isinstance(tos_path.right, ast.JoinedStr):
parts = tos_path.right.values
if (
len(parts) == 3
and all(isinstance(p, ast.FormattedValue) for p in parts[:2])
and isinstance(parts[2], ast.Constant)
):
suffix = parts[2].value
# Batocera: tos_path = biosdir / f"{biosversion}{v_language}.img".
# KNULLI: filename = f"{biosversion}{v_language}.img".
names_built = []
for node in ast.walk(finder):
value = node.value if isinstance(node, ast.Assign) else None
if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Div):
value = value.right
if isinstance(value, ast.JoinedStr):
parts = value.values
if (
len(parts) == 3
and all(isinstance(p, ast.FormattedValue) for p in parts[:2])
and isinstance(parts[2], ast.Constant)
):
names_built.append(parts[2].value)
if len(names_built) == 1:
suffix = names_built[0]
if marker is None or prefix is None or suffix is None:
src.fail("findBestTos no longer builds <tos{version} or etos*><language>.img")
raised = [node for node in ast.walk(finder) if isinstance(node, ast.Raise)]
@@ -466,10 +553,7 @@ def hatari(cg: Configgen) -> list[Launcher]:
src.fail("findBestTos is not given a batoceraPaths directory")
search_dir = cg.paths[call.args[0].id]
kept = _trace(body, index)
options = [
option[0] for stmt in kept for node in ast.walk(stmt)
if (option := _config_option(node)) is not None
]
options = list(dict.fromkeys(name for stmt in kept for name in _option_names(stmt)))
spans = [src.span(stmt) for stmt in kept] + [(finder.lineno, finder.end_lineno)]
args: list[str] = []
second = call.args[1] if len(call.args) > 1 else None
@@ -484,6 +568,14 @@ def hatari(cg: Configgen) -> list[Launcher]:
args.append("{machine}")
elif isinstance(element, ast.Name) and element.id == tos_var:
args.append("<file>")
elif (
isinstance(element, ast.BinOp)
and isinstance(element.right, ast.Name)
and element.right.id == tos_var
and _path_value(element.left, cg.paths) == search_dir
):
# KNULLI returns the bare name and joins it: BIOS / tos.
args.append("<file>")
if "<file>" not in args:
src.fail("the TOS findBestTos returns is not put on the command line")
@@ -563,7 +655,7 @@ def melonds(cg: Configgen) -> list[Launcher]:
node for node in ast.walk(generate)
if isinstance(node, ast.Assign)
and ast.unparse(node.targets[0]).startswith("base_config['Emu']['ConsoleType']")
and _config_option(node.value) is not None
and _option_names(node.value)
]
enabled = [
(key, value) for key, value in zip(base.value.keys, base.value.values)
@@ -574,7 +666,7 @@ def melonds(cg: Configgen) -> list[Launcher]:
]
if len(console) != 1 or len(enabled) != 1:
src.fail("generate no longer sets Emu.ConsoleType from an option with external BIOS on")
option = _config_option(console[0].value)[0] # type: ignore[index]
option = _option_names(console[0].value)[0] # type: ignore[attr-defined]
launchers = []
for key, value in zip(base.value.keys, base.value.values):
if not isinstance(value, ast.Dict) or not isinstance(key, ast.Constant):
@@ -647,6 +739,153 @@ def gsplus(cg: Configgen) -> list[Launcher]:
)]
def _system_branches(scope: ast.FunctionDef) -> list[tuple[str, ast.If]]:
"""(system, branch) for each `if system.name == "<name>":` of an if/elif chain."""
branches: list[tuple[str, ast.If]] = []
for node in ast.walk(scope):
if not isinstance(node, ast.If):
continue
test = node.test
if (
isinstance(test, ast.Compare)
and ast.unparse(test.left) == "system.name"
and len(test.ops) == 1
and isinstance(test.ops[0], ast.Eq)
and isinstance(test.comparators[0], ast.Constant)
):
branches.append((str(test.comparators[0].value), node))
return sorted(branches, key=lambda item: item[1].lineno)
def _guard_message(
src: Source, scope: ast.FunctionDef, test: Callable[[ast.expr], bool], what: str
) -> tuple[ast.If, ast.JoinedStr]:
"""The `if not <test>: raise X(f"...")` statement and its message."""
found = [
node for node in ast.walk(scope)
if isinstance(node, ast.If)
and isinstance(node.test, ast.UnaryOp)
and isinstance(node.test.op, ast.Not)
and test(node.test.operand)
and isinstance(node.body[0], ast.Raise)
and isinstance(node.body[0].exc, ast.Call)
and node.body[0].exc.args
and isinstance(node.body[0].exc.args[0], ast.JoinedStr)
]
if len(found) != 1:
src.fail(f"expected one `if not {what}: raise` with a message, found {len(found)}")
return found[0], found[0].body[0].exc.args[0] # type: ignore[attr-defined, no-any-return]
def _render(message: ast.JoinedStr, var: str, value: str) -> str:
parts = []
for part in message.values:
if isinstance(part, ast.Constant):
parts.append(str(part.value))
elif isinstance(part.value, ast.Name) and part.value.id == var: # type: ignore[attr-defined]
parts.append(value)
else:
parts.append(f"{{{ast.unparse(part.value)}}}") # type: ignore[attr-defined]
return "".join(parts)
def lexaloffle(cg: Configgen) -> list[Launcher]:
"""Per system, a binary under BIOS run as the command, its folder on LD_LIBRARY_PATH."""
module = "lexaloffle.lexaloffleGenerator"
src = cg.generator(module)
constants = dict(cg.paths)
defined: dict[str, ast.stmt] = {}
for node in src.tree.body:
name = _assigned_name(node)
value = _path_value(getattr(node, "value", None), constants)
if name is not None and value is not None:
constants[name] = value
defined[name] = node
generate = src.function("generate")
command = [
node for node in ast.walk(generate)
if _assigned_name(node) == "commandArray"
and isinstance(node.value, ast.List) # type: ignore[attr-defined]
and node.value.elts # type: ignore[attr-defined]
and isinstance(node.value.elts[0], ast.Name) # type: ignore[attr-defined]
]
if len(command) != 1:
src.fail("generate no longer starts commandArray with the binary it runs")
binary = command[0].value.elts[0].id # type: ignore[attr-defined]
exists, missing = _guard_message(src, generate, lambda test: (
isinstance(test, ast.Call)
and isinstance(test.func, ast.Attribute)
and test.func.attr == "exists"
and ast.unparse(test.func.value) == binary
), f"{binary}.exists()")
access, not_executable = _guard_message(src, generate, lambda test: (
isinstance(test, ast.Call)
and ast.unparse(test.func) == "os.access"
and [ast.unparse(a) for a in test.args] == [binary, "os.X_OK"]
), f"os.access({binary}, os.X_OK)")
env = [
(key, value)
for call in _calls(generate, attr="Command")
for keyword in call.keywords
if keyword.arg == "env" and isinstance(keyword.value, ast.Dict)
for key, value in zip(keyword.value.keys, keyword.value.values)
if isinstance(key, ast.Constant) and key.value == "LD_LIBRARY_PATH"
]
if len(env) != 1:
src.fail("the command environment no longer sets LD_LIBRARY_PATH once")
env_key, env_value = env[0]
prefix = cg.bios.rstrip("/") + "/"
launchers = []
for system, branch in _system_branches(generate):
bound = {
name: (stmt, _path_value(stmt.value, constants)) # type: ignore[attr-defined]
for stmt in branch.body if (name := _assigned_name(stmt)) is not None
}
if binary not in bound or not (bound[binary][1] or "").startswith(prefix):
src.fail(f"the {system} branch binds {binary} to no path under {cg.bios}")
library = [
name for name in bound
if name != binary and _mentions(env_value, set(), {name})
]
if len(library) != 1 or not (bound[library[0]][1] or "").startswith(prefix):
src.fail(f"the {system} branch puts no /userdata/bios folder on LD_LIBRARY_PATH")
binary_stmt, path = bound[binary]
library_stmt, folder = bound[library[0]]
test_line = src.span(branch.test)
constant = next(
(defined[n.id] for n in ast.walk(binary_stmt.value) # type: ignore[attr-defined]
if isinstance(n, ast.Name) and n.id in defined),
None,
)
spans = [test_line, src.span(binary_stmt), src.span(exists), src.span(access),
src.span(command[0])]
if constant is not None:
spans.append(src.span(constant))
launchers.append(Launcher(
emulator=cg.emulator(module), build="standalone", kind="fixed_name",
slot=system, names=[path[len(prefix):]], # type: ignore[index]
case_insensitive=False, search_dir=cg.bios, passes_to="exec",
fails_when_missing=", ".join(
_render(message, binary, path) for message in (missing, not_executable) # type: ignore[arg-type]
),
source_ref=src.ref(spans),
))
launchers.append(Launcher(
emulator=cg.emulator(module), build="standalone", kind="passes_path",
slot=system, names=[], search_dir=folder,
passes_to="environment LD_LIBRARY_PATH",
source_ref=src.ref([test_line, src.span(library_stmt),
(src.span(env_key)[0], src.span(env_value)[1])]),
))
if not launchers:
src.fail("generate no longer branches on system.name")
return launchers
# Generators that hand a directory of /userdata/bios to the emulator through
# its environment, with the module holding their path constants.
ENVIRONMENT_GENERATORS = (("flycast.flycastGenerator", "flycast/flycastPaths.py"),)
@@ -658,7 +897,7 @@ def environment_directories(cg: Configgen) -> list[Launcher]:
for module, paths_module in ENVIRONMENT_GENERATORS:
src = cg.generator(module)
constants = dict(cg.paths)
for node in cg.source(f"{GENERATORS}/{paths_module}").tree.body:
for node in cg.source(f"{cg.generators}/{paths_module}").tree.body:
name = _assigned_name(node)
value = _path_value(getattr(node, "value", None), constants)
if name is not None and value is not None:
@@ -687,13 +926,106 @@ def environment_directories(cg: Configgen) -> list[Launcher]:
return launchers
# Generators that hand the emulator a /userdata/bios directory through its
# configuration: a line written to the file it reads (openMSX's script.tcl
# filepool entries, xroar.conf rompath) or a key saved to it (amiberry.conf
# rom_path).
CONFIG_DIRECTORY_GENERATORS = (
"openmsx.openmsxGenerator",
"xroar.xroarGenerator",
"amiberry.amiberryGenerator",
)
def config_directories(
cg: Configgen, modules: Iterable[str] = CONFIG_DIRECTORY_GENERATORS
) -> list[Launcher]:
"""file.write("<line naming a BIOS directory>") and settings.save(key, <BIOS directory>)."""
prefix = cg.bios.rstrip("/")
inside = re.compile(rf"{re.escape(prefix)}(?:/[^\s\"';]*)?")
launchers: list[Launcher] = []
for module in modules:
src = cg.generator(module)
constants = dict(cg.paths)
for node in src.tree.body:
name = _assigned_name(node)
value = _path_value(getattr(node, "value", None), constants)
if name is not None and value is not None:
constants[name] = value
found: list[tuple[str, str, ast.Call]] = []
for call in _calls(src.tree, attr="write"):
text = call.args[0] if len(call.args) == 1 else None
if isinstance(text, ast.Constant) and isinstance(text.value, str):
line = text.value.strip()
named = inside.search(line)
if named:
found.append((named.group(0), f"written: {line}", call))
for call in _calls(src.tree, attr="save"):
if len(call.args) != 2 or not isinstance(call.args[0], ast.Constant):
continue
value = _path_value(call.args[1], constants)
if value is None and isinstance(call.args[1], ast.Constant):
value = str(call.args[1].value)
if value is not None and (value.rstrip("/") == prefix or value.startswith(prefix + "/")):
found.append((value, str(call.args[0].value), call))
if not found:
src.fail("hands no /userdata/bios directory through its config")
launchers.extend(
Launcher(
emulator=cg.emulator(module), build="standalone", kind="passes_path",
names=[], search_dir=directory, passes_to=passes_to,
source_ref=src.ref([src.span(call)]),
)
for directory, passes_to, call in found
)
return launchers
# Generators that start the emulator from a folder they enter with os.chdir.
WORKING_DIRECTORY_GENERATORS = ("shadps4.shadps4Generator",)
def working_directories(cg: Configgen) -> list[Launcher]:
"""os.chdir(<folder>) in the generators above, before the command runs.
shadPS4 reads its user folder below the working directory first
(std::filesystem::current_path() / "user"): Batocera enters
/userdata/system/configs/shadps4.
"""
launchers = []
for module in WORKING_DIRECTORY_GENERATORS:
src = cg.generator(module)
calls = _calls(src.tree, attr="chdir")
if len(calls) != 1 or len(calls[0].args) != 1:
src.fail("one os.chdir(folder) call")
call = calls[0]
argument = call.args[0]
constants = dict(cg.paths)
spans = [(call.lineno, call.end_lineno or call.lineno)]
if isinstance(argument, ast.Name):
scope = _innermost_function(src, call)
assigned = src.assignment(argument.id, scope)
spans.append((assigned.lineno, assigned.end_lineno or assigned.lineno))
folder = _path_value(assigned.value, constants)
else:
folder = _path_value(argument, constants)
if folder is None:
src.fail("os.chdir names no folder")
launchers.append(Launcher(
emulator=cg.emulator(module), build="standalone", kind="passes_path", names=[],
search_dir=folder, passes_to=f"working directory of {src.path.rsplit('/', 1)[-1]}",
source_ref=src.ref(spans),
))
return launchers
# libretro core options whose value names the BIOS file the core opens.
FILE_OPTIONS = (("_o2em_options", "o2em_bios"), ("_neocd_options", "neocd_bios"))
def core_file_options(cg: Configgen) -> list[Launcher]:
"""_set_from_system(settings, option, system, [name], default=...)."""
src = cg.source(LIBRETRO_OPTIONS)
src = cg.source(cg.libretro_options)
table = src.assignment("_option_functions").value
if not isinstance(table, ast.Dict):
src.fail("_option_functions is not a dict display")
@@ -739,13 +1071,64 @@ def core_file_options(cg: Configgen) -> list[Launcher]:
return launchers
# Batocera's patches to an emulator's own path code. Each rewrites one of
# the folders the emulator derives (yuzu's family generates every user
# folder through Generate<Name>Path) to a fixed folder of /userdata.
PATH_PATCHES = ("package/batocera/emulators/citron/002-adjust-paths.patch",)
_PATH_REMOVED = re.compile(r"^-\s*Generate\w+Path\((\w+Path::\w+),\s*(.+?)\);\s*$")
_PATH_ADDED = re.compile(r'^\+\s*Generate\w+Path\((\w+Path::\w+),\s*"(/userdata/bios[^"]*)"\);\s*$')
def patched_paths(read: Callable[[str], str], patches: tuple[str, ...] = PATH_PATCHES) -> list[Launcher]:
"""The emulator folders a path patch points at the BIOS tree.
A patch line replacing `GenerateCitronPath(CitronPath::KeysDir,
citron_path / KEYS_DIR)` with a literal /userdata/bios folder hands
that folder to the emulator under the handle the code names it by.
The emulator is the package the patch belongs to.
"""
launchers: list[Launcher] = []
for path in patches:
lines = read(path).splitlines()
target = ""
removed: dict[str, tuple[int, str]] = {}
found = 0
for number, line in enumerate(lines, 1):
if line.startswith("+++ b/"):
target = line[len("+++ b/"):]
continue
if (old := _PATH_REMOVED.match(line)):
removed[old.group(1)] = (number, old.group(2))
continue
if not (new := _PATH_ADDED.match(line)):
continue
handle, folder = new.groups()
if handle not in removed or not target:
raise LauncherShapeError(f"{path}:{number}: {handle} added with no line it replaces")
first, original = removed[handle]
found += 1
launchers.append(Launcher(
emulator=path.split("/")[-2], build="standalone", kind="passes_path", names=[],
search_dir=folder,
passes_to=f"{handle} in {target} returns it, in place of {original}",
fails_when_missing=False,
source_ref=f"{path}:{first}-{number}",
))
if not found:
raise LauncherShapeError(f"{path}: no path rewritten to /userdata/bios")
return launchers
EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = (
duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus,
x16emu, gsplus, environment_directories, core_file_options,
x16emu, gsplus, lexaloffle, environment_directories, config_directories,
core_file_options, working_directories,
)
def extract_launchers(read: Callable[[str], str]) -> list[dict[str, Any]]:
"""Every launcher record, read through `read(repo-relative path)`."""
cg = Configgen(read)
return [launcher.entry() for extractor in EXTRACTORS for launcher in extractor(cg)]
launchers = [launcher for extractor in EXTRACTORS for launcher in extractor(cg)]
launchers += patched_paths(read)
return [launcher.entry() for launcher in launchers]
+113 -11
View File
@@ -14,10 +14,11 @@ import re
import sys
import urllib.error
import urllib.request
from collections.abc import Callable
from pathlib import Path
from common import yaml_load
from common import load_target_config, yaml_load
from hashing import cut_md5_owner
from safeparse import yaml_load_upstream
@@ -28,7 +29,9 @@ from .base_scraper import (
md5_field,
requirement_entry,
)
from .batocera_launchers import extract_launchers
from .batocera_launchers import Configgen, configs_root, extract_launchers
from .targets.batocera_targets_scraper import BuildSelection
from .targets.batocera_targets_scraper import Scraper as BoardTargets
PLATFORM_NAME = "batocera"
@@ -168,7 +171,8 @@ def es_systems_libretro_systems(text: str, dropped: set[str]) -> dict[str, list[
def es_systems_by_system(
es_systems_text: str, defaults_text: str, dropped: set[str]
es_systems_text: str, defaults_text: str, dropped: set[str],
built: Callable[[str], bool] | None = None,
) -> dict[str, list[str]]:
"""system -> the cores es_systems.yml offers it, in the file's order.
@@ -195,6 +199,10 @@ def es_systems_by_system(
default = defaults.get(system)
if isinstance(default, dict):
emulator, core = default.get("emulator"), default.get("core")
if (built is not None and emulator and emulator != "libretro"
and str(emulator) not in names and not built(str(emulator))):
# A default the build lacks (configgen_cores).
emulator = core = None
if emulator and emulator != "libretro":
names.append(str(emulator))
if core:
@@ -472,7 +480,8 @@ def platform_systems(
def configgen_cores(
defaults_text: str, es_systems_text: str, dropped: set[str]
defaults_text: str, es_systems_text: str, dropped: set[str],
built: Callable[[str], bool] | None = None,
) -> tuple[list[str], list[str], list[str]]:
"""Core names, standalone cores and dual-mode cores of a configgen build.
@@ -481,27 +490,103 @@ def configgen_cores(
dual-mode ones those es_systems.yml also offers under libretro (dolphin
for GameCube). `dropped` names what es_systems.yml files under an
emulator without being one.
`built` says whether some board builds a name (boards_build). What it
answers no for is not run: KNULLI's es_systems lists citron and shadps4,
which no KNULLI board selects, and its defaults pick suyu, which no
package builds. A default's core follows a standalone emulator the
build runs (vox_official is lexaloffle's Voxatron build).
"""
data = yaml_load_upstream(defaults_text)
cores, standalone = es_systems_emulators(es_systems_text)
libretro = es_systems_libretro(es_systems_text)
if not cores:
raise RuntimeError("es_systems.yml names no emulator")
if built is not None:
cores = {name for name in cores if built(name)}
standalone &= cores
for system, cfg in data.items():
if system == "default" or not isinstance(cfg, dict):
continue
emulator = cfg.get("emulator", "")
core = cfg.get("core", "")
if core:
cores.add(core)
if emulator and emulator != "libretro":
if built is not None and not built(emulator):
continue
standalone.add(emulator)
if core and core != emulator:
standalone.add(core)
elif built is not None and core and not built(core):
continue
if core:
cores.add(core)
dual = (standalone & libretro) - dropped
return sorted(cores - dropped), sorted(standalone - dropped), sorted(dual)
def boards_build(selection: BuildSelection, launchers: list[dict]) -> Callable[[str], bool]:
"""Whether some board of a build runs an emulator.
es_systems.yml ties each name to the packages that build it
(requireAnyOf), and a name it lists is built when a board selects one of
them: KNULLI lists citron and shadps4, and its Config.in selects them
only on x86_64 boards, which it has none of. A name only
configgen-defaults.yml gives is built when a board selects the package
of that name: the symbol its Config.in defines, else the one Buildroot
derives from the folder (BR2_PACKAGE_<NAME>, package/pkg-generic.mk). A
program the launcher runs from the BIOS folder (lexaloffle,
/userdata/bios/pico-8/pico8) needs no package.
"""
supplied = {
name.strip()
for record in launchers
if record.get("passes_to") == "exec"
and str(record.get("search_dir") or "").startswith("/userdata/bios")
for name in str(record.get("emulator", "")).split(",")
}
def built(name: str) -> bool:
if name in selection.emulators or name in supplied:
return True
if name in selection.listed:
return False
symbol = selection.packages.get(name) or (
"BR2_PACKAGE_" + name.upper().replace("-", "_"))
return symbol in selection.symbols
return built
def installable_cores(
selection: BuildSelection, cores: list[str], standalone: list[str], dual: list[str],
) -> list[str]:
"""Buildbot cores a board's RetroArch core downloader installs, beyond
the libretro cores the image builds.
The targets scraper names, per board, the RetroArch targets entry its
downloader reads (`downloads`): a Batocera x86_64 board installs any
core of linux-x86_64, an armv7 one any of linux-armhf. A name compares
within its build mode: amiberry built standalone is another program
than amiberry_libretro, which the buildbot serves.
"""
found: set[str] = set()
for entry in selection.targets.values():
if entry.get("downloads"):
found |= load_target_config("retroarch", entry["downloads"]) or set()
built_libretro = (set(cores) - set(standalone)) | set(dual)
return sorted(found - built_libretro)
def records_run(launchers: list[dict], built: Callable[[str], bool]) -> list[dict]:
"""The launcher records of emulators the build runs.
configgen keeps the generators of emulators a build does not select
(KNULLI's citron, pcsx2, shadps4): what they read is never read on the
device, so the records do not describe the platform.
"""
return [r for r in launchers if any(built(n) for n in str(r["emulator"]).split(", "))]
class Scraper(BaseScraper):
"""Scraper for batocera-systems Python dict."""
@@ -509,12 +594,19 @@ class Scraper(BaseScraper):
self.tag = fetch_stable_tag()
super().__init__(url=SOURCE_URL.format(tag=self.tag))
def _fetch_cores(self) -> tuple[list[str], list[str], list[str]]:
"""Core names, standalone cores and dual-mode cores at the pinned tag."""
def _fetch_cores(
self, built: Callable[[str], bool] | None = None
) -> tuple[list[str], list[str], list[str]]:
"""Core names, standalone cores and dual-mode cores at the pinned tag.
With `built` (boards_build), a name counts when some board builds
it or its launcher runs a program from the BIOS folder.
"""
return configgen_cores(
self._fetch_text(CONFIGGEN_DEFAULTS_URL.format(tag=self.tag)),
self._fetch_text(ES_SYSTEMS_URL.format(tag=self.tag)),
_not_cores(),
built,
)
def _fetch_text(self, url: str) -> str:
@@ -548,7 +640,14 @@ class Scraper(BaseScraper):
SYSTEM_SLUG_MAP,
)
cores, standalone, dual = self._fetch_cores()
launchers = extract_launchers(
lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}")
)
selection = BoardTargets().build_selection(self.tag)
built = boards_build(selection, launchers)
launchers = records_run(launchers, built)
cores, standalone, dual = self._fetch_cores(built)
installable = installable_cores(selection, cores, standalone, dual)
libretro_systems = es_systems_libretro_systems(
self._fetch_text(ES_SYSTEMS_URL.format(tag=self.tag)), _not_cores()
)
@@ -568,10 +667,13 @@ class Scraper(BaseScraper):
if dual:
# Launched both ways: each layout gets its files.
result["dual_mode_cores"] = dual
if installable:
result["installable_cores"] = installable
# The systems each libretro core runs for: its save folders.
result["libretro_systems"] = libretro_systems
result["launchers"] = extract_launchers(
lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}")
result["launchers"] = launchers
result["configs_root"] = configs_root(
Configgen(lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}"))
)
return result
+3 -1
View File
@@ -35,6 +35,8 @@ class Launcher:
fails_scope: str | None = None
depends_on_option: str | None = None
device: str | None = None
# The machines the platform offers under the record's systems.
machines: list[str] | None = None
def entry(self) -> dict[str, Any]:
"""The platform YAML form, in a fixed key order, unset keys left out."""
@@ -42,7 +44,7 @@ class Launcher:
"emulator", "build", "kind", "slot", "names", "expands",
"case_insensitive", "search_dir", "passes_to",
"fails_when_missing", "fails_scope", "depends_on_option",
"device", "source_ref",
"device", "machines", "source_ref",
)
return {key: getattr(self, key) for key in order if getattr(self, key) is not None}
+741
View File
@@ -0,0 +1,741 @@
"""EmuELEC's launcher layer, read from its packages at the pinned tag.
emuelecRunEmu.sh starts every game. A libretro core runs in RetroArch (or
retrorun) with /storage/roms/bios as its system directory. A standalone
emulator runs from the script the `case ${PLATFORM}` arm names for the core
ES passes (`"${EMU}" = "flycastsa"`), and that script links, moves, copies
or passes the file. Some packages ship the config the emulator reads
(duckstation, hatari, advancemame, mame), others link a BIOS folder at
install time (amiberry, timidity). The emulator of each record is the core
name the dispatch tests, or the package that installs the link.
"""
from __future__ import annotations
import posixpath
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from .configgen_launchers import Launcher
from .rocknix_launchers import fixed_paths
from .shell_launchers import (
LauncherPatternMissing,
Link,
RepoTree,
Script,
describe_keys,
settings,
source_ref,
words,
)
SX05RE = "packages/sx05re"
BIN = f"{SX05RE}/emuelec/bin"
RUN_EMU = f"{BIN}/emuelecRunEmu.sh"
UTILS = f"{BIN}/emuelec-utils"
EMULATORS = f"{SX05RE}/emulators"
LIBRETRO = f"{SX05RE}/libretro"
RETROARCH_MK = f"{SX05RE}/libretro_base/retroarch/package.mk"
BIOS_ROOT = "/storage/roms/bios"
_EMU_TEST = re.compile(r'\$\{?EMU\}?"?\s*==?\s*"([^"]+)"')
_ARM = re.compile(r'^\s*((?:"[^"]+"\|?)+)\)\s*$')
_RUNTHIS = re.compile(r"""^\s*RUNTHIS\+?=(['"])(.*)\1\s*$""")
_SCRIPT = re.compile(r"[\w./-]+\.(?:sh|start)$")
_ADVMAME_INSTALL = re.compile(r"cp -r \$\{PKG_DIR\}/config/(advmame\.rc\w*) \S+/advmame\.rc$")
@dataclass
class Dispatch:
"""The cores emuelecRunEmu.sh hands to one script, and the lines that do it."""
emulators: list[str] = field(default_factory=list)
lines: set[int] = field(default_factory=set)
class Runs:
"""emuelecRunEmu.sh, read once: which core starts which script."""
def __init__(self, tree: RepoTree) -> None:
self.script = Script(RUN_EMU, tree.read(RUN_EMU))
self.by_script: dict[str, Dispatch] = {}
emu: tuple[str, int] | None = None
arm: tuple[list[str], int] | None = None
for number, line in self.script.code():
if found := _ARM.match(line):
arm = ([p.strip('"') for p in found.group(1).split("|") if p], number)
emu = None
continue
if re.match(r"\s*(;;|esac\b)", line):
arm, emu = None, None
continue
if found := _EMU_TEST.search(line):
emu = (found.group(1).removesuffix("_libretro"), number)
continue
if re.match(r"\s*fi\b", line):
emu = None
continue
run = _RUNTHIS.match(line)
command = words(run.group(2) if run else line)
name = next(
(posixpath.basename(w.text) for w in command if _SCRIPT.fullmatch(w.text)), None
)
if name is None or (emu is None and arm is None):
continue
names, at = ([emu[0]], emu[1]) if emu else arm # type: ignore[misc]
entry = self.by_script.setdefault(name, Dispatch())
entry.emulators.extend(n for n in names if n not in entry.emulators)
entry.lines |= {at, number}
if not self.by_script:
raise self.script.missing("script started for a core")
def of(self, name: str) -> Dispatch:
if name not in self.by_script:
raise self.script.missing(f"start of {name}")
return self.by_script[name]
def emulator(self, name: str) -> str:
return ", ".join(self.of(name).emulators)
def ref(self, name: str) -> tuple[str, set[int]]:
return (RUN_EMU, set(self.of(name).lines))
def _read(tree: RepoTree, path: str) -> Script:
return Script(path, tree.read(path))
def _under(path: str, root: str = BIOS_ROOT) -> bool:
return path.rstrip("/") == root or path.startswith(root + "/")
def retroarch(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""system_directory, written by sed into the retroarch.cfg the package installs."""
mk = _read(tree, RETROARCH_MK)
sed_line, sed = mk.find(
r"s/# system_directory =/system_directory =((?:\\/|[^/])+)/", "system_directory sed"
)
run_line, _ = runs.script.find(
r"RUNTHIS='\$\{RABIN\}.* -L /tmp/cores/\$\{EMU\}\.so --config \$\{RACONF\}",
"RetroArch start with --config",
)
return [
Launcher(
emulator="retroarch",
build="libretro",
kind="passes_path",
names=[],
source_ref=source_ref([(mk.path, {sed_line}), (RUN_EMU, {run_line})]),
search_dir=sed.group(1).replace("\\/", "/"),
passes_to="retroarch.cfg system_directory (--config)",
)
]
def retrorun(tree: RepoTree, runs: Runs) -> list[Launcher]:
script = runs.script
base_line, base = script.find(r'^\s*RUNTHIS="(\w+)"\s*$', "retrorun command")
test_line, _ = script.find(
rf'if \[\[ "\$\{{EMULATOR\}}" = "{base.group(1)}" \]\]', f"{base.group(1)} test"
)
run_line, run = script.find(r"RUNTHIS\+=.*\s(-d)\s+(\S+)\s", "retrorun -d", base_line)
return [
Launcher(
emulator=base.group(1),
build="libretro",
kind="passes_path",
names=[],
source_ref=source_ref([(RUN_EMU, {test_line, base_line, run_line})]),
search_dir=run.group(2),
passes_to=f"{base.group(1)} {run.group(1)}",
)
]
def freej2me(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""JAVA_HOME set for the core, and the jar its script copies beside the JDK."""
script = runs.script
test_line, test = script.find(r'if \[ \$\{EMU\} == "(\w+)_libretro" \]', "core test")
call_line, call = script.find(r"^\s*(\S+\.sh)\s*$", "script call", test_line)
java_line, java = script.find(r"^\s*(JAVA_HOME)='([^']+)'", "JAVA_HOME", test_line)
export_line, _ = script.find(rf"^\s*export {java.group(1)}\s*$", "export", java_line)
core = test.group(1)
path = f"{LIBRETRO}/{core}/scripts/{call.group(1)}"
started = _read(tree, path)
guard_line, guard = started.find(r'if \[\[ -f "([^"]+)" \]\]; then$', "JDK version test")
copy_line, copy = started.find(r"^\s*cp -rf (\S+) (\S+)$", "jar copy", guard_line)
source = copy.group(1)
return [
Launcher(
emulator=core,
build="libretro",
kind="passes_path",
names=[],
source_ref=source_ref(
[(RUN_EMU, {test_line, call_line, java_line, export_line}), (path, {guard_line})]
),
search_dir=java.group(2),
passes_to=f"environment {java.group(1)}",
),
Launcher(
emulator=core,
build="libretro",
kind="copies",
names=[posixpath.basename(source)],
source_ref=source_ref(
[(RUN_EMU, {test_line, call_line}), (path, {guard_line, copy_line})]
),
search_dir=posixpath.dirname(source),
passes_to=copy.group(2),
depends_on_option=f"{guard.group(1)} exists",
),
]
def pico8(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""The folder the user drops in bios/ is moved where the binary runs from."""
path = f"{BIN}/pico8.sh"
script = _read(tree, path)
guard_line, guard = script.find(r'if \[\[ -e "([^"]+)" \]\]; then$', "new version test")
folder = guard.group(1)
copy_line, copy = script.find(
rf"^\s*cp -rf {re.escape(folder)}/\* (\S+)$", "copy of the folder", guard_line
)
target = copy.group(1)
remove_line, _ = script.find(rf"^\s*rm -rf {re.escape(folder)}$", "removal", copy_line)
mode_line, mode = script.find(
rf"^\s*chmod \+x {re.escape(target)}/([^/\s]+)$", "executable bit", remove_line
)
binary = mode.group(1)
starts = {
n for n, line in script.code() if re.match(rf"\s*{re.escape(target)}/{binary} -", line)
}
if not starts:
raise script.missing(f"start of {target}/{binary}")
name = posixpath.basename(path)
return [
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="moves",
names=[binary],
source_ref=source_ref(
[runs.ref(name), (path, {guard_line, copy_line, remove_line, mode_line, *starts})]
),
case_insensitive=False,
search_dir=folder,
passes_to=target,
fails_when_missing=False,
depends_on_option=f"{folder} exists",
)
]
def link_record(emulator: str, script: Script, link: Link, extra: set[int]) -> Launcher:
return Launcher(
emulator=emulator,
build="standalone",
kind="links",
names=list(link.names),
source_ref=source_ref([(script.path, link.lines | extra)]),
expands=link.expands or None,
search_dir=link.bios_side,
passes_to=f"symlink {link.link} -> {link.target}",
fails_when_missing=False,
)
def _link_guard(script: Script, link: Link) -> set[int]:
"""The `[ ! -L <link> ]` test the link waits for, when it is the one just above."""
first = max(link.lines)
tests = [
number
for number, line in script.code()
if number < first
and re.search(r"\[ ! -L ", line)
and link.link in script.expand(line, number, set())
]
return {tests[-1]} if tests and first - tests[-1] <= 4 else set()
# Start scripts whose ln -s commands put a BIOS folder where the emulator reads.
LINK_SCRIPTS = (
f"{EMULATORS}/flycastsa/scripts/flycast.sh",
f"{EMULATORS}/flycast-dojo/scripts/flycastdojo.sh",
)
# Packages whose package.mk links a BIOS folder into the installed tree.
LINK_PACKAGES = (
f"{EMULATORS}/amiberry",
f"{EMULATORS}/amiberry-lite",
f"{SX05RE}/tools/sound/timidity",
)
def links(tree: RepoTree, runs: Runs) -> list[Launcher]:
records = []
for path in LINK_SCRIPTS:
script = _read(tree, path)
found = script.links(BIOS_ROOT)
if not found:
raise script.missing(f"ln -s of a path under {BIOS_ROOT}")
name = posixpath.basename(path)
for link in found:
record = link_record(runs.emulator(name), script, link, _link_guard(script, link))
record.source_ref = f"{source_ref([runs.ref(name)])}, {record.source_ref}"
records.append(record)
for package in LINK_PACKAGES:
script = _read(tree, f"{package}/package.mk")
found = script.links(BIOS_ROOT, fixed={"INSTALL": ""})
if not found:
raise script.missing(f"ln -s of a path under {BIOS_ROOT}")
records.extend(
link_record(posixpath.basename(package), script, link, set()) for link in found
)
return records
def duckstation(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""The settings.ini the package ships hands a directory and leaves the paths empty."""
package = f"{EMULATORS}/duckstation"
path = f"{package}/config/settings.ini"
found = {(s.section, s.key): s for s in settings(tree.read(path))}
folder_key = ("BIOS", "SearchDirectory")
file_keys = (("BIOS", "PathNTSCU"), ("BIOS", "PathNTSCJ"), ("BIOS", "PathPAL"))
if folder_key not in found:
raise LauncherPatternMissing(path, "[BIOS] SearchDirectory")
set_files = [k for k in file_keys if k in found and found[k].value]
if set_files:
raise LauncherPatternMissing(path, f"empty [BIOS] {set_files[0][1]}")
mk = _read(tree, f"{package}/package.mk")
mk_line, _ = mk.find(r"cp -rf \$\{PKG_DIR\}/config/\* ", "config install")
script = _read(tree, f"{package}/scripts/duckstation.sh")
run_line, _ = script.find(r'-settings "\$\{CONFIG_DIR\}/settings\.ini"', "-settings")
name = posixpath.basename(script.path)
lines = {found[k].line for k in (folder_key, *file_keys) if k in found}
unset = describe_keys("", file_keys).strip()
return [
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="scans_dir",
names=[],
source_ref=source_ref(
[runs.ref(name), (path, lines), (mk.path, {mk_line}), (script.path, {run_line})]
),
search_dir=found[folder_key].value,
passes_to=f"settings.ini [BIOS] SearchDirectory (-settings), {unset} unset",
)
]
YABASANSHIRO_SCRIPTS = (
f"{EMULATORS}/yabasanshiroSA_1_11/scripts/yabasanshiro.sh",
f"{EMULATORS}/yabasanshiroSA_1_5/scripts/yabasanshiro1_5.sh",
)
def yabasanshiro(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""The BIOS flag, set when the HLE option is off and the file exists."""
records = []
for path in YABASANSHIRO_SCRIPTS:
script = _read(tree, path)
option_line, option = script.find(r"^\s*(\w+)=\$\(get_ee_setting (\w+) ", "HLE option")
test_line, test = script.find(
rf'if \[ "\$\{{{option.group(1)}\}}" != (\w+) \]', "HLE option test", option_line
)
exists_line, exists = script.find(r'if \[ -e "([^"]+)" \]', "BIOS test", test_line)
bios = exists.group(1)
flag_line, flag = script.find(
rf'^\s*(\w+)="(-\w) {re.escape(bios)}"$', "BIOS flag", exists_line
)
run_line, run = script.find(
rf"\{{\s*(\S+) .*\$\{{{flag.group(1)}\}}", "start with the flag", flag_line
)
name = posixpath.basename(path)
records.append(
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="fixed_name",
names=[posixpath.basename(bios)],
source_ref=source_ref(
[
runs.ref(name),
(path, {option_line, test_line, exists_line, flag_line, run_line}),
]
),
case_insensitive=False,
search_dir=posixpath.dirname(bios),
passes_to=f"{run.group(1)} {flag.group(2)}",
fails_when_missing=False,
depends_on_option=f"{option.group(2)} != {test.group(1)}",
)
)
return records
def jzintv(tree: RepoTree, runs: Runs) -> list[Launcher]:
path = f"{EMULATORS}/jzintv/scripts/jzintv.sh"
script = _read(tree, path)
run_line, run = script.find(r"^\s*exec (\S+) .*\s(-p)\s+(\S+)\s", "start with -p")
name = posixpath.basename(path)
return [
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="passes_path",
names=[],
source_ref=source_ref([runs.ref(name), (path, {run_line})]),
search_dir=run.group(3).rstrip("/"),
passes_to=f"{run.group(1)} {run.group(2)}",
)
]
def mupen64plus(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""The 64DD IPL names, tried in order, passed only beside a .ndd disk."""
path = f"{EMULATORS}/mupen64plussa/mupen64plussa-core/m64p.sh"
script = _read(tree, path)
fallbacks = [
(number, found)
for number, line in script.code()
if (found := re.search(r'\[ ! -f "\$\{(\w+)\}" \] && \1="([^"]+)"', line))
]
if not fallbacks:
raise script.missing("fallback name test")
var = fallbacks[0][1].group(1)
first = script.value(var, fallbacks[0][0])
if first is None:
raise script.missing(f"assignment of {var}")
used = {first.line, *(n for n, _ in fallbacks)}
paths = [
script.expand(v, n, used)
for n, v in [(first.line, first.value), *((n, f.group(2)) for n, f in fallbacks)]
]
folders = {posixpath.dirname(p) for p in paths}
if len(folders) != 1:
raise script.missing(f"one folder for the {var} names")
test_line, test = script.find(
rf'if \[ -f "([^"]+)" \] && \[ -f "\$\{{{var}\}}" \]', "disk and IPL test"
)
passes = [
(number, found)
for number, line in script.code()
if (found := re.search(rf'^\s*(\S+) .*(--[\w-]+) "\$\{{{var}\}}"', line))
]
if not passes:
raise script.missing(f"flag that hands {var}")
flags = sorted({f"{f.group(1)} {f.group(2)}" for _, f in passes})
name = posixpath.basename(path)
return [
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="names_searched",
names=[posixpath.basename(p) for p in paths],
source_ref=source_ref(
[runs.ref(name), (path, used | {test_line} | {n for n, _ in passes})]
),
case_insensitive=False,
search_dir=folders.pop(),
passes_to=", ".join(flags),
fails_when_missing=False,
depends_on_option=f'-f "{test.group(1)}"',
)
]
def hatari(tree: RepoTree, runs: Runs) -> list[Launcher]:
package = f"{EMULATORS}/hatarisa"
script = _read(tree, f"{package}/scripts/hatari.start")
copy_line, copy = script.find(r'^\s*cp \S+/([\w.-]+\.cfg) "\$(\w+)"', "default config copy")
run_lines = {
n
for n, line in script.code()
if n > copy_line and f'--configfile "${copy.group(2)}"' in line
}
if not run_lines:
raise script.missing(f"--configfile ${copy.group(2)}")
path = f"{package}/config/conf/{copy.group(1)}"
found = settings(tree.read(path))
keys = [
(s.section, s.key)
for s in found
if _under(s.value) and "." in posixpath.basename(s.value)
]
if not keys:
raise LauncherPatternMissing(path, f"key naming a file under {BIOS_ROOT}")
value, lines = fixed_paths(found, keys, path)
mk = _read(tree, f"{package}/package.mk")
mk_line, _ = mk.find(r"cp -R \$\{PKG_DIR\}/config/\* ", "config install")
name = posixpath.basename(script.path)
return [
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="fixed_name",
names=list(value.names),
source_ref=source_ref(
[runs.ref(name), (path, lines), (mk.path, {mk_line}),
(script.path, {copy_line, *run_lines})]
),
search_dir=value.search_dir,
passes_to=f"{describe_keys(copy.group(1), value.keys)} (--configfile)",
)
]
def advancemame(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""Folder keys of the advmame.rc package.mk installs, one per device branch."""
package = f"{EMULATORS}/advancemame"
mk = _read(tree, f"{package}/package.mk")
installs = [
(number, found.group(1))
for number, line in mk.code()
if (found := _ADVMAME_INSTALL.search(line))
]
if not installs:
raise mk.missing("advmame.rc install")
by_value: dict[tuple[tuple[str, str], ...], list[tuple[str, set[int], int]]] = {}
for mk_line, file in installs:
path = f"{package}/config/{file}"
keys = [s for s in settings(tree.read(path), separator=" ") if _under(s.value)]
if not keys:
raise LauncherPatternMissing(path, f"key naming a folder under {BIOS_ROOT}")
value = tuple((s.key, s.value) for s in keys)
by_value.setdefault(value, []).append((path, {s.line for s in keys}, mk_line))
if len(by_value) != 1:
raise mk.missing("one set of BIOS folders for every device")
((value, configs),) = by_value.items()
script = _read(tree, f"{package}/bin/advmame.sh")
run_line, _ = script.find(r"^\s*(?:\w+=\S+ )*advmame ", "advmame start")
name = posixpath.basename(script.path)
refs = [
runs.ref(name),
*((path, lines) for path, lines, _ in configs),
(mk.path, {mk_line for _, _, mk_line in configs}),
(script.path, {run_line}),
]
return [
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="passes_path",
names=[],
source_ref=source_ref(refs),
search_dir=folder,
passes_to=f"advmame.rc {key}",
)
for key, folder in value
]
def mame(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""The files mame.sh copies into bios/mame, and the folders mame.ini hands the core."""
package = f"{LIBRETRO}/mame"
script = _read(tree, f"{package}/scripts/mame.sh")
name = posixpath.basename(script.path)
emulator = runs.emulator(name)
records = []
guard: tuple[int, str] | None = None
for number, line in script.code():
if found := re.search(r'if \[ ! -f "([^"]+)" \]', line):
guard = (number, found.group(1))
continue
copy = re.match(r'\s*cp -rf "([^"]+)/"(\S+) "([^"]+)"\s*$', line)
if copy is None:
continue
if guard is None:
raise script.missing(f"test before the copy at line {number}")
if not _under(copy.group(3)):
continue
records.append(
Launcher(
emulator=emulator,
build="libretro",
kind="copies",
names=[copy.group(2)],
source_ref=source_ref([runs.ref(name), (script.path, {guard[0], number})]),
search_dir=copy.group(1),
passes_to=copy.group(3),
fails_when_missing=False,
depends_on_option=f"{guard[1]} absent",
)
)
if not records:
raise script.missing(f"copy into {BIOS_ROOT}")
ini = next((r for r in records if r.names == ["mame*"]), None)
if ini is None:
raise script.missing("copy of mame.ini")
option_path = f"{package}/config/MAME/MAME.opt"
option = next(
(s for s in settings(tree.read(option_path)) if s.key == "mame_read_config"), None
)
if option is None:
raise LauncherPatternMissing(option_path, "mame_read_config")
ini_path = f"{package}/config/ini/mame.ini"
# MAME reads from the folders its *path options name and writes to its
# *_directory ones.
folders = [
(s, [p for p in s.value.split(";") if _under(p)])
for s in settings(tree.read(ini_path), separator=" ")
if s.key.endswith("path")
]
folders = [(s, parts) for s, parts in folders if parts]
if not folders:
raise LauncherPatternMissing(ini_path, f"path under {BIOS_ROOT}")
for setting, parts in folders:
for part in parts:
records.append(
Launcher(
emulator=emulator,
build="libretro",
kind="passes_path",
names=[],
source_ref=source_ref(
[(ini_path, {setting.line}), (option_path, {option.line})]
) + f", {ini.source_ref}",
search_dir=part.rstrip("/"),
passes_to=f"mame.ini {setting.key}",
depends_on_option=f"mame_read_config = {option.value}",
)
)
return records
def scummvm(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""--extrapath for the standalone build, and the scummvm.ini copied for the core."""
path = f"{EMULATORS}/scummvmsa/bin/scummvm.start"
script = _read(tree, path)
name = posixpath.basename(path)
test_line, test = script.find(r'^\s*if \[ -d "([^"]+)" \]; then\s*$', "extra folder test")
set_line, set_ = script.find(r'^\s*(\w+)="(--[\w-]+)=([^"]+)"', "extra path", test_line)
run_line, run = script.find(rf"^\s*(\S+) .*\${set_.group(1)}\b", "start with it", set_line)
core_line, core = script.find(r"-L /tmp/cores/(\w+)_libretro\.so", "libretro start")
arm_line, arm = script.find(r'^\s*"(\w+)"\)', "setup arm", core_line)
copy_line, copy = script.find(r"^\s*cp (\S+) (\S+)$", "copy into the BIOS folder", arm_line)
used = {copy_line}
source = script.expand(copy.group(1), copy_line, used)
target = script.expand(copy.group(2), copy_line, used)
if not _under(target):
raise script.missing(f"copy into {BIOS_ROOT}")
return [
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="passes_path",
names=[],
source_ref=source_ref([runs.ref(name), (path, {test_line, set_line, run_line})]),
search_dir=set_.group(3),
passes_to=f"{run.group(1)} {set_.group(2)}",
depends_on_option=f"{test.group(1)} exists",
),
Launcher(
emulator=core.group(1),
build="libretro",
kind="copies",
names=[posixpath.basename(source)],
source_ref=source_ref([(path, {core_line, arm_line} | used)]),
search_dir=posixpath.dirname(source),
passes_to=target,
fails_when_missing=False,
depends_on_option=f"{name} {arm.group(1)}",
),
]
def dolphin(tree: RepoTree, runs: Runs) -> list[Launcher]:
path = f"{EMULATORS}/dolphinSA/scripts/dolphin.sh"
script = _read(tree, path)
run_line, run = script.find(r"\b(XDG_DATA_HOME)=(\S+) .*\bdolphin-emu", "XDG_DATA_HOME start")
name = posixpath.basename(path)
return [
Launcher(
emulator=runs.emulator(name),
build="standalone",
kind="passes_path",
names=[],
source_ref=source_ref([runs.ref(name), (path, {run_line})]),
search_dir=run.group(2),
passes_to=f"environment {run.group(1)}",
)
]
def midi(tree: RepoTree, runs: Runs) -> list[Launcher]:
"""The MIDI synth set_midi_source starts for the ra_midi_output setting."""
script = runs.script
setting_line, setting = script.find(
r'^\s*(\w+)=\$\(get_ee_setting "(\w*midi\w*)" ', "MIDI setting"
)
call_line, _ = script.find(
rf'set_midi_source "\$\{{{setting.group(1)}\}}"', "set_midi_source call", setting_line
)
utils = _read(tree, UTILS)
head_line, _ = utils.find(r"^\s*function set_midi_source\(\)", "set_midi_source")
end_line, _ = utils.find(r"^\s*esac\b", "end of the source case", head_line)
records = []
arm: tuple[int, str] | None = None
for number, line in utils.code():
if number <= head_line or number >= end_line:
continue
if found := re.match(r"\s*([\w-]+)\)\s*$", line):
arm = (number, found.group(1))
continue
command = [w.text for w in words(line)]
paths = [
(command[i - 1], value)
for i, value in enumerate(command)
if i and command[i - 1].startswith("-") and re.search(r"(^|/)bios(/|$)", value)
]
if not paths or arm is None:
continue
((flag, value),) = paths
is_file = "." in posixpath.basename(value) and _under(value)
records.append(
Launcher(
emulator=command[0],
build="standalone",
kind="fixed_name" if is_file else "passes_path",
names=[posixpath.relpath(value, BIOS_ROOT)] if is_file else [],
source_ref=source_ref(
[(RUN_EMU, {setting_line, call_line}), (UTILS, {head_line, arm[0], number})]
),
slot=arm[1],
case_insensitive=False if is_file else None,
search_dir=BIOS_ROOT if is_file else value.rstrip("/"),
passes_to=f"{command[0]} {flag}",
fails_when_missing=False,
depends_on_option=f"{setting.group(2)} = {arm[1]}",
)
)
if not records:
raise utils.missing("MIDI synth started with a BIOS path")
return records
EXTRACTORS: tuple[Callable[[RepoTree, Runs], list[Launcher]], ...] = (
retroarch,
retrorun,
freej2me,
mame,
pico8,
links,
duckstation,
yabasanshiro,
jzintv,
mupen64plus,
hatari,
advancemame,
scummvm,
dolphin,
midi,
)
def extract_launchers(tree: RepoTree) -> list[dict]:
"""Every launcher record the extractors find, in extractor order."""
runs = Runs(tree)
return [record.entry() for extractor in EXTRACTORS for record in extractor(tree, runs)]
+36
View File
@@ -43,6 +43,7 @@ from .batocera_scraper import (
platform_systems,
requirements_from_systems,
)
from .emuelec_launchers import extract_launchers
from .es_emulators import (
SystemEmulators,
attach_system_cores,
@@ -50,6 +51,7 @@ from .es_emulators import (
json_systems,
system_cores,
)
from .shell_launchers import RepoTree
PLATFORM_NAME = "emuelec"
@@ -120,6 +122,14 @@ BUILDS_OF = {
PORTS_SYSTEM = "ports"
def launcher_revision(url: str) -> str:
"""The tag or commit a batocera-systems URL is read at."""
found = re.search(rf"/{REPO}/([^/]+)/packages/", url)
if found is None:
raise ValueError(f"{url} names no {REPO} revision")
return found.group(1)
def port_engines(text: str) -> list[tuple[str, str, str | None, bool]]:
"""(port, engine, check_bios key, libretro?) for each entry of ports.yaml.
@@ -199,7 +209,18 @@ class Scraper(BaseScraper):
)
not_cores = _not_cores(PLATFORM_NAME)
cores, standalone, dual = es_cores(es_systems, RUNNERS, BUILDS_OF, not_cores)
# Each system keeps the list es_systems.json gives it: it says whose
# files a platform entry asks for. What runs is what an image carries.
attach_system_cores(systems, system_cores(es_systems, RUNNERS, not_cores))
built = self._built()
unbuilt = {n for n in cores if n not in built}
cores, standalone, dual = ([n for n in names if n in built] for names in (cores, standalone, dual))
# Runners (retroarch, retrorun) and the launcher's own spellings are
# no ES core name: only a record of an emulator no image carries goes.
launchers = [
r for r in extract_launchers(RepoTree.github(REPO, launcher_revision(self.url)))
if not set(str(r["emulator"]).split(", ")) <= unbuilt
]
return {
"platform": "EmuELEC",
"version": self.tag.removeprefix("v"),
@@ -215,8 +236,23 @@ class Scraper(BaseScraper):
"standalone_cores": standalone,
"dual_mode_cores": dual,
"systems": systems,
# Read at the revision the BIOS list comes from, for the
# emulators an image carries.
"launchers": launchers,
}
def _built(self) -> set[str]:
"""The cores at least one released device image carries.
Read by the targets scraper: a device keeps a core when a package it
builds installs it and its es_systems.cfg still offers it. The port
engines depend on emuelec-ports, which no package of an image
depends on, and flycast-dojo is in no device list (UNBUILT there).
"""
from .targets.emuelec_targets_scraper import Scraper as DeviceTargets
targets = DeviceTargets().device_targets(self.tag)["targets"]
return {name for entry in targets.values() for name in entry["cores"]}
def main() -> None:
from .base_scraper import scraper_cli
File diff suppressed because it is too large. Load diff
+53 -5
View File
@@ -19,9 +19,10 @@ Verification (knulli-systems at the release tag):
10834d00, es-app/src/ApiSystem.cpp:764-774) and by knulli-support:69.
es_systems.yml and configgen-defaults.yml have Batocera's shape, so the cores
are read with Batocera's code. The configgen generators do not: the hatari,
melonds and gsplus generators and libretroOptions.py are older forms than the
ones batocera_launchers reads, so no launcher layer is written.
are read with Batocera's code. The launcher layer comes from KNULLI's own
configgen package and board scripts at the same tag (knulli_launchers):
Batocera's extractors where a generator is still Batocera's code, KNULLI's
where it is older or its own.
"""
from __future__ import annotations
@@ -36,19 +37,29 @@ from .batocera_scraper import (
)
from .batocera_scraper import (
_not_cores,
boards_build,
configgen_cores,
es_systems_by_system,
es_systems_libretro_systems,
extract_systems_dict,
installable_cores,
platform_systems,
records_run,
requirements_from_systems,
)
from .es_emulators import attach_system_cores
from .targets.knulli_targets_scraper import Scraper as BoardTargets
from .batocera_launchers import configs_root
from .knulli_launchers import BATOCERA_SUBMODULE, extract_launchers
from .knulli_launchers import configgen as knulli_configgen
PLATFORM_NAME = "knulli"
REPO = "knulli-cfw/knulli-linux"
_RAW = "https://raw.githubusercontent.com/knulli-cfw/knulli-linux/{ref}/"
# The batocera.linux revision KNULLI's submodule pins, and its files.
_SUBMODULE = "https://api.github.com/repos/knulli-cfw/knulli-linux/contents/{path}?ref={ref}"
_BATOCERA_RAW = "https://raw.githubusercontent.com/batocera-linux/batocera.linux/{ref}/"
SOURCE_TEMPLATE = _RAW + "package/system/knulli-scripts/scripts/knulli-systems"
CONFIGGEN_DEFAULTS_TEMPLATE = (
_RAW + "package/system/knulli-configgen/configs/configgen-defaults.yml"
@@ -81,6 +92,23 @@ class Scraper(BaseScraper):
except urllib.error.URLError as e:
raise ConnectionError(f"Failed to fetch {url}: {e}") from e
def submodule_revision(self) -> str:
"""The batocera.linux commit KNULLI's batocera submodule pins at the tag."""
import json # noqa: PLC0415
from .base_scraper import github_headers # noqa: PLC0415
url = _SUBMODULE.format(path=BATOCERA_SUBMODULE, ref=self.tag)
req = urllib.request.Request(url, headers=github_headers())
try:
with urllib.request.urlopen(req, timeout=30) as resp:
found = json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
raise ConnectionError(f"Failed to fetch {url}: {e}") from e
if not isinstance(found, dict) or found.get("type") != "submodule" or not found.get("sha"):
raise RuntimeError(f"{url}: {BATOCERA_SUBMODULE} is not a submodule")
return str(found["sha"])
def validate_format(self, raw_data: str) -> bool:
has_dict = re.search(r"^systems\s*=\s*\{", raw_data, re.MULTILINE) is not None
return has_dict and "def checkBios" in raw_data and '"biosFiles"' in raw_data
@@ -101,11 +129,25 @@ class Scraper(BaseScraper):
defaults = self._fetch_text(CONFIGGEN_DEFAULTS_TEMPLATE.format(ref=self.tag))
es_systems = self._fetch_text(ES_SYSTEMS_TEMPLATE.format(ref=self.tag))
dropped = _not_cores("batocera") | _not_cores(PLATFORM_NAME)
cores, standalone, dual = configgen_cores(defaults, es_systems, dropped)
revision = self.submodule_revision()
launchers = extract_launchers(
lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path),
lambda path: self._fetch_text(_BATOCERA_RAW.format(ref=revision) + path),
)
# What runs is what at least one board builds, read by the targets
# scraper from the Kconfig of KNULLI's tree and its batocera and
# buildroot submodules (boards_build).
selection = BoardTargets().build_selection(self.tag)
built = boards_build(selection, launchers)
launchers = records_run(launchers, built)
cores, standalone, dual = configgen_cores(defaults, es_systems, dropped, built)
installable = installable_cores(selection, cores, standalone, dual)
# Each system's own list (psx, es_systems.yml:640-653: libretro
# pcsx_rearmed, swanstation, mednafen_psx, then duckstation and
# duckstation-legacy).
attach_system_cores(systems, es_systems_by_system(es_systems, defaults, dropped))
attach_system_cores(
systems, es_systems_by_system(es_systems, defaults, dropped, built)
)
result = {
"platform": "KNULLI",
"version": self.tag,
@@ -121,9 +163,15 @@ class Scraper(BaseScraper):
result["standalone_cores"] = standalone
if dual:
result["dual_mode_cores"] = dual
if installable:
result["installable_cores"] = installable
# configgen hands a libretro core /userdata/saves/<system>, as
# Batocera's does: the systems each core runs for are its save folders.
result["libretro_systems"] = es_systems_libretro_systems(es_systems, dropped)
result["launchers"] = launchers
result["configs_root"] = configs_root(
knulli_configgen(lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path))
)
return result
+1
View File
@@ -93,6 +93,7 @@ SYSTEM_MAP = {
"SGB": ("nintendo-sgb", "Super Game Boy", "SGB_MiSTer"),
"SNES": ("nintendo-snes", "Super NES / Super Famicom", "SNES_MiSTer"),
"Saturn": ("sega-saturn", "Sega Saturn", "Saturn_MiSTer"),
"SharpX1": ("sharp-x1", "Sharp X1", "SharpX1_Mister"),
"TGFX16-CD": ("nec-pc-engine-cd", "PC Engine CD / TurboGrafx-CD", "TurboGrafx16_MiSTer"),
"TI-99_4A": ("ti99", "Texas Instruments TI-99/4A", "TI-99_4A_MiSTer"),
"WonderSwan": ("wonderswan", "Bandai WonderSwan", "WonderSwan_MiSTer"),
+61 -20
View File
@@ -17,6 +17,7 @@ import ast
import posixpath
import re
from collections.abc import Callable, Mapping
from functools import partial
from typing import Any
from .configgen_launchers import (
@@ -39,10 +40,12 @@ CORES = f"{GENERATORS}/libretro/libretroCores.py"
LIBRETRO_GENERATOR = f"{GENERATORS}/libretro/libretroGenerator.py"
HATARI = f"{GENERATORS}/hatari/hatariGenerator.py"
GSPLUS = f"{GENERATORS}/gsplus/gsplusGenerator.py"
XROAR = f"{GENERATORS}/xroar/xroarGenerator.py"
AMIBERRY_GENERATOR = f"{GENERATORS}/amiberry/amiberryGenerator.py"
AMIBERRY_KICKSTARTS = f"{GENERATORS}/amiberry/amiberryKickstarts.py"
AMIBERRY_SUBSYSTEMS = f"{GENERATORS}/amiberry/amiberrySubSystems.py"
AMIBERRY_CONFIG = f"{GENERATORS}/amiberry/amiberryConfig.py"
AMIBERRY_GLOBAL = f"{GENERATORS}/amiberry/amiberryGlobalConfig.py"
PCSX2 = f"{GENERATORS}/pcsx2/pcsx2Generator.py"
DOLPHIN = f"{GENERATORS}/dolphin/dolphinGenerator.py"
XEMU = f"{GENERATORS}/xemu/xemuGenerator.py"
@@ -51,8 +54,8 @@ MUPEN_CONFIG = f"{GENERATORS}/mupen/mupenConfig.py"
SOURCE_FILES = (
FILES_MODULE, LAUNCHER, RETROARCH, CORES, LIBRETRO_GENERATOR, HATARI, GSPLUS,
AMIBERRY_KICKSTARTS, AMIBERRY_SUBSYSTEMS, AMIBERRY_CONFIG, PCSX2, DOLPHIN, XEMU,
MUPEN_CONFIG,
AMIBERRY_KICKSTARTS, AMIBERRY_SUBSYSTEMS, AMIBERRY_CONFIG, AMIBERRY_GLOBAL, PCSX2, DOLPHIN, XEMU,
MUPEN_CONFIG, XROAR,
)
# Recalbox's system.ini runs every libretro core as `emulator = libretro`:
@@ -847,22 +850,21 @@ def amiberry(cg: Configgen) -> list[Launcher]:
types = ", ".join(ast.unparse(t).removeprefix("RomType.") for t in entry.elts[0].elts)
names = kick.literal(entry.elts[1], "kickstart names")
ref = f"{kick.ref([kick.span(entry), *tail])}, {config_ref}"
common = dict(emulator=emulator, build="standalone", slot=system,
case_insensitive=False, search_dir=search_dir,
depends_on_option=f"rom type {types}", source_ref=ref)
make = partial(Launcher, emulator=emulator, build="standalone", slot=system,
case_insensitive=False, search_dir=search_dir,
depends_on_option=f"rom type {types}", source_ref=ref)
if any("|" in name for name in names):
if len(names) != 1:
kick.fail(f"line {entry.lineno}: several kickstart pairs are not read")
launchers.append(Launcher(
launchers.append(make(
kind="fixed_name", names=names[0].split("|"),
passes_to=f"{rom_key}, {ext_key}",
fails_when_missing=", ".join(messages), **common,
fails_when_missing=", ".join(messages),
))
else:
launchers.append(Launcher(
launchers.append(make(
kind="names_searched", names=names, passes_to=rom_key,
fails_when_missing=False if system in computers else messages[0],
**common,
))
launchers.append(Launcher(
emulator=emulator, build="standalone", kind="fixed_name", slot=", ".join(computers),
@@ -871,9 +873,26 @@ def amiberry(cg: Configgen) -> list[Launcher]:
fails_when_missing=False, depends_on_option="none of the listed kickstarts found",
source_ref=f"{kick.ref(tail)}, {config_ref}",
))
launchers.append(_amiberry_rom_path(cg, emulator))
return launchers
def _amiberry_rom_path(cg: Configgen, emulator: str) -> Launcher:
"""amiberry.conf rom_path, the folder Amiberry scans for every ROM."""
src = cg.source(AMIBERRY_GLOBAL)
create = src.function("createGlobalSettings")
found = [c for c in _calls(create, attr="setString") if _setter(c, "rom_path")]
if len(found) != 1 or len(found[0].args) != 2:
src.fail(f"expected one rom_path setter, found {len(found)}")
call = found[0]
directory = cg.bios_dir(spell(call.args[1], cg.env(src, create)), src, "rom_path")
return Launcher(
emulator=emulator, build="standalone", kind="passes_path", names=[],
search_dir=directory, passes_to="written: rom_path in amiberry.conf",
source_ref=src.ref([(call.lineno, call.end_lineno)]),
)
def pcsx2(cg: Configgen) -> list[Launcher]:
"""A region token of the rom name picks a BIOS, then a fixed order."""
src = cg.source(PCSX2)
@@ -937,30 +956,28 @@ def pcsx2(cg: Configgen) -> list[Launcher]:
if _assigned_name(n) in {c for c in constants.values()}]
ref = src.ref([(min(names_lines), max(names_lines)), (chooser.lineno, chooser.end_lineno),
(writes[0].lineno, writes[0].end_lineno)])
common = dict(emulator=cg.emulator(PCSX2), build="standalone", case_insensitive=False,
search_dir=search_dir, passes_to=f"[{section}] {key}",
fails_when_missing=False, source_ref=ref)
make = partial(Launcher, emulator=cg.emulator(PCSX2), build="standalone",
case_insensitive=False, search_dir=search_dir,
passes_to=f"[{section}] {key}", fails_when_missing=False, source_ref=ref)
launchers = []
for (given, tested), tokens in groups.items():
slot = constants.get(given, given).removeprefix("BIOS_")
depends = f"rom name contains {', '.join(tokens)}"
if tested == given:
launchers.append(Launcher(
launchers.append(make(
kind="names_searched", slot=slot,
names=list(dict.fromkeys([given, *fallback])),
depends_on_option=depends, **common,
depends_on_option=depends,
))
else:
# The name written is not the one tested: no search, one name.
launchers.append(Launcher(
launchers.append(make(
kind="fixed_name", slot=slot, names=[given],
depends_on_option=f"{depends} and {tested} is present",
**common,
))
launchers.append(Launcher(
launchers.append(make(
kind="names_searched", slot="default", names=fallback,
depends_on_option="rom name with no region token, or its BIOS absent",
**common,
))
return launchers
@@ -1161,7 +1178,7 @@ def core_file_options(cg: Configgen, directories: list[Launcher]) -> list[Launch
names=[call.args[1].value.strip('"')], search_dir=directory.search_dir,
passes_to=f"core option {option}",
depends_on_option=f"system {', '.join(systems)}",
source_ref=f"{src.ref([(function.lineno, function.end_lineno), *spans])}, "
source_ref=f"{src.ref([(function.lineno, function.end_lineno or function.lineno), *spans])}, "
f"{directory.source_ref}",
))
found = {str(launcher.passes_to).split()[-1] for launcher in launchers}
@@ -1171,8 +1188,32 @@ def core_file_options(cg: Configgen, directories: list[Launcher]) -> list[Launch
return launchers
def xroar(cg: Configgen) -> list[Launcher]:
"""-rompath os.path.join(recalboxFiles.BIOS, args.system) on the command line."""
src = cg.source(XROAR)
generate = src.function("generate")
# args.system is the ES system name, as self.system.Name is elsewhere.
env = {**cg.env(src, generate), "args.system": SYSTEM_PLACEHOLDER}
found = [
(flag, value)
for node in ast.walk(generate)
if isinstance(node, ast.List)
for flag, value in zip(node.elts, node.elts[1:])
if isinstance(flag, ast.Constant) and flag.value == "-rompath"
]
if len(found) != 1:
src.fail(f"expected one -rompath on the command line, found {len(found)}")
flag, value = found[0]
directory = cg.bios_dir(spell(value, env), src, "-rompath")
return [Launcher(
emulator=cg.emulator(XROAR), build="standalone", kind="passes_path", names=[],
search_dir=directory, passes_to="command line -rompath",
source_ref=src.ref([(flag.lineno, value.end_lineno or value.lineno)]),
)]
STANDALONE: tuple[Callable[[Configgen], list[Launcher]], ...] = (
hatari, gsplus, amiberry, pcsx2, xemu, mupen64plus,
hatari, gsplus, amiberry, pcsx2, xemu, mupen64plus, xroar,
)
LIBRETRO: tuple[Callable[[Configgen], list[Launcher]], ...] = (
core_assets, hatari_libretro, content_files,
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -217,6 +217,9 @@ class Scraper(BaseScraper):
sys_name = sys_data.get("name", "") if isinstance(sys_data, dict) else ""
if sys_name:
native["native_name"] = sys_name
# One held file of a group is enough (Program.cs): verify reads it.
if bios.get("group"):
native["group"] = str(bios["group"])
requirements.append(
BiosRequirement(
@@ -224,7 +227,7 @@ class Scraper(BaseScraper):
system=SYSTEM_SLUG_MAP.get(sys_key, sys_key),
md5=md5 or None,
destination=file_path,
required=True,
required=not bios.get("optional", False),
native_id=sys_key,
native_path=declared_path,
native=native,
+53 -3
View File
@@ -49,9 +49,10 @@ OUTSIDE_BIOS: dict[str, tuple[str, ...]] = {
}
# Packages whose emulator reads its system files below the working directory
# the generated launch script enters with pushd, a folder outside $biosdir:
# DraStic opens system/<name> under it. Each must still pushd.
WORKING_DIRECTORY: tuple[str, ...] = ("drastic",)
# the generated launch script enters with pushd: DraStic opens system/<name>
# under it, UAE4All2 kickstarts/<name> (its launchDir is getcwd()). Each must
# still pushd.
WORKING_DIRECTORY: tuple[str, ...] = ("drastic", "uae4all")
_FUNCTION = re.compile(r"^function\s+([\w.+-]+)\s*\(\)\s*\{\s*$")
_ASSIGN = re.compile(r"^(?:local\s+)?([A-Za-z_]\w*)=(.*)$")
@@ -524,6 +525,7 @@ class PackageReader:
self._links(links)
self._outside_bios(events)
self._working_directory(events)
self._reaching_links(events)
return self.records
@staticmethod
@@ -827,6 +829,54 @@ class PackageReader:
)
def _reaching_links(self, events: list[Event]) -> None:
"""The directories outside the BIOS one that a whole-directory link lies in.
openmsx's moveConfigDir "$home/.openMSX" "$configdir/msx/openmsx"
puts the emulator's directory where the systemroms link to BIOS/openmsx
lies, and xroar's ./configure --prefix="$md_inst" builds the program
whose share/xroar/roms links to $biosdir. Neither line names the BIOS
directory, and each says where the link is read from.
"""
suffix = " is a symlink to it"
linked = [
(record.passes_to or "").removesuffix(suffix).rstrip("/")
for record in self.records
if record.kind == "links" and not record.names
and (record.passes_to or "").endswith(suffix)
]
if not linked:
return
def holds(directory: str) -> bool:
directory = directory.strip("\"'").rstrip("/")
return bool(directory) and any(
path == directory or path.startswith(directory + "/") for path in linked
)
for event in events:
if event.heredoc or not event.words or _mentions_bios(event.raw):
continue
command = event.words[0][0]
if command == "moveConfigDir" and len(event.words) >= 3:
link, real = event.words[1][0], event.words[2][0]
if holds(real):
self.record(
"links", [], [(event.path, event.line)],
search_dir=real, passes_to=f"{link}{suffix}",
fails_when_missing=False, depends_on_option=self._depends(event),
)
elif command.endswith("configure"):
for word in event.words[1:]:
prefix = word[0].strip("\"'")
if prefix.startswith("--prefix=") and holds(prefix[len("--prefix="):]):
self.record(
"passes_path", [], [(event.path, event.line)],
search_dir=prefix[len("--prefix="):].strip("\"'"),
passes_to="${prefix} of configure --prefix",
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _working_directory(self, events: list[Event]) -> None:
"""The folder a generated launch script enters before it runs the emulator."""
if self.package.id not in WORKING_DIRECTORY:
+85 -18
View File
@@ -192,7 +192,7 @@ def config_records(
package: str,
file: str,
passes_to_suffix: str = "",
**fields: object,
depends_on_option: str | None = None,
) -> list[Launcher]:
"""fixed_name records for the bios paths of a config installed per device."""
configs = device_configs(tree, package, file)
@@ -218,7 +218,7 @@ def config_records(
(described + passes_to_suffix) if value.names else f"{described} left empty"
),
device=device,
**fields,
depends_on_option=depends_on_option,
)
)
return records
@@ -308,10 +308,40 @@ def amiberry(tree: RepoTree) -> list[Launcher]:
if not links:
raise mk.missing(f"ln -s of {BIOS_ROOT}")
records.extend(link_record("amiberry", mk, link) for link in links)
# The script copies the installed folder, links included, to the one it
# enters before it runs amiberry: the kickstarts link is read there.
copy_line, copy = script.find(r"cp -rf? (\S+) \$\{?(\w+)\}?\s*$", "copy of the installed folder")
cd_line, cd = script.find(r"^\s*cd \$\{?(\w+)\}?\s*$", "working directory")
if cd.group(1) != copy.group(2):
raise LauncherPatternMissing(script.path, "cd into the copied folder")
entered: set[int] = {cd_line}
folder = script.expand("${" + cd.group(1) + "}", cd_line, entered)
records.append(Launcher(
emulator="amiberry", build="standalone", kind="passes_path", names=[],
source_ref=source_ref([(script.path, entered)]),
search_dir=folder, passes_to=f"working directory of {script.path.rsplit('/', 1)[-1]}",
))
installed = copy.group(1).rstrip("/")
for link in links:
if not link.link.startswith(installed + "/"):
continue
copied = folder + link.link[len(installed):]
records.append(Launcher(
emulator="amiberry", build="standalone", kind="links", names=list(link.names),
source_ref=source_ref([(mk.path, link.lines), (script.path, {copy_line} | entered)]),
search_dir=link.bios_side,
passes_to=f"symlink {copied} -> {link.target}, copied with {installed}",
))
return records
def link_record(emulator: str, script: Script, link: Link, **fields: object) -> Launcher:
def link_record(
emulator: str,
script: Script,
link: Link,
case_insensitive: bool | None = None,
fails_when_missing: bool | str = False,
) -> Launcher:
return Launcher(
emulator=emulator,
build="standalone",
@@ -321,15 +351,22 @@ def link_record(emulator: str, script: Script, link: Link, **fields: object) ->
expands=link.expands or None,
search_dir=link.bios_side,
passes_to=f"symlink {link.link} -> {link.target}",
**fields,
case_insensitive=case_insensitive,
fails_when_missing=fails_when_missing,
)
def hatari(tree: RepoTree) -> list[Launcher]:
package = f"{STANDALONE}/hatarisa"
script = Script(
f"{package}/scripts/start_hatari.sh", tree.read(f"{package}/scripts/start_hatari.sh")
# The script is named after the package's binary: start_hatari.sh became
# start_hatarisa.sh (2e7041d273), so it is found, not spelled.
starts = sorted(
name for name in tree.listdir(f"{package}/scripts")
if name.startswith("start_") and name.endswith(".sh")
)
if len(starts) != 1:
raise LauncherPatternMissing(f"{package}/scripts", "one start script")
script = Script(f"{package}/scripts/{starts[0]}", tree.read(f"{package}/scripts/{starts[0]}"))
copy_line, copy = script.find(r"cp \S+/([\w-]+\.cfg) ", "default config copy")
run_line, _ = script.find(r"--configfile", "--configfile", copy_line)
path = f"{package}/config/conf/{copy.group(1)}"
@@ -557,17 +594,6 @@ def duckstation(tree: RepoTree) -> list[Launcher]:
)
def aethersx2(tree: RepoTree) -> list[Launcher]:
return directory_records(
tree,
"aethersx2",
f"{STANDALONE}/aethersx2-sa",
"aethersx2/inis/PCSX2.ini",
("Folders", "Bios"),
(("Filenames", "BIOS"),),
)
def armsx2(tree: RepoTree) -> list[Launcher]:
return directory_records(
tree,
@@ -580,6 +606,47 @@ def armsx2(tree: RepoTree) -> list[Launcher]:
# Start scripts whose ln -s commands put a BIOS path where the emulator reads.
def pico8(tree: RepoTree) -> list[Launcher]:
"""The PICO-8 binary start_pico8.sh executes, outside /storage/roms/bios.
The script picks the binary by HW_ARCH, pico8_64 on aarch64 and
pico8_dyn on any other, and runs it from GAME_DIR, or from
GAME_DIR/${HW_ARCH} when that folder exists.
"""
path = f"{STANDALONE}/pico-8/scripts/start_pico8.sh"
script = Script(path, tree.read(path))
game_line, game = script.find(r'^\s*GAME_DIR="([^"$]+)"', "GAME_DIR assignment")
case_line, _ = script.find(r"^\s*case \$\{HW_ARCH\} in", "case on HW_ARCH")
arch_line, arch = script.find(r"^\s*(\w+)\)", "HW_ARCH branch", case_line + 1)
first_line, first = script.find(r'STATIC_BIN="([^"$]+)"', "binary of the first branch", arch_line)
other_line, _ = script.find(r"^\s*\*\)", "default HW_ARCH branch", first_line)
rest_line, rest = script.find(r'STATIC_BIN="([^"$]+)"', "binary of the default branch", other_line)
sub_line, _ = script.find(
r'if \[ -d "\$\{GAME_DIR\}/\$\{HW_ARCH\}" \]', "per-architecture launch folder"
)
end_line, _ = script.find(r"^\s*fi\b", "end of the launch folder test", sub_line)
run_line, _ = script.find(r"^\s*\$\{LAUNCH_DIR\}/\$\{STATIC_BIN\} ", "binary run")
return [
Launcher(
emulator="pico-8",
build="standalone",
kind="fixed_name",
names=[first.group(1), rest.group(1)],
source_ref=source_ref([(path, {
game_line, case_line, arch_line, first_line, other_line, rest_line,
*range(sub_line, end_line + 1), run_line,
})]),
search_dir=game.group(1).rstrip("/"),
passes_to="exec",
fails_when_missing=False,
depends_on_option=(
f"HW_ARCH {arch.group(1)} runs {first.group(1)}, any other {rest.group(1)}, "
f"from {game.group(1).rstrip('/')}/${{HW_ARCH}} when that folder exists"
),
)
]
LINK_SCRIPTS = (
("drastic", f"{STANDALONE}/drastic-sa/scripts/start_drastic.sh"),
("dolphin", f"{STANDALONE}/dolphin-sa/scripts/start_dolphin_gc.sh"),
@@ -683,8 +750,8 @@ EXTRACTORS: tuple[Callable[[RepoTree], list[Launcher]], ...] = (
mednafen,
yabasanshiro,
duckstation,
aethersx2,
armsx2,
pico8,
patched_directories,
links,
)
+18 -4
View File
@@ -190,6 +190,22 @@ class Scraper(BaseScraper):
self.tag = None if url else fetch_github_latest_version(GITHUB_REPO)
super().__init__(url=url or SOURCE_TEMPLATE.format(ref=self.tag))
def _built_cores(self, standalone: list[str]) -> tuple[list[str], list[str]]:
"""(cores, standalone cores) at least one device image carries.
RetroArch runs the libretro cores the packages of its emulators
metapackage install per device
(projects/ROCKNIX/packages/virtual/emulators/package.mk), read by
the targets scraper, where every libretro profile counted before. A
standalone package no device carries is not run either.
"""
from .targets.rocknix_targets_scraper import Scraper as DeviceTargets
builds = DeviceTargets().device_builds(self.tag or launcher_revision(self.url))
libretro = sorted({core for _arch, lr, _sa in builds.values() for core in lr})
carried = set(standalone_emulators(sorted({p for *_rest, sa in builds.values() for p in sa})))
standalone = [name for name in standalone if name in carried]
return sorted({*libretro, *standalone}), standalone
def _fetch_standalone(self) -> list[str]:
"""The packages of projects/ROCKNIX/packages/emulators/standalone."""
import json
@@ -281,7 +297,7 @@ class Scraper(BaseScraper):
)
entry["files"].append(requirement_entry(req))
standalone = self._fetch_standalone()
cores, standalone = self._built_cores(self._fetch_standalone())
return {
"platform": "ROCKNIX",
"version": self.tag or fetch_github_latest_version(GITHUB_REPO),
@@ -290,9 +306,7 @@ class Scraper(BaseScraper):
"base_destination": "bios",
"hash_type": "md5",
"verification_mode": "md5",
# RetroArch with every libretro core, plus the standalone
# emulators ROCKNIX packages beside it.
"cores": ["retroarch", *standalone],
"cores": cores,
"standalone_cores": standalone,
# Launched both ways only where package.mk also adds the
# emulator as a RetroArch core.
@@ -3,6 +3,8 @@
Sources (batocera-linux/batocera.linux):
- configs/batocera-*.board -- board definitions, each sets BR2_PACKAGE_BATOCERA_TARGET_*
- package/batocera/core/batocera-system/Config.in -- select PACKAGE if CONDITION lines
- Config.in and every Kconfig file it sources -- the selects a package
declares itself (advanced_drastic selects drastic)
- package/batocera/emulationstation/batocera-es-system/es_systems.yml
-- emulator requireAnyOf flag mapping
Sources (batocera-linux/buildroot, at the submodule commit the tag pins):
@@ -19,6 +21,8 @@ import re
import sys
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timezone
import yaml
@@ -79,6 +83,16 @@ def _fetch(url: str, headers: dict | None = None) -> str:
raise RuntimeError(f"cannot fetch {url}: {e}") from e
def _fetch_present(url: str) -> str | None:
"""A file's text, or None when the server answers that it does not exist."""
try:
return _fetch(url)
except RuntimeError as e:
if isinstance(e.__cause__, urllib.error.HTTPError) and e.__cause__.code == 404:
return None
raise
def _fetch_json(url: str) -> list | dict:
text = _fetch(url, headers=github_headers())
try:
@@ -205,11 +219,14 @@ def _parse_meta_flags(text: str) -> list[tuple[str, str]]:
# ("select BR2_PACKAGE_XROAR if BR2_PACKAGE_HAS_LIBGL") reached no board.
BUILDROOT_KCONFIG = (
"arch/Config.in",
# Each armv7-A CPU selects BR2_ARM_CPU_ARMV7A (cortex_a15_a7 included).
"arch/Config.in.arm",
"package/mesa3d/Config.in",
"package/libglvnd/Config.in",
)
_CONFIG_LINE_RE = re.compile(r"^\s*(?:menu)?config\s+(BR2_\w+)")
_SOURCE_RE = re.compile(r'^\s*source\s+"\$BR2_EXTERNAL_\w+_PATH/([^"]+)"')
_OWNED_SELECT_RE = re.compile(r"^\s+select\s+(BR2_\w+)(?:\s+if\s+([^#]+))?")
_BOARD_FLAG_RE = re.compile(r"^(BR2_\w+)=y\s*$", re.MULTILINE)
_BOARD_INCLUDE_RE = re.compile(r"^(-?)include\s+(\S+)", re.MULTILINE)
@@ -315,6 +332,51 @@ def _parse_es_systems(text: str) -> dict[str, list[str]]:
return package_to_emulators
# RetroArch's core downloader fetches from DEFAULT_BUILDBOT_SERVER_URL, which
# Linux sets for x86_64, i386 and armv7 hard-float alone, and leaves empty on
# aarch64 (config.def.h:1855-1864 at Batocera's 4b74434 and at v1.22.2).
def buildbot_target(symbols: set[str] | frozenset[str]) -> str | None:
"""The RetroArch targets entry a board's core downloader installs from.
`symbols` are the board's active symbols. An armv7-A CPU selects
BR2_ARM_CPU_ARMV7A (arch/Config.in.arm) and is hard-float unless the
board asks for soft-float (BR2_ARM_EABI): Buildroot picks EABIHF for a
CPU with a VFP, which every Cortex-A has. armv6 (bcm2835), aarch64 and
riscv64 get no buildbot URL.
"""
if "BR2_x86_64" in symbols:
return "linux-x86_64"
if "BR2_i386" in symbols:
return "linux-x86"
if "BR2_ARM_CPU_ARMV7A" in symbols and "BR2_ARM_EABI" not in symbols:
return "linux-armhf"
return None
# configure options and make variables that compile the downloader out.
_NO_DOWNLOADER = ("--disable-update-cores", "--disable-online-updater", "--disable-networking",
"HAVE_UPDATE_CORES=0", "HAVE_ONLINE_UPDATER=0", "HAVE_LAKKA=1")
_BLANK_CORES_URL = re.compile(r'^\s*core_updater_buildbot_cores_url\s*=\s*""', re.MULTILINE)
def downloader_disabled(retroarch_mk: str, shipped_cfgs: dict[str, str]) -> str | None:
"""Why RetroArch's core downloader installs nothing, or None.
Compiled out by its build options, or left without a URL by a shipped
retroarch.cfg. A config that hides the menu entry alone does not stop
it: without HAVE_LAKKA, Settings > User Interface offers it back
(menu_setting.c). A config that blanks the URL for some boards only is
a case this reader does not split, and an error.
"""
for option in _NO_DOWNLOADER:
if option in retroarch_mk:
return f"retroarch.mk sets {option}"
blank = sorted(path for path, text in shipped_cfgs.items() if _BLANK_CORES_URL.search(text))
if blank:
raise RuntimeError(f"{', '.join(blank)} blank the core downloader URL: split per board")
return None
# Buildroot's architecture choice, as a board sets it (arch/Config.in).
_ARCH_SYMBOLS = {
"BR2_aarch64": "aarch64",
@@ -342,6 +404,44 @@ def _arch_from_flag(flag: str, board_flags: set[str] | frozenset[str] = frozense
return "aarch64"
@dataclass(frozen=True)
class BuildSelection:
"""What the boards of a build turn on, read once from its Kconfig.
`targets` is each board's entry of the targets file, `downloads` naming
the RetroArch targets entry its core downloader installs from. `emulators` holds
every es_systems name some board builds and `listed` every name
es_systems ties to a package, `symbols` every symbol some board ends up
with, and `packages` the config symbol each package folder of the tree
defines.
"""
targets: dict[str, dict]
emulators: frozenset[str]
listed: frozenset[str]
symbols: frozenset[str]
packages: dict[str, str]
def _sourced(text: str) -> list[str]:
"""The files a Kconfig file sources, relative to the external tree."""
return [m.group(1) for line in text.splitlines() if (m := _SOURCE_RE.match(line))]
def _package_symbols(files: dict[str, str]) -> dict[str, str]:
"""package folder -> the first config symbol its Config.in defines."""
found: dict[str, str] = {}
for path, text in files.items():
parts = path.split("/")
if len(parts) < 3 or parts[-1] != "Config.in" or "package" not in parts:
continue
config = next((m.group(1) for line in text.splitlines()
if (m := _CONFIG_LINE_RE.match(line))), None)
if config:
found.setdefault(parts[-2], config)
return found
class Scraper(BaseTargetScraper):
"""Cross-references Batocera boards, Config.in, and es_systems to build target lists.
@@ -357,6 +457,10 @@ class Scraper(BaseTargetScraper):
ES_SYSTEMS_PATH = "package/batocera/emulationstation/batocera-es-system/es_systems.yml"
SYSTEM_SYMBOL = "BR2_PACKAGE_BATOCERA_SYSTEM"
BUILDROOT_REPO = "batocera-linux/buildroot"
# Path prefix -> repository of a submodule the root Config.in sources
# from: KNULLI reads Batocera's packages as batocera/package/...
SUBMODULES: dict[str, str] = {}
RETROARCH_MK = "package/batocera/emulators/retroarch/retroarch/retroarch.mk"
def __init__(self, url: str = "") -> None:
super().__init__(url=url or f"https://github.com/{self.REPO}")
@@ -411,6 +515,64 @@ class Scraper(BaseTargetScraper):
pending += [(inc, bool(dash)) for dash, inc in _BOARD_INCLUDE_RE.findall(text)]
return target, flags
def _kconfig_files(self) -> dict[str, str]:
"""The root Config.in and every file it sources, recursively.
The build reads these and nothing else: a package's own Config.in
can select another package (advanced_drastic selects drastic), and
such a select sits in no system Config.in. A sourced file absent at
the pin defines nothing and is named: KNULLI 20260511 sources
package/utils/vtree/Config.in, a package its tree gained later.
"""
bases: list[tuple[str, str]] = []
for prefix, repo in self.SUBMODULES.items():
pin = _fetch_json(self._api(prefix.rstrip("/")))
sha = pin.get("sha") if isinstance(pin, dict) else None
if not sha:
raise RuntimeError(f"no {prefix.rstrip('/')} submodule commit at {self.tag}")
bases.append((prefix, f"https://raw.githubusercontent.com/{repo}/{sha}/"))
def url(path: str) -> str:
for prefix, base in bases:
if path.startswith(prefix):
return base + path.removeprefix(prefix)
return self._raw(path)
files: dict[str, str] = {}
absent: list[str] = []
pending = ["Config.in"]
with ThreadPoolExecutor(max_workers=8) as pool:
while pending:
found = dict(zip(pending, pool.map(lambda path: _fetch_present(url(path)), pending)))
absent += [path for path, text in found.items() if text is None]
files.update((path, text or "") for path, text in found.items())
pending = sorted({src for text in found.values() if text
for src in _sourced(text)} - set(files))
if "Config.in" in absent or len(files) - len(absent) < 2:
raise RuntimeError(f"Config.in at {self.tag} sources no readable Kconfig file")
for path in absent:
print(f" sourced but absent at {self.tag}: {path}", file=sys.stderr)
return files
def _shipped_retroarch_cfgs(self) -> dict[str, str]:
"""Every retroarch.cfg the tree ships, its submodules included."""
repos = [("", self.REPO, self.tag)]
for prefix, repo in self.SUBMODULES.items():
pin = _fetch_json(self._api(prefix.rstrip("/")))
if not isinstance(pin, dict) or not isinstance(pin.get("sha"), str):
raise RuntimeError(f"no submodule commit for {prefix} at {self.tag}")
repos.append((prefix, repo, pin["sha"]))
found: dict[str, str] = {}
for prefix, repo, ref in repos:
listing = _fetch_json(f"https://api.github.com/repos/{repo}/git/trees/{ref}?recursive=1")
if not isinstance(listing, dict) or listing.get("truncated"):
raise RuntimeError(f"{repo} tree at {ref} is unreadable or truncated")
for entry in listing.get("tree", []):
if re.search(r"(^|/)retroarch\.cfg$", entry["path"]):
raw = f"https://raw.githubusercontent.com/{repo}/{ref}/{entry['path']}"
found[prefix + entry["path"]] = _fetch(raw)
return found
def _buildroot_rules(self) -> list[tuple[str, str, str]]:
"""Buildroot's selects for the symbols the tree tests, at its pin."""
pin = _fetch_json(self._api("buildroot"))
@@ -423,26 +585,32 @@ class Scraper(BaseTargetScraper):
rules += _parse_owned_selects(_fetch(f"{raw}/{path}"))
return rules
def fetch_targets(self) -> dict:
"""Build per-board emulator availability map."""
self.tag = self.pinned_revision(self.PLATFORM)
def build_selection(self, tag: str | None = None) -> BuildSelection:
"""Each board's selection, from the Kconfig the build reads.
`tag` is the revision to read, by default the one the platform's
YAML was scraped at: a platform scraper moving to a new release
passes its own.
"""
self.tag = tag or self.pinned_revision(self.PLATFORM)
print(" fetching board list...", file=sys.stderr)
boards = self._list_boards()
print(" fetching Config.in...", file=sys.stderr)
config_in_url = self._raw(self.CONFIG_IN_PATH)
config_in_text = _fetch(config_in_url)
print(" fetching Kconfig files...", file=sys.stderr)
files = self._kconfig_files()
config_in_text = files.get(self.CONFIG_IN_PATH)
if not config_in_text:
raise RuntimeError(f"empty Config.in from {config_in_url}")
raise RuntimeError(f"Config.in at {self.tag} does not source {self.CONFIG_IN_PATH}")
meta_rules = _parse_meta_flags(config_in_text)
rules = _parse_owned_selects(config_in_text)
rules = [rule for text in files.values() for rule in _parse_owned_selects(text)]
print(
f" parsed {len(meta_rules)} meta-flag rules, {len(rules)} select lines",
f" parsed {len(meta_rules)} meta-flag rules, {len(rules)} select lines "
f"in {len(files)} Kconfig files",
file=sys.stderr,
)
if not rules:
raise RuntimeError(f"no 'select BR2_PACKAGE_*' lines parsed from {config_in_url}")
if not _parse_owned_selects(config_in_text):
raise RuntimeError(f"no 'select BR2_PACKAGE_*' lines parsed from {self.CONFIG_IN_PATH}")
rules += self._buildroot_rules()
print(" fetching es_systems.yml...", file=sys.stderr)
@@ -459,9 +627,14 @@ class Scraper(BaseTargetScraper):
raise RuntimeError(f"no package->emulator mappings parsed from {es_systems_url}")
images_by_flag = _parse_images(config_in_text)
if not images_by_flag:
raise RuntimeError(f"no target image list parsed from {config_in_url}")
raise RuntimeError(f"no target image list parsed from {self.CONFIG_IN_PATH}")
disabled = downloader_disabled(_fetch(self._raw(self.RETROARCH_MK)),
self._shipped_retroarch_cfgs())
if disabled:
print(f" core downloader off: {disabled}", file=sys.stderr)
targets: dict[str, dict] = {}
symbols: set[str] = set()
for board_name in sorted(boards):
target_key = board_name.removeprefix(self.BOARD_PREFIX).removesuffix(".board")
print(f" processing {target_key}...", file=sys.stderr)
@@ -476,6 +649,7 @@ class Scraper(BaseTargetScraper):
if self.SYSTEM_SYMBOL not in selected_packages:
print(f" {board_name} builds no system image", file=sys.stderr)
continue
symbols |= selected_packages
# Map selected packages to emulator names via es_systems.yml
emulators: set[str] = set()
@@ -491,16 +665,30 @@ class Scraper(BaseTargetScraper):
images = sorted(set(images_by_flag.get(primary_flag, [])))
if images and images != [target_key]:
targets[target_key]["images"] = images
downloads = None if disabled else buildbot_target(selected_packages)
if downloads:
targets[target_key]["downloads"] = downloads
print(
f" {len(emulators)} emulators ({len(selected_packages)} packages selected)",
file=sys.stderr,
)
return BuildSelection(
targets=targets,
emulators=frozenset(c for entry in targets.values() for c in entry["cores"]),
listed=frozenset(n for names in package_to_emulators.values() for n in names),
symbols=frozenset(symbols),
packages=_package_symbols(files),
)
def fetch_targets(self) -> dict:
"""Build per-board emulator availability map."""
selection = self.build_selection()
return {
"platform": self.PLATFORM,
"source": f"{self.url}/tree/{self.tag}",
"scraped_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"targets": targets,
"targets": selection.targets,
}
@@ -610,7 +610,12 @@ class Scraper(BaseTargetScraper):
return fetch_bytes(RAW.format(ref=self.ref, path=path)).decode("utf-8")
def fetch_targets(self) -> dict:
self.ref = self.pinned_revision(PLATFORM_NAME)
return self.device_targets()
def device_targets(self, ref: str | None = None) -> dict:
"""The targets file, read at `ref`, by default the revision
platforms/emuelec.yml was scraped at."""
self.ref = ref or self.pinned_revision(PLATFORM_NAME)
release = json.loads(
fetch_bytes(RELEASE_API.format(ref=self.ref), headers=github_headers())
)
@@ -9,6 +9,8 @@ the tag platforms/knulli.yml names):
the root Config.in in place of Batocera's batocera-system/Config.in
- package/emulationstation/knulli-es-system/es_systems.yml
-- emulator requireAnyOf flag mapping
- Config.in and what it sources, Batocera's packages read from the
batocera submodule at the commit the tag pins
Sources (knulli-cfw/buildroot, at the submodule commit the tag pins):
- arch/Config.in, package/mesa3d/Config.in, package/libglvnd/Config.in
@@ -32,6 +34,8 @@ class Scraper(BatoceraScraper):
ES_SYSTEMS_PATH = "package/emulationstation/knulli-es-system/es_systems.yml"
SYSTEM_SYMBOL = "BR2_PACKAGE_KNULLI_SYSTEM"
BUILDROOT_REPO = "knulli-cfw/buildroot"
SUBMODULES = {"batocera/": "batocera-linux/batocera.linux"}
RETROARCH_MK = "package/retroarch/retroarch/retroarch.mk"
def main() -> None:
@@ -5,6 +5,8 @@ Sources (ROCKNIX/distribution, at the release tag platforms/rocknix.yml names):
image carries, those aarch64 adds, and a `case "${DEVICE}"` block per SoC
- projects/ROCKNIX/devices/<DEVICE>/options -- ENABLE_32BIT, which the 32-bit
cores of a device depend on (distributions/ROCKNIX/options defaults it on)
- each libretro package's package.mk -- the cores its makeinstall_target
copies to ${INSTALL}/usr/lib/libretro (vice-lr installs ten of them)
An H700 or RK3326 handheld carries neither Dolphin nor AetherSX2: without a
target list, its pack shipped the PS2, GameCube and PS3 firmware anyway.
@@ -13,6 +15,7 @@ target list, its pack shipped the PS2, GameCube and PS3 firmware anyway.
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
@@ -89,18 +92,90 @@ def parse_device_cores(
return libretro, standalone
def core_names(libretro: list[str], standalone: list[str]) -> list[str]:
"""Package names as the profiles' cores: lists spell them.
_INSTALL_BODY_RE = re.compile(r"^makeinstall_target\(\)\s*\{\n(.*?)^\}", re.M | re.S)
_COPY_RE = re.compile(r"\bcp\s+(.*?)\s+\$\{?INSTALL\}?/usr/lib/libretro(?:/(\S*))?")
_CORE_FILE_RE = re.compile(r"([\w+.-]+)_libretro\.(?:so\b|\{[^}]*\bso\b[^}]*\})")
_TEST_RE = re.compile(r'\[\s*"\$\{(\w+)\}"\s*=\s*"([^"]*)"\s*\]')
A libretro package is the core's name with `-lr` (`beetle-psx-lr`,
`neocd_lr`), spelled with underscores as the buildbot names it. A
standalone package is kept under both spellings, as the platform
scraper keeps it: `vice-sa` is the only name vice-standalone answers
to, `vice` that of the libretro profile.
def installed_cores(package_mk: str, arch: str, with_32bit: bool) -> list[str]:
"""The cores a libretro package installs on a device.
The name is that of the file makeinstall_target copies to
${INSTALL}/usr/lib/libretro ($INSTALL in some), renamed when the copy names its target:
beetle-psx-lr copies mednafen_psx_libretro.so as beetle_psx_libretro.so,
vice-lr ten EMUTYPE builds. pcsx_rearmed-lr picks its file by
TARGET_ARCH and adds the 32-bit build when ENABLE_32BIT is on, desmume-lr
copies the 32-bit build on aarch64 (ARCH). A test on another variable,
or a package that installs no core, is an error.
"""
names = {re.sub(r"[-_]lr$", "", package).replace("-", "_") for package in libretro}
names.update(standalone_emulators(standalone))
return sorted(names)
body = _INSTALL_BODY_RE.search(package_mk)
if body is None:
raise RuntimeError("package.mk has no makeinstall_target")
# config/options:36 sets TARGET_ARCH="${ARCH}".
values = {"ARCH": arch, "TARGET_ARCH": arch, "ENABLE_32BIT": "true" if with_32bit else "false"}
def holds(condition: str) -> bool:
tests = _TEST_RE.findall(condition)
if not tests or re.sub(_TEST_RE, "", condition).strip(" ;&") not in ("", "then"):
raise RuntimeError(f"makeinstall_target tests {condition.strip()!r}")
for variable, value in tests:
if variable not in values:
raise RuntimeError(f"makeinstall_target tests ${{{variable}}}")
if values[variable] != value:
return False
return True
found: list[str] = []
stack: list[bool] = []
in_case = False
for raw in body.group(1).splitlines():
line = raw.strip()
if re.match(r"case \$\{TARGET_ARCH\} in$", line):
in_case = True
stack.append(False)
continue
if line == "esac":
in_case = False
stack.pop()
continue
if in_case and (arm := re.match(r"([\w|*]+)\)\s*(.*)$", line)):
stack[-1] = arch in arm.group(1).split("|") or arm.group(1) == "*"
line = arm.group(2)
if line.startswith("if "):
stack.append(holds(line[3:]))
continue
if line == "else":
stack[-1] = not stack[-1]
continue
if line == "fi":
stack.pop()
continue
if not all(stack):
continue
for sources, target in _COPY_RE.findall(line):
if named := _CORE_FILE_RE.search(target):
found.append(named.group(1))
continue
for source in sources.split():
if not source.startswith("-") and (named := _CORE_FILE_RE.search(source.rsplit("/", 1)[-1])):
found.append(named.group(1))
if not found:
raise RuntimeError("makeinstall_target installs no libretro core")
return sorted(set(found))
def package_paths(tree: list[str]) -> dict[str, str]:
"""package name -> its package.mk, a ROCKNIX project package first.
The build takes projects/ROCKNIX/packages/<...>/<name> over
packages/<...>/<name> when both exist.
"""
found: dict[str, str] = {}
for path in sorted(tree, key=lambda p: not p.startswith("projects/ROCKNIX/")):
if path.endswith("/package.mk"):
found.setdefault(path.rsplit("/", 2)[-2], path)
return found
# A package that builds nothing itself and pulls other cores in. idtech-lr
@@ -149,13 +224,40 @@ class Scraper(BaseTargetScraper):
raise RuntimeError(f"no device in {DEVICES_DIR} at {self.tag}")
return sorted(devices)
def fetch_targets(self) -> dict:
self.tag = self.pinned_revision(PLATFORM_NAME)
def _tree(self) -> list[str]:
listing = json.loads(fetch_bytes(
f"https://api.github.com/repos/ROCKNIX/distribution/git/trees/{self.tag}?recursive=1",
headers=github_headers(),
))
if listing.get("truncated"):
raise RuntimeError(f"ROCKNIX tree at {self.tag} is truncated")
return [entry["path"] for entry in listing.get("tree", [])]
def device_builds(self, tag: str | None = None) -> dict[str, tuple[str, list[str], list[str]]]:
"""{device: (architecture, libretro cores, standalone packages)}.
Read at `tag`, by default the revision platforms/rocknix.yml was
scraped at: metapackages resolved to the packages they pull in, and
each libretro package to the cores it installs on the device.
"""
self.tag = tag or self.pinned_revision(PLATFORM_NAME)
package_mk = self._raw(EMULATORS_MK)
if "LIBRETRO_CORES" not in package_mk:
raise RuntimeError(f"no LIBRETRO_CORES in {EMULATORS_MK} at {self.tag}")
paths = package_paths(self._tree())
texts: dict[str, str] = {}
targets: dict[str, dict] = {}
def cores_of(package: str, arch: str, with_32bit: bool) -> list[str]:
if package not in paths:
raise RuntimeError(f"no package.mk for {package} at {self.tag}")
if package not in texts:
texts[package] = self._raw(paths[package])
try:
return installed_cores(texts[package], arch, with_32bit)
except RuntimeError as e:
raise RuntimeError(f"{paths[package]}: {e}") from e
builds: dict[str, tuple[str, list[str], list[str]]] = {}
for device in self._devices():
options = self._raw(f"{DEVICES_DIR}/{device}/options")
arch = "x86_64" if device == "AMD64" else "aarch64"
@@ -165,9 +267,19 @@ class Scraper(BaseTargetScraper):
if meta in libretro:
libretro.remove(meta)
libretro += metapackage_cores(self._raw(path), arch)
cores = core_names(libretro, standalone)
targets[device.lower()] = {"architecture": arch, "cores": cores}
print(f" {device.lower()} ({arch}): {len(cores)} cores", file=sys.stderr)
cores = sorted({c for package in libretro for c in cores_of(package, arch, with_32bit)})
builds[device.lower()] = (arch, cores, standalone)
return builds
def fetch_targets(self) -> dict:
targets: dict[str, dict] = {}
for device, (arch, libretro, standalone) in self.device_builds().items():
# A standalone package is kept under both spellings, as the
# platform scraper keeps it: vice-sa is the only name
# vice-standalone answers to, vice that of the libretro profile.
cores = sorted({*libretro, *standalone_emulators(standalone)})
targets[device] = {"architecture": arch, "cores": cores}
print(f" {device} ({arch}): {len(cores)} cores", file=sys.stderr)
return {
"platform": PLATFORM_NAME,
+1 -1
View File
@@ -97,7 +97,7 @@ def _engine_sections(manufacturers: dict[str, dict[str, list]]) -> dict[str, dic
from siterender import _PROGRAM_TOPS, SECTION_LABELS, record_card_move # noqa: PLC0415
profiles = {key: profile for key, profile in _profiles_on_disk().items()
if profile.get("type") not in ("alias", "test")}
if profile.get("type") != "alias" and profile.get("role") != "test"}
readers: dict[str, set[str]] = {}
for key, profile in profiles.items():
for entry in profile.get("files") or []:
+3 -2
View File
@@ -423,7 +423,7 @@ def _build_boards() -> None:
ids_by_name: dict[str, set[str]] = {}
for key in sorted(profiles):
profile = profiles[key]
if profile.get("type") == "test":
if profile.get("role") == "test":
continue
systems = [str(sys_id) for sys_id in profile.get("systems") or []]
for entry in profile.get("files") or []:
@@ -967,6 +967,7 @@ SYSTEM_NAMES: dict[str, str] = {
"galaksija": "Galaksija computer",
"game": "Picross puzzle (libretro sample)",
"gamecom": "Tiger Game.com",
"gamemaker": "GameMaker games",
"gameplan-mpu1": "Game Plan MPU-1",
"gamepock": "Epoch Game Pocket Computer",
"gametank": "GameTank console",
@@ -1124,7 +1125,7 @@ SYSTEM_NAMES: dict[str, str] = {
"sega-isgsm": "Sega ISG Selection Master",
"sega-megaplay": "Sega Mega Play",
"sega-megatech": "Sega Mega-Tech",
"sega-model2": "Sega Model 2",
"sega-model1": "Sega Model 1",
"sega-naomi": "Sega NAOMI",
"sega-naomi2": "Sega NAOMI 2",
"sega-naomigd": "Sega NAOMI GD-ROM",
+42 -4
View File
@@ -29,6 +29,8 @@ rank: the second core reads it by its own rule, which no rank expresses.
from __future__ import annotations
from alternatives import set_keys
BIOS_CATEGORY = "bios"
@@ -65,9 +67,11 @@ def build_slot_index(profiles: dict) -> dict[str, dict]:
entry = index.setdefault(
key,
{"rank": None, "conflict": False, "regions": set(),
"emulators": [], "ranked_by": set(), "unranked_by": set()},
"emulators": [], "ranked_by": set(), "unranked_by": set(),
"sets": set()},
)
entry["regions"] |= {str(r) for r in (f.get("region") or [])}
entry["sets"] |= set(set_keys(emu_name, f))
rank = f.get("priority")
entry["unranked_by" if rank is None else "ranked_by"].add(emu_name)
if rank is not None:
@@ -123,7 +127,7 @@ def resolve_slot_drops(
# A slot is a system AND a declared region: the Japanese and American
# PlayStation BIOS are not alternatives to each other, and comparing their
# ranks across regions would pick one territory's file for another's.
tiers: dict[tuple[str, tuple[str, ...]], list[tuple[str, int | None]]] = {}
bucketed: dict[tuple[str, tuple[str, ...]], list[tuple[str, int | None, frozenset]]] = {}
for group_id, members in groups.items():
for destination, name in members:
if held is not None and destination not in held:
@@ -134,9 +138,18 @@ def resolve_slot_drops(
continue
tier = tuple(sorted(entry["regions"]))
rank = None if entry.get("conflict") else entry["rank"]
tiers.setdefault((group_id, tier), []).append((destination, rank))
bucketed.setdefault((group_id, tier), []).append(
(destination, rank, frozenset(entry.get("sets") or ()))
)
# A rank orders the members of one set: px68k-sdl ranks its IPL and its
# font ROM lists each from 1, and the two never compete. Candidates are
# compared when they share a set, and those in no set among themselves.
tiers: dict[tuple[str, tuple[str, ...], int], list[tuple[str, int | None]]] = {}
for (group_id, tier), found in bucketed.items():
for part, component in enumerate(_by_shared_set(found)):
tiers[(group_id, tier, part)] = component
for (group_id, tier), candidates in tiers.items():
for (group_id, tier, _part), candidates in tiers.items():
if len(candidates) < 2:
keep.update(dest for dest, _p in candidates)
continue
@@ -160,3 +173,28 @@ def resolve_slot_drops(
drop.update(dest for dest, _p in candidates if dest != winners[0])
return drop - keep, sorted(undecidable)
def _by_shared_set(
found: list[tuple[str, int | None, frozenset]],
) -> list[list[tuple[str, int | None]]]:
"""The candidates of one tier split where no set links them."""
parent = list(range(len(found)))
def root(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
first_of: dict[object, int] = {}
for i, (_dest, _rank, sets) in enumerate(found):
for key in sets or {None}:
if key in first_of:
parent[root(i)] = root(first_of[key])
else:
first_of[key] = i
parts: dict[int, list[tuple[str, int | None]]] = {}
for i, (dest, rank, _sets) in enumerate(found):
parts.setdefault(root(i), []).append((dest, rank))
return list(parts.values())
+2 -1
View File
@@ -228,7 +228,7 @@ def profile_claims(
# outside the BIOS tree, where no platform declares a file.
if outside_gap_scope(entry, is_standalone) or read_from_save_dir(
entry, is_standalone
):
) or entry.get("unplaced"):
continue
# A member of a romset is not a file at the BIOS root: the archive
# holding it occupies the destination (_archive_claims). Claiming
@@ -306,6 +306,7 @@ def _archive_claims(
if isinstance(entry, dict)
and not outside_gap_scope(entry, is_standalone)
and not read_from_save_dir(entry, is_standalone)
and not entry.get("unplaced")
]
prefix = profile.get("archive_prefix", "")
claims: list[Claim] = []
+41 -10
View File
@@ -18,6 +18,7 @@ from common import (
resolve_platform_cores,
standalone_set as platform_standalone_set,
)
from alternatives import groups_of
from hashing import compute_hashes
from validation import (
filter_files_by_mode,
@@ -276,11 +277,15 @@ def _fold_into(existing: dict, file_entry: dict, emu_name: str) -> None:
existing["_source_refs"] = existing.get("_source_refs", set()) | {sr_key}
else:
existing.setdefault("_source_refs", set())
if file_entry.get("required"):
if groups_of(file_entry):
_fold_set_member(existing, file_entry, emu_name)
elif file_entry.get("required"):
existing["required"] = True
# Which cores need it: `required` above is the union, true when
# any core needs it, and a package of one core must not read it.
existing["_required_by"] = existing.get("_required_by", set()) | {emu_name}
elif file_entry.get("required") is not None:
existing.setdefault("required", file_entry["required"])
_merge_hashes(existing, file_entry, emu_name, revision)
# Merge non-hash data fields if existing lacks them.
# A core that creates an entry without size/path/validation may be
@@ -292,6 +297,21 @@ def _fold_into(existing: dict, file_entry: dict, emu_name: str) -> None:
_merge_region(existing, file_entry.get("region"))
def _fold_set_member(entry: dict, file_entry: dict, emu_name: str) -> None:
"""A core that takes this file as one of a set needs the set, not it.
Yabause loads the first of saturn_bios.bin, sega_101.bin and
mpr-17933.bin it finds. None of the three is required on its own, so
the core stays out of `required` and `_required_by`, and `_one_of`
names it with `one_of_required` saying whether its set is required.
"""
entry["_one_of"] = entry.get("_one_of", set()) | {emu_name}
if file_entry.get("required", True):
entry["one_of_required"] = True
else:
entry.setdefault("one_of_required", False)
def _merge_region(existing: dict, theirs: str | list | None) -> None:
"""The regions every reader selects the file for, the pack's own rule.
@@ -379,7 +399,8 @@ def _merge_priority(existing: dict, theirs: int | None) -> None:
def _new_truth_entry(file_entry: dict, emu_name: str, db: dict | None) -> dict:
entry: dict = {"name": file_entry["name"]}
if file_entry.get("required") is not None:
grouped = bool(groups_of(file_entry))
if file_entry.get("required") is not None and not grouped:
entry["required"] = file_entry["required"]
for field in _COPIED_FIELDS:
val = file_entry.get(field)
@@ -396,7 +417,9 @@ def _new_truth_entry(file_entry: dict, emu_name: str, db: dict | None) -> dict:
if not file_entry.get("region"):
entry["_region_void"] = True
entry["_cores"] = {emu_name}
entry["_required_by"] = {emu_name} if file_entry.get("required") else set()
entry["_required_by"] = {emu_name} if file_entry.get("required") and not grouped else set()
if grouped:
_fold_set_member(entry, file_entry, emu_name)
entry["_named_by"] = {emu_name} if file_entry.get("_opens_by_name", True) else set()
entry["_brought_by"] = _brought(file_entry, emu_name)
sr = file_entry.get("source_ref")
@@ -427,13 +450,15 @@ def _has_exploitable_data(entry: dict) -> bool:
def _system_dir_files(profile: dict, standalone: bool) -> list[dict]:
"""Entries of the build the platform runs, read from the system directory."""
return list(
filter(
read_from_system_dir,
filter_files_by_mode(profile.get("files", []), standalone=standalone),
)
)
"""Entries of the build the platform runs, read from the system directory.
An entry the platform's launchers leave outside the BIOS tree
(``unplaced``) is not: the platform has no file of its own there.
"""
return [
f for f in filter_files_by_mode(profile.get("files", []), standalone=standalone)
if read_from_system_dir(f) and not f.get("unplaced")
]
def _archives_not_members(files: list[dict]) -> list[dict]:
@@ -848,6 +873,9 @@ def generate_platform_truth(
fe.pop("_region_void", None)
fe["_cores"] = sorted(fe.get("_cores", set()))
fe["_required_by"] = sorted(fe.get("_required_by", set()))
one_of = fe.pop("_one_of", None)
if one_of:
fe["one_of"] = sorted(one_of)
fe["_named_by"] = sorted(fe.get("_named_by", set()))
fe["_brought_by"] = sorted(fe.get("_brought_by", set()))
if not fe["_brought_by"]:
@@ -950,6 +978,9 @@ def _diff_system(truth_sys: dict, scraped_sys: dict) -> dict:
hash_mismatch.append(mismatch)
t_req = t_entry.get("required")
s_req = s_entry.get("required")
if t_entry.get("one_of_required") and not t_req:
# Needed as one of a set: no value of its own to compare.
t_req = None
if t_req is not None and s_req is not None and t_req != s_req:
required_mismatch.append(
{
+17 -2
View File
@@ -422,8 +422,7 @@ def validate_file(
result.crc32 = hashes["crc32"]
ext = os.path.splitext(filepath)[1].lower()
if ext in BLOCKED_EXTENSIONS:
result.add_check(False, f"Blocked file extension: {ext}")
blocked = ext in BLOCKED_EXTENSIONS
too_large = result.size > MAX_FILE_SIZE
if too_large:
@@ -495,6 +494,22 @@ def validate_file(
or profile_hashes.get("crc32", {}).get((result.crc32, None))
)
if blocked:
# An executable is refused unless an emulator profile pins its exact
# bytes: PICO-8's pico8.exe and SDL2.dll are declared by sha1 from the
# official archive. A weak hash (md5, crc32) never vouches for code.
pinned = (
profile_hashes.get("sha1", {}).get(result.sha1)
or profile_hashes.get("sha256", {}).get(hashes.get("sha256", ""))
)
if pinned:
result.add_check(
True,
f"Executable whose bytes an emulator profile pins by sha1: {', '.join(sorted(pinned))}",
)
else:
result.add_check(False, f"Blocked file extension: {ext}")
# The files of a ZIP no list declares whole, when the upload is one.
members: list[str] | None = None
if sha1_known:
+40
View File
@@ -86,6 +86,7 @@ def _validate_yaml_directory(
out.extend(_save_dir_without_library_name(path, data))
out.extend(_systems_outside_profile(path, data))
out.extend(_doubled_paths(path))
out.extend(_group_requirement_split(path, data))
return out
@@ -122,6 +123,36 @@ def _systems_outside_profile(path: Path, document: object) -> list[str]:
return out
def _group_requirement_split(path: Path, document: object) -> list[str]:
"""A set named by a member and not declared, declared and never used,
or a member that writes a required of its own.
The requirement belongs to the set, in the profile's groups map, and a
member's is derived from it. A value on the member is a second source
that can contradict the first: a true on the first name only wrote the
search order into required, and the other names read as optional.
"""
from alternatives import groups_of
if not isinstance(document, dict):
return []
label = path.relative_to(ROOT)
declared = set((document.get("groups") or {}).keys())
used: set[str] = set()
out: list[str] = []
for index, entry in enumerate(document.get("files") or []):
if not isinstance(entry, dict):
continue
names = groups_of(entry)
used.update(names)
if names and "required" in entry:
out.append(f"{label}: files[{index}] ({entry.get('name')}) is in a group "
"and writes required: the set's requirement is in groups")
out.extend(f"{label}: group {g} has no entry in groups" for g in sorted(used - declared))
out.extend(f"{label}: groups declares {g}, which no entry names" for g in sorted(declared - used))
return out
def _checks_without_values(path: Path, document: object) -> list[str]:
"""A check an entry declares with nothing to compare against.
@@ -199,6 +230,12 @@ def _validate_pack_manifests(dist: Path) -> list[str]:
def _scan_pack_manifests(dist: Path) -> list[str]:
validator = _validator("pack-manifest.schema.json")
# The lists a pack carries for the installer, each under its own
# contract: the schemas name no other file, so they are checked here.
carried = {
"install_list": _validator("install-manifest.schema.json"),
"targets": _validator("target-manifest.schema.json"),
}
def _label(path: Path) -> str:
resolved = path.resolve()
@@ -224,6 +261,9 @@ def _scan_pack_manifests(dist: Path) -> list[str]:
out.append(f"{label}: {exc}")
continue
out.extend(_errors(validator, document, label))
for key, inner in carried.items():
if isinstance(document, dict) and key in document:
out.extend(_errors(inner, document[key], f"{label}:{key}"))
except (OSError, zipfile.BadZipFile) as exc:
out.append(f"{_label(archive)}: {exc}")
return out
+13
View File
@@ -606,9 +606,15 @@ def declaration_settles(
declared_names: Mapping[str, list[tuple[int | None, str]]] | set[str],
dest: str = "",
folded_slots: frozenset[str] = frozenset(),
declared_contents: Mapping[str, set[str]] | None = None,
) -> bool:
"""Whether a platform declaration meets a core's requirement.
An entry the core finds by content (``found_by: content`` on the entry)
is met by any declaration of a file it accepts by hash, in the folder it
scans: atari800 takes RetroDECK's ROM_BASIC_A by its CRC32, so its own
label for that ROM is not one more file to ship.
A platform declaring the name the core reads has met the requirement
where the declaration fills the entry's own slot, whose content the slot
arbitration decides, or elsewhere at a size the core accepts: the builder
@@ -630,6 +636,13 @@ def declaration_settles(
return True
slot = sanitize_pack_path(dest or fname).lower()
directory = slot if dest.endswith("/") else slot.rpartition("/")[0]
if declared_contents and file_entry.get("found_by") == "content":
for field in ("md5", "sha1", "crc32"):
values = file_entry.get(field) or []
for value in values if isinstance(values, list) else str(values).split(","):
folders = declared_contents.get(f"{field}:{str(value).strip().lower()}")
if folders and directory in folders:
return True
if not dest.endswith("/") and slot in folded_slots:
return True
for name in (fname, *file_entry.get("aliases", [])):
+288 -43
View File
@@ -37,7 +37,16 @@ import slots
sys.path.insert(0, os.path.dirname(__file__))
from hashing import parse_md5_list
from alternatives import (
core_file_spares,
set_fields,
set_keys,
set_requirements,
settle_sets,
slot_set_keys,
)
from common import (
OUTER_ROOTS,
_foreign_tree,
_owner_tokens,
platform_arg,
@@ -60,6 +69,7 @@ from common import (
compute_hashes,
expand_directory_entries,
platform_declarations,
platform_declared_contents,
group_identical_platforms,
list_emulator_profiles,
list_system_ids,
@@ -91,6 +101,7 @@ from common import (
yaml = require_yaml()
from exporter.candidates import BIOS, EntryCategories
from launchers import LauncherLayer, declared_destinations, held_record
from cross_reference import launcher_reads_not_held
from nativemode import (
SIZE_AND_ANY_HASH,
digest_algorithm,
@@ -453,6 +464,7 @@ def _candidate_verdict(
declared_names: Mapping[str, list[tuple[int | None, str]]] | set[str],
dest: str = "",
folded_slots: frozenset[str] = frozenset(),
declared_contents: Mapping[str, set[str]] | None = None,
) -> str:
"""Whether a profile entry can be a gap, and whether it is settled.
@@ -468,7 +480,7 @@ def _candidate_verdict(
if outside_gap_scope(file_entry, is_standalone):
return "skip"
if not include_all and declaration_settles(
file_entry, fname, declared_names, dest, folded_slots
file_entry, fname, declared_names, dest, folded_slots, declared_contents
):
return "settled"
return "keep"
@@ -502,6 +514,7 @@ def find_undeclared_files(
if declared_names is None:
declared_names = platform_declarations(config, db)
folded_slots = case_folded_slots(config, declared_names)
declared_contents = platform_declared_contents(config, db)
# Whether the builder drops a file whose local copy contradicts its
# declared hash, which decides if such a copy counts as held here.
@@ -533,6 +546,9 @@ def find_undeclared_files(
config, profiles, relevant, db, declared_destinations(config)
)
launcher_candidates: list[tuple[str, bool, dict, dict]] = []
# Each grouped requirement met, as (set keys, entry, fills, (reported
# entry, the entry it is a claim on)): settled once every core is read.
set_records: list[tuple[tuple, dict, bool, tuple[dict, dict | None] | None]] = []
undeclared = []
seen_files: set[tuple] = set()
# Key -> the entry reported for it. A later profile reading the same
@@ -586,12 +602,15 @@ def find_undeclared_files(
f.get("archive"),
dest,
f.get("system"),
f.get("variant_group"),
f.get("selection_slot"),
region_key,
# Below the save directory each core is handed a folder
# of its own: two cores reading hiscore.dat there read
# two files.
("save", core_save_folder(profile)) if save_root else None,
("save", core_save_folder(profile)) if save_root
else (f["root"],) if f.get("root") in OUTER_ROOTS
# In the emulator's own folder: no other core reads it.
else ("emulator", emu_name) if f.get("unplaced") else None,
)
if not fname or unread_on(f, config.get("_key", "")):
continue
@@ -599,12 +618,21 @@ def find_undeclared_files(
duplicate = seen_key in seen_files
if duplicate and (first is None or first["profile"] == emu_name):
continue
# Nor does it answer for one read below the roms directory.
verdict = _candidate_verdict(
f, fname, is_standalone, include_all,
{} if save_root else declared_names, dest, folded_slots,
{} if save_root or f.get("root") in OUTER_ROOTS else declared_names,
dest, folded_slots,
None if save_root or f.get("root") in OUTER_ROOTS else declared_contents,
)
if verdict == "settled":
seen_files.add(seen_key)
if (launcher_layer and not f.get("unsourceable") and not save_root
and f.get("root") not in OUTER_ROOTS and not f.get("unplaced")):
launcher_layer.declared_elsewhere(emu_name, is_standalone, f)
# Answered by the platform's own declaration, which its
# report covers: the set it belongs to is spoken for.
set_records.append((set_keys((emu_name, is_standalone), f), f, True, None))
continue
if verdict == "skip":
continue
@@ -617,7 +645,7 @@ def find_undeclared_files(
archive,
f.get("system_dir"),
f.get("system"),
f.get("variant_group"),
f.get("selection_slot"),
region_key,
is_standalone,
)
@@ -632,7 +660,7 @@ def find_undeclared_files(
"systems": entry_system_ids(f, profile),
"system": f.get("system"),
"region": f.get("region"),
"variant_group": f.get("variant_group"),
"selection_slot": f.get("selection_slot"),
"name": archive,
"archive": archive,
"path": system_dir_path(f, archive),
@@ -650,6 +678,14 @@ def find_undeclared_files(
}
if f.get("system_dir"):
archive_entries[archive_key]["system_dir"] = f["system_dir"]
# The archive's own declaration names its sets: an
# archive is a member like a loose file.
own = archive_entry(archive, profile.get("files", []))
archive_entries[archive_key].update(set_fields(own, profile))
set_records.append((
set_keys((emu_name, is_standalone), own), own, in_repo,
(archive_entries[archive_key], None),
))
entry = archive_entries[archive_key]
entry["archive_file_count"] += 1
if f.get("required", False):
@@ -730,7 +766,7 @@ def find_undeclared_files(
"systems": entry_system_ids(f, profile),
"system": f.get("system"),
"region": f.get("region"),
"variant_group": f.get("variant_group"),
"selection_slot": f.get("selection_slot"),
"name": fname,
"path": dest,
"required": f.get("required", False),
@@ -740,6 +776,7 @@ def find_undeclared_files(
"in_repo": in_repo,
"note": f.get("note", ""),
"checks": sorted(checks) if checks else [],
**set_fields(f, profile),
"source_ref": f.get("source_ref"),
"expected": _build_expected(f, checks),
**_identity_of(f),
@@ -758,14 +795,28 @@ def find_undeclared_files(
members = with_archive_members(f, profile.get("files", [])).get("members")
if members:
entry["members"] = members
if launcher_layer and not save_root:
if f.get("layout_path"):
entry["layout_path"] = f["layout_path"]
if f.get("unprefixed"):
entry["unprefixed"] = True
if f.get("unplaced"):
entry["unplaced"] = f["unplaced"]
elif f.get("root") in OUTER_ROOTS:
# Read below the roms directory (ROCKNIX's roms/pico-8)
# or in an emulator folder (RetroBat's emulators/pico8).
entry["root"] = f["root"]
elif launcher_layer and not save_root:
launcher_candidates.append((emu_name, is_standalone, f, entry))
keys = set_keys((emu_name, is_standalone), f)
if duplicate:
first.setdefault("other_claims", []).append(entry)
set_records.append((keys, f, in_repo, (entry, first)))
continue
reported[seen_key] = entry
undeclared.append(entry)
set_records.append((keys, f, in_repo, (entry, None)))
settled: set[int] = set()
if launcher_layer:
added, settled = launcher_layer.settle(
launcher_candidates, lambda report: held_record(report, db)
@@ -776,7 +827,99 @@ def find_undeclared_files(
for entry in sorted(archive_entries.values(), key=lambda e: e["name"]):
undeclared.append(entry)
return undeclared
return _split_bundled_claims(
_settle_core_sets(undeclared, set_records, settled, profiles)
)
def _split_bundled_claims(undeclared: list[dict]) -> list[dict]:
"""A file one emulator installs itself, another reads from the pack.
The first profile reading a requirement hosts the claims of the others,
and a host installed with its emulator is left out of the pack with all
of them: DraStic brings its own usrcheat.dat, and ROCKNIX's DSPerate,
which reads the same file, lost it wherever DraStic runs too. Built for
a board without DraStic, the pack carried it.
"""
out: list[dict] = []
for entry in undeclared:
claims = entry.get("other_claims") or []
rest = [c for c in claims if not c.get("bundled")]
if not entry.get("bundled") or not rest:
out.append(entry)
continue
own = [c for c in claims if c.get("bundled")]
kept = {k: v for k, v in entry.items() if k != "other_claims"}
if own:
kept["other_claims"] = own
promoted = dict(rest[0])
if rest[1:]:
promoted["other_claims"] = rest[1:]
out += [kept, promoted]
return out
def _settle_core_sets(
undeclared: list[dict],
records: list[tuple[tuple, dict, bool, tuple[dict, dict | None] | None]],
launcher_settled: set[int],
profiles: dict[str, dict],
) -> list[dict]:
"""The core complement with each set of alternatives settled.
A member the collection lacks is dropped when another member is held,
declared by the platform or placed by its launcher, and a set nobody
holds keeps one entry, its first member, naming the others. A claim
another profile shares stays: its own core may need that very file.
"""
# Archive units rank first among equals, as the builder orders them.
grouped = sorted(
(r for r in records if r[0]),
key=lambda r: not (r[3] is not None and r[3][0].get("archive")),
)
if not grouped:
return undeclared
excused, standing = settle_sets([
(keys, f, fills or (ref is not None and id(ref[0]) in launcher_settled))
for keys, f, fills, ref in grouped
], lambda key: set_requirements(profiles.get(key[0][0], {})).get(key[1], True))
declared_sets = {
key for keys, _f, _fills, ref in grouped if ref is None for key in keys
}
dropped: set[int] = set()
for position, (keys, _f, _fills, ref) in enumerate(grouped):
if ref is None:
continue
entry, host = ref
required_keys = [k for k in keys if k[1] in entry.get("required_groups", ())]
if required_keys and all(k in declared_sets for k in required_keys):
# The platform declares a member of each of its required sets:
# under --required-only that declaration carries them.
entry["set_declared"] = True
if position in standing:
entry["alternatives"] = standing[position].others
entry["required"] = standing[position].required
if position not in excused:
continue
if host is not None:
host["other_claims"] = [c for c in host.get("other_claims", []) if c is not entry]
if not host["other_claims"]:
del host["other_claims"]
else:
dropped.add(id(entry))
out: list[dict] = []
for entry in undeclared:
if id(entry) not in dropped:
out.append(entry)
continue
# Another core reading the same requirement still needs it.
claims = entry.get("other_claims") or []
if claims:
promoted = dict(claims[0])
if claims[1:]:
promoted["other_claims"] = claims[1:]
out.append(promoted)
return out
def find_exclusion_notes(
@@ -866,6 +1009,10 @@ def find_exclusion_notes(
_UNPLACED_REASONS = {
"outside_bios_folder": "read in a folder the platform keeps outside the BIOS tree",
"no_roms_directory": "read below a roms folder the platform's registry does not name",
"no_emulators_directory": "read in an emulator folder the platform's registry does not describe",
"no_configs_directory": "read below a configs folder the platform's file does not record",
"no_platform_slug": "no platform slug for its system",
"below_platform_folder": "below the folder the platform lists",
"no_save_directory": "read below a save directory the platform keeps in no fixed place",
@@ -889,6 +1036,13 @@ def _mark_unplaced(config: dict, undeclared: list[dict], profiles: dict) -> None
reason = unplaced.get(
(u.get("emulator", ""), u.get("name", ""), u.get("path") or "")
)
if not reason and u.get("root") in OUTER_ROOTS:
# The pack is extracted into the BIOS folder: the installer
# writes this file into the roms or emulator folder, the pack
# cannot.
u["in_pack"] = False
u["omitted"] = f"read below the {u['root']} folder, which only the installer reaches"
continue
if reason:
u["in_pack"] = False
u["omitted"] = _UNPLACED_REASONS[reason]
@@ -1429,6 +1583,25 @@ def verify_platform(
):
file_severity[dest] = sev
# RetroBat's group: one file of it held is enough, and the others are
# reported OPTIONAL (batocera-systems/Program.cs). None held, each stays
# required.
members: dict[tuple[str, str], list[str]] = {}
for sys_id, system in config.get("systems", {}).items():
for file_entry in system.get("files", []):
if file_entry.get("group"):
dest = _dest_key(file_entry, sys_id)
if dest in file_status:
members.setdefault((sys_id, file_entry["group"]), []).append(dest)
for dests in members.values():
if any(file_status[d] == Status.OK for d in dests):
for dest in dests:
if file_status[dest] == Status.MISSING:
file_required[dest] = False
file_severity[dest] = compute_severity(Status.MISSING, False, mode)
for result in results_at.get(dest, []):
result["required"] = False
shipped, occupied = _builder_placement(
packed_order, bool(config.get("case_insensitive_fs", False))
)
@@ -1466,6 +1639,10 @@ def verify_platform(
)
not in region_drops
]
if required_only:
# One member of a required set, as the builder keeps it.
spares = core_file_spares(undeclared)
undeclared = [u for u in undeclared if id(u) not in spares]
_mark_unplaced(config, undeclared, profiles)
from packextras import ExtraResolver
@@ -1576,6 +1753,10 @@ def verify_platform(
"excluded_files": excluded,
"packed_extras": packed_extras,
"undeclared_files": undeclared,
# Reported apart, never scored: no severity, count or exit code reads it.
"launcher_reads_not_held": launcher_reads_not_held(
{config.get("_key", platform): config}, db, profiles, data_dir_registry
),
"exclusion_notes": exclusions,
"region_fallbacks": sorted(region_fallbacks),
**({"left_out": left_out} if no_game_data else {}),
@@ -1778,6 +1959,8 @@ def _print_undeclared_entry(
name_label = f"{name} ({arc_count} file{'s' if arc_count != 1 else ''})"
else:
name_label = name
if u.get("alternatives") and not u.get("in_repo"):
name_label += f" (or {', '.join(u['alternatives'])})"
print(f" {prefix}: {u['emulator']} needs {name_label}")
checks = u.get("checks", [])
if checks:
@@ -1940,6 +2123,28 @@ def _print_undeclared_section(result: dict, verbose: bool) -> None:
)
def _print_launcher_reads(result: dict, verbose: bool) -> None:
"""Names the platform's launch code reads that the collection lacks."""
every = result.get("launcher_reads_not_held") or []
rows = [r for r in every if not r.get("unsourceable")]
unsourceable = [r for r in every if r.get("unsourceable")]
listed = "" if verbose else " (--verbose lists them)"
if rows:
print(f" Launcher reads, not held: {len(rows)}{listed}")
if unsourceable:
print(f" Launcher reads, unsourceable: {len(unsourceable)}{listed}")
if not verbose:
return
for row in unsourceable:
print(f" {row['name']} [{', '.join(row['emulators'])}]: {row['unsourceable']}")
for row in rows:
kinds = ", ".join(sorted({r["kind"] for r in row["readers"]}))
casing = f", held as {row['held_as']}" if row.get("held_as") else ""
print(f" {row['name']} [{', '.join(row['emulators'])}, {kinds}{casing}]")
for reader in row["readers"]:
print(f" {reader['emulator']}: {reader['source_ref']}")
def print_platform_result(
result: dict, group: list[str], verbose: bool = False
) -> None:
@@ -2007,6 +2212,7 @@ def print_platform_result(
seen_details: set[str] = set()
_print_detail_entries(result["details"], seen_details, verbose)
_print_undeclared_section(result, verbose)
_print_launcher_reads(result, verbose)
# The same notes come back for every platform running these cores and
# filled 985 of the 1114 lines of verify --all: counted, listed on ask.
@@ -2129,6 +2335,9 @@ def verify_emulator(
return local_path, status
details = []
# Members of a set another member fills, or that the set's first member
# stands for: not missing, and out of every count.
set_members: list[dict] = []
file_status: dict[str, str] = {}
file_severity: dict[str, str] = {}
dest_to_name: dict[str, str] = {}
@@ -2234,6 +2443,18 @@ def verify_emulator(
if fe.get("archive")
or _resolve_destination(fe, standalone) not in region_drops
]
spares: set[str] = set()
if required_only:
from packextras import emulator_required_spares
spares = emulator_required_spares(
emu_name, profile, files, standalone, region_drops, db,
validation_index, zip_contents, data_registry,
)
files = [
fe for fe in files
if fe.get("archive") or _resolve_destination(fe, standalone) not in spares
]
# Check data directories (only notice if not cached)
for dd in deliverable(
@@ -2264,6 +2485,12 @@ def verify_emulator(
occupy_trees(emu_name, profile)
continue
# Each destination's outcome, settled by set once the profile is
# read: an archive unit is a member like a loose file (Kronos reads
# kronos/stvbios.zip, else stvbios.zip).
placed_results: list[tuple[str, dict, bool, bool, list[dict]]] = []
fallback_reason = "declared hash contradicted by the local file"
# Verify archives as units (e.g., neogeo.zip, aes.zip)
seen_archives: set[str] = set()
for file_entry in files:
@@ -2271,6 +2498,11 @@ def verify_emulator(
if archive and archive not in seen_archives:
seen_archives.add(archive)
own_entry = archive_entry(archive, files)
if _archive_destination(
archive, profile.get("archive_prefix", ""),
profile.get("pack_structure"), standalone,
) in spares:
continue
local_path, resolve_status = pending(own_entry, *resolve_file(
own_entry,
db,
@@ -2313,7 +2545,6 @@ def verify_emulator(
result["system"] = file_entry.get("system", "")
result["hle_fallback"] = False
result["ground_truth"] = build_ground_truth(archive, validation_index)
details.append(result)
# Where the pack puts it: an archive_prefix or pack_structure
# moves it, and a loose same-named entry is another file.
dest = _archive_destination(
@@ -2337,25 +2568,13 @@ def verify_emulator(
elif local_path:
occupy(dest, local_path, emu_name)
packed_dests.add(dest)
dest_to_name[dest] = archive
cur = result["status"]
prev = file_status.get(dest)
if prev is None or _STATUS_ORDER.get(cur, 0) > _STATUS_ORDER.get(
prev, 0
):
file_status[dest] = cur
sev = _emulator_severity(cur, required, False)
prev_sev = file_severity.get(dest)
if prev_sev is None or _SEVERITY_ORDER.get(
sev, 0
) > _SEVERITY_ORDER.get(prev_sev, 0):
file_severity[dest] = sev
placed_results.append((dest, result, required, False, [own_entry]))
# Results of this emulator, by destination. Several entries of one
# profile on one destination are the releases the code accepts
# there (a shareware and a retail HOG, each GRP in a size table):
# holding one of them fills the slot, and the others are not gaps.
emu_results: dict[str, list[tuple[dict, bool, bool]]] = {}
emu_results: dict[str, list[tuple[dict, bool, bool, dict]]] = {}
for file_entry in files:
# Skip archive members (verified as archive units above)
@@ -2455,13 +2674,13 @@ def verify_emulator(
# The slot the emulator pack places this entry in: standalone_path
# under --standalone, the sanitised path.
dest = _resolve_destination(file_entry, standalone) or name
emu_results.setdefault(dest, []).append((result, required, hle))
emu_results.setdefault(dest, []).append((result, required, hle, file_entry))
for dest, alternatives in emu_results.items():
# What the emulator pack places here: an accepted file, else a
# file whose hash the profile contradicts. A refused copy is held
# but never packed.
fallback_reason = "declared hash contradicted by the local file"
slot_entries = [a[3] for a in alternatives]
if len(alternatives) > 1:
# Reduced in the order generate_emulator_pack chooses, so the
# destination's status is the one the pack reports: a slot it
@@ -2514,27 +2733,50 @@ def verify_emulator(
elif not block and packed is not None:
occupy(dest, packed, emu_name)
packed_dests.add(dest)
for result, required, hle in alternatives:
for result, required, hle, file_entry in alternatives:
if block_reason and result["status"] != Status.MISSING:
result["status"] = Status.MISSING
result["reason"] = block_reason
result["destination"] = dest
details.append(result)
placed_results.append((dest, result, required, hle, slot_entries))
# Aggregate by destination (path if available, else name)
dest_to_name[dest] = result["name"]
cur = result["status"]
prev = file_status.get(dest)
if prev is None or _STATUS_ORDER.get(cur, 0) > _STATUS_ORDER.get(
prev, 0
):
file_status[dest] = cur
sev = _emulator_severity(cur, required, hle)
prev_sev = file_severity.get(dest)
if prev_sev is None or _SEVERITY_ORDER.get(
sev, 0
) > _SEVERITY_ORDER.get(prev_sev, 0):
file_severity[dest] = sev
# A set the code takes one member of is one need: once a member is
# in the pack the others are not missing, and a set nobody holds is
# one gap under its first member. Settled per profile, before the
# destinations of several profiles are merged.
excused, standing = settle_sets([
(
slot_set_keys(emu_name, entries),
entries[0],
result["status"] == Status.OK
or (result["status"] == Status.UNTESTED
and result.get("reason") == fallback_reason),
)
for _dest, result, _required, _hle, entries in placed_results
], lambda key: set_requirements(profile).get(key[1], True))
for position, (dest, result, required, hle, _fe) in enumerate(placed_results):
if position in excused and result["status"] == Status.MISSING:
set_members.append(result)
continue
if position in standing:
result["alternatives"] = standing[position].others
result["required"] = required = standing[position].required
details.append(result)
# Aggregate by destination (path if available, else name)
dest_to_name[dest] = result["name"]
cur = result["status"]
prev = file_status.get(dest)
if prev is None or _STATUS_ORDER.get(cur, 0) > _STATUS_ORDER.get(
prev, 0
):
file_status[dest] = cur
sev = _emulator_severity(cur, required, hle)
prev_sev = file_severity.get(dest)
if prev_sev is None or _SEVERITY_ORDER.get(
sev, 0
) > _SEVERITY_ORDER.get(prev_sev, 0):
file_severity[dest] = sev
occupy_trees(emu_name, profile)
@@ -2568,6 +2810,7 @@ def verify_emulator(
"severity_counts": counts,
"status_counts": status_counts,
"details": details,
"set_members": set_members,
"data_dir_notices": sorted(set(data_dir_notices)),
"ground_truth_coverage": {
"with_validation": with_validation,
@@ -2673,7 +2916,9 @@ def print_emulator_result(result: dict, verbose: bool = False) -> None:
seen.add(d["name"])
req = "required" if d.get("required", True) else "optional"
hle = ", HLE available" if d.get("hle_fallback") else ""
print(f" MISSING ({req}{hle}): {d['name']}")
others = d.get("alternatives")
either = f" (or {', '.join(others)})" if others else ""
print(f" MISSING ({req}{hle}): {d['name']}{either}")
gt = d.get("ground_truth", [])
if gt:
if verbose: