mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-12 21:13:49 -06:00
chore: merge origin/main
# Conflicts: # install.ps1 # install.sh # install/launchers/index.json
This commit is contained in:
commit
8e15f94ed1
2865 files changed
+187255
-99594
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", []):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 []
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user