chore: merge site fixes

This commit is contained in:
Abdessamad Derraz committed 2026-11-06 18:17:24 +01:00
commit 5b6feef785
286 files changed
+2851 -285

No files matched your search

+3
View File
@@ -116,6 +116,9 @@ def main() -> int:
parser.add_argument("--json", action="store_true", help="print the list as JSON")
parser.add_argument("--strict", action="store_true", help="exit 1 while a file is listed")
args = parser.parse_args()
from common import work_from_repo_root # noqa: PLC0415
work_from_repo_root(args, parser, ["db", "emulators_dir", "platforms_dir", "install_dir"])
rows = bundled_only(
load_database(args.db), load_emulator_profiles(args.emulators_dir),
+6 -3
View File
@@ -83,14 +83,17 @@ from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass, field
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
# First: core_probe stops an interpreter older than 3.9 before anything else
# fails on it.
import core_probe
import yaml
import autorun
import campaign_profiles
import campaign_rules as rules
import core_probe
import measurement_records as mr
ROOT = Path(__file__).resolve().parent.parent
+38 -17
View File
@@ -54,11 +54,13 @@ import pin_installer # noqa: E402
import refresh_data_dirs # noqa: E402
import upstream # noqa: E402
from common import ( # noqa: E402
InputMissing,
apply_target_overrides,
list_registered_platforms,
load_emulator_profiles,
load_platform_registry,
require_directory,
suggest_names,
upstream_profile_index,
write_text_atomic,
yaml_load,
@@ -73,9 +75,13 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
CORE_INFO_REPO = "https://github.com/libretro/libretro-core-info"
# The git blob of every .info as last reviewed: `--record-coreinfo` writes it.
CORE_INFO_RECORD = REPO_ROOT / "platforms" / "_core_info.json"
NO_INTRO_MIRROR = "https://github.com/hugo19941994/auto-datfile-generator"
NO_INTRO_RELEASE = "Daily_Rebuild"
NO_INTRO_ASSET = "no-intro.zip"
NO_INTRO_MIRROR = game_dat_importer.NOINTRO_MIRROR
NO_INTRO_RELEASE = game_dat_importer.NOINTRO_RELEASE
NO_INTRO_ASSET = game_dat_importer.NOINTRO_ASSET
# The mirror rebuilds daily. A pack older than this is a mirror that stopped:
# hugo19941994's failed every day from 2026-07-08, and comparing our import
# date with its asset read OK for four months.
NO_INTRO_MAX_AGE_DAYS = 7
TOSEC_DOWNLOADS = "https://www.tosecdev.org/downloads"
MAME_REPO = "https://github.com/mamedev/mame"
FBNEO_REPO = "https://github.com/libretro/FBNeo"
@@ -893,7 +899,10 @@ def record_core_info(
"""
unknown = sorted(set(names) - set(blobs))
if unknown:
raise ValueError(f"no such .info in core-info: {', '.join(unknown)}")
raise InputMissing(
f"no such .info in core-info: {', '.join(unknown)}. "
+ suggest_names(unknown, list(blobs), "libretro-core-info")
)
recorded = load_core_info_record(path) or {}
recorded.update({n: blobs[n] for n in (names or blobs)})
payload = {"repository": CORE_INFO_REPO, "blobs": dict(sorted(recorded.items()))}
@@ -1083,6 +1092,25 @@ def _no_intro_asset_date(cache_dir: str, offline: bool) -> str:
return ""
def _today() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _no_intro_row(subject: str, local: str, updated: str) -> Finding:
"""Our import against the mirror's pack, and the pack against today."""
age = (datetime.fromisoformat(_today()) - datetime.fromisoformat(updated)).days
if age > NO_INTRO_MAX_AGE_DAYS:
return Finding(
"catalogs", subject, STALE, local=local, remote=updated,
detail=f"the mirror last rebuilt {NO_INTRO_ASSET} on {updated}, {age} days ago, "
f"though it rebuilds daily: {NO_INTRO_MIRROR}/actions",
)
return Finding(
"catalogs", subject, STALE if updated > local else OK,
local=local, remote=updated, detail=f"{NO_INTRO_ASSET} rebuilt {updated}",
)
def _check_nointro_games(cache_dir: str, offline: bool) -> Finding:
"""games/catalogs/no-intro/ against the pack it was read from. The pack
holds every DAT: rebuilt after the catalogs were read, it may carry
@@ -1094,10 +1122,7 @@ def _check_nointro_games(cache_dir: str, offline: bool) -> Finding:
updated = _no_intro_asset_date(cache_dir, offline)
if not updated:
return Finding("catalogs", subject, UNKNOWN, local=fetched, detail="asset not listed")
return Finding(
"catalogs", subject, STALE if updated > fetched else OK,
local=fetched, remote=updated, detail=f"{NO_INTRO_ASSET} rebuilt {updated}",
)
return _no_intro_row(subject, fetched, updated)
def redump_datfile_version(code: str) -> str:
@@ -1171,14 +1196,7 @@ def _check_no_intro(cache_dir: str, offline: bool) -> Finding:
updated = _no_intro_asset_date(cache_dir, offline)
if not updated:
return Finding("catalogs", "no-intro", UNKNOWN, local=imported, detail="asset not listed")
return Finding(
"catalogs",
"no-intro",
STALE if updated > imported else OK,
local=imported,
remote=updated,
detail=f"{NO_INTRO_ASSET} rebuilt {updated}",
)
return _no_intro_row("no-intro", imported, updated)
def tosec_latest_pack(html: str) -> str | None:
@@ -1839,7 +1857,10 @@ def main() -> int:
if args.record_coreinfo is not None:
blobs = core_info_blobs(args.cache_dir, args.offline)
if not blobs:
raise RuntimeError("core-info listing unavailable: nothing recorded")
raise InputMissing(
f"core-info listing not in {args.cache_dir}: run without --offline"
if args.offline else "core-info listing unavailable: the GitHub API did not answer"
)
done = record_core_info(blobs, args.record_coreinfo)
print(f"recorded {len(done)} .info file(s) as reviewed")
return 0
+8 -8
View File
@@ -1,11 +1,9 @@
"""Check a BIOS folder against one platform's list, from the published data only:
python check_platform_folder.py batocera path/to/bios. Exit 1 on a file wrong or missing."""
"""Usage: python check_platform_folder.py batocera path/to/bios. Exit 1 on a file wrong or missing."""
from __future__ import annotations
import hashlib
import json
import sys
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
@@ -42,14 +40,12 @@ def check(platform: str, root: Path, api: str = API) -> list[tuple[str, str]]:
"""(destination, ok | wrong | missing) per entry. With no destination (EmuDeck), any file with its hash passes."""
with urllib.request.urlopen(f"{api}{platform}.json") as response:
contract = json.load(response)["items"][0]["contract"]
mode, results = contract["verification_mode"], []
loose = [path for path in root.iterdir() if path.is_file()] if root.is_dir() else []
mode, results, loose = contract["verification_mode"], [], [p for p in root.iterdir() if p.is_file()]
for system_id, system in contract["systems"].items():
listed = [e for e in system.get("files") or [] if not e.get("destination")]
for entry in (e for e in system.get("files") or [] if e.get("destination")):
path = root / entry["destination"]
verdict = "ok" if accepts(entry, path, mode) else "wrong" if path.is_file() else "missing"
results.append((entry["destination"], verdict))
results.append((entry["destination"], "ok" if accepts(entry, path, mode) else "wrong" if path.is_file() else "missing"))
if listed:
held = any(accepts(e, path, mode) for e in listed for path in loose)
results.append((f"{system_id}: any file", "ok" if held else "missing"))
@@ -57,12 +53,16 @@ def check(platform: str, root: Path, api: str = API) -> list[tuple[str, str]]:
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
try:
found = check(sys.argv[1], Path(sys.argv[2]))
except urllib.error.HTTPError as error: # a 404: no platform has that name
except urllib.request.HTTPError as error: # a 404: no platform has that name
with urllib.request.urlopen(API.replace("platforms/", "catalog.json")) as response:
known = sorted(i["url"][10:-5] for i in json.load(response)["items"] if i["url"].startswith("manifests/"))
sys.exit(f"unknown platform: {sys.argv[1]}. Known: {', '.join(known)}" if error.code == 404 else error)
except OSError as error: # no such folder, or no network (URLError)
sys.exit(f"error: {error}")
for verdict in ("ok", "wrong", "missing"):
names = sorted({dest for dest, seen in found if seen == verdict})
print(f"{verdict}: {len(names)}" + "".join(f"\n {name}" for name in names if verdict != "ok"))
+101 -15
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterable
from collections import Counter
import argparse
import filecmp
import hashlib
import functools
@@ -19,6 +20,16 @@ import signal
import sys
import zipfile
from pathlib import Path
from typing import Any, TextIO
# 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.
if sys.version_info < (3, 9):
sys.exit(
f"{Path(sys.argv[0]).name} needs Python 3.9 or newer, "
f"this is {sys.version.split()[0]}"
)
try:
@@ -53,6 +64,33 @@ class DatabaseUnreadable(InputMissing):
"""The database a command was pointed at does not exist or is not JSON."""
class SourceUnreadable(ValueError):
"""A profile or platform file that is not valid YAML. Not an InputMissing:
the callers that skip a platform whose file is absent must not skip one
whose file is damaged. run_cli prints the message on one line."""
def source_yaml(stream: TextIO) -> Any:
"""Parse one of our YAML sources, naming the file a syntax error is in.
Every profile is loaded whatever the command, so one misindented key in
an unrelated profile stopped `verify.py --emulator handy` on a 37-line
traceback, the faulty file named only near its end.
"""
if yaml is None:
return yaml_load(stream)
try:
return yaml_load(stream)
except yaml.YAMLError as exc:
mark = getattr(exc, "problem_mark", None)
where = f"{stream.name}:{mark.line + 1}" if mark else stream.name
problem = getattr(exc, "problem", None) or str(exc).splitlines()[0]
raise SourceUnreadable(
f"{where}: {problem}. Check it with: "
"python scripts/validate_schemas.py --source-only"
) from None
def load_database(db_path: str) -> dict:
"""Load database.json and return parsed dict.
@@ -64,6 +102,18 @@ def load_database(db_path: str) -> dict:
with open(db_path) as f:
return json.load(f)
except FileNotFoundError:
# A mistyped --db beside the real database was told to rescan the
# whole collection into a second file under the typo.
import difflib # noqa: PLC0415
typed = Path(db_path)
close = difflib.get_close_matches(
typed.name, [p.name for p in typed.parent.glob("*.json")], n=1, cutoff=0.8
)
if close:
raise DatabaseUnreadable(
f"{db_path} not found. Did you mean {typed.with_name(close[0])}?"
) from None
raise DatabaseUnreadable(
f"{db_path} not found. Build it with: python scripts/generate_db.py "
f"--force --bios-dir bios --output {db_path}"
@@ -302,7 +352,7 @@ def _inherited_yaml(platform_name: str, platforms_dir: str) -> tuple[dict, list[
raise FileNotFoundError(f"Platform config not found: {config_file}")
with open(config_file) as f:
config = yaml_load(f) or {}
config = source_yaml(f) or {}
if "inherits" not in config:
return config, [platform_name]
@@ -348,7 +398,7 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") -
reg_real = os.path.realpath(registry_path)
if reg_real not in _shared_yml_cache:
with open(registry_path) as f:
_shared_yml_cache[reg_real] = yaml_load(f) or {}
_shared_yml_cache[reg_real] = source_yaml(f) or {}
reg = _shared_yml_cache[reg_real]
# The platform's own entry first, then its parents' for what it does
# not say: RetroPie's status, scraper and install are its own, its
@@ -387,7 +437,7 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") -
shared_real = os.path.realpath(shared_path)
if shared_real not in _shared_yml_cache:
with open(shared_path) as f:
_shared_yml_cache[shared_real] = yaml_load(f) or {}
_shared_yml_cache[shared_real] = source_yaml(f) or {}
shared = _shared_yml_cache[shared_real]
shared_groups = shared.get("shared_groups", {})
for system in config.get("systems", {}).values():
@@ -494,7 +544,7 @@ def load_data_dir_registry(platforms_dir: str = "platforms") -> dict:
if not os.path.exists(registry_path):
return {}
with open(registry_path) as f:
data = yaml_load(f) or {}
data = source_yaml(f) or {}
return data.get("data_directories", {})
@@ -504,7 +554,7 @@ def load_platform_registry(platforms_dir: str = "platforms") -> dict:
if not os.path.exists(registry_path):
return {}
with open(registry_path) as f:
return (yaml_load(f) or {}).get("platforms", {})
return (source_yaml(f) or {}).get("platforms", {})
def list_registered_platforms(platforms_dir: str = "platforms") -> list[str]:
@@ -521,7 +571,7 @@ def list_registered_platforms(platforms_dir: str = "platforms") -> list[str]:
if not os.path.exists(registry_path):
raise InputMissing(f"no platform registry at {registry_path}")
with open(registry_path) as f:
registry = yaml_load(f) or {}
registry = source_yaml(f) or {}
platforms = []
for name, meta in sorted(registry.get("platforms", {}).items()):
config_path = os.path.join(platforms_dir, meta.get("config", f"{name}.yml"))
@@ -621,6 +671,31 @@ def suggest_names(
return f"{listing_flag} lists all {total}."
def work_from_repo_root(
args: argparse.Namespace, parser: argparse.ArgumentParser, path_flags: list[str]
) -> None:
"""Run from the repository root, whichever directory the command started in.
database.json records every file from the root and the data registry
names its caches the same way, so a run from scripts/ ended on a
FileNotFoundError traceback. A path the caller typed is read from where
they typed it, a default from the root.
"""
root = Path(__file__).resolve().parent.parent
if Path.cwd().resolve() == root:
return
for flag in path_flags:
value = getattr(args, flag, None)
if value and value != parser.get_default(flag):
# A Path stays a Path for the code that joins onto it.
setattr(args, flag, type(value)(os.path.abspath(value)))
os.chdir(root)
if getattr(args, "platform", None):
# Read before the move, the registry was not there to map
# "RetroArch" to its slug.
args.platform = platform_arg(args.platform)
def platform_arg(value: str) -> str:
"""--platform as typed, mapped to the registry's slug when it names one.
@@ -669,7 +744,7 @@ def _target_overrides(platform_name: str, targets_dir: str) -> dict:
if not os.path.exists(overrides_file):
return {}
with open(overrides_file) as f:
all_overrides = yaml_load(f) or {}
all_overrides = source_yaml(f) or {}
return all_overrides.get(platform_name, {}).get("targets", {})
@@ -690,7 +765,7 @@ def platform_files_target(platform: str, target: str, platforms_dir: str = "plat
if not os.path.exists(target_file):
return False
with open(target_file) as f:
targets = (yaml_load(f) or {}).get("targets", {})
targets = (source_yaml(f) or {}).get("targets", {})
return target in _target_alias_index(targets, _target_overrides(platform, targets_dir))
@@ -710,7 +785,7 @@ def canonical_target_name(
if not os.path.exists(target_file):
continue
with open(target_file) as f:
targets = (yaml_load(f) or {}).get("targets", {})
targets = (source_yaml(f) or {}).get("targets", {})
canonical = _target_alias_index(
targets, _target_overrides(platform_name, targets_dir)
).get(target)
@@ -764,7 +839,7 @@ def load_target_config(
f"(platforms with targets: {', '.join(with_targets)})"
)
with open(target_file) as f:
data = yaml_load(f) or {}
data = source_yaml(f) or {}
targets = data.get("targets", {})
overrides = _target_overrides(platform_name, targets_dir)
@@ -822,7 +897,7 @@ def list_available_targets(
if not os.path.exists(target_file):
return []
with open(target_file) as f:
data = yaml_load(f) or {}
data = source_yaml(f) or {}
# The count --target filters on: overrides applied, as load_target_config
# applies them. The scraped list counted Batocera's machine variants and
@@ -2229,7 +2304,7 @@ def load_emulator_profiles(
raise InputMissing(f"no emulator profile directory at {emulators_dir}")
for f in source_yaml_paths(emu_path):
with open(f) as fh:
profile = yaml_load(fh) or {}
profile = source_yaml(fh) or {}
if "emulator" not in profile:
continue
_normalize_hash_fields(profile)
@@ -2359,7 +2434,7 @@ def group_identical_platforms(
try:
raw_path = os.path.join(platforms_dir, f"{platform}.yml")
with open(raw_path) as f:
raw = yaml_load(f) or {}
raw = source_yaml(f) or {}
inherits[platform] = "inherits" in raw
config = load_platform_config(platform, platforms_dir)
except FileNotFoundError:
@@ -3503,6 +3578,9 @@ def build_target_cores_cache(
raise
notes.append(f"INFO: Skipping {p}: no hardware targets")
skip.append(p)
except SourceUnreadable:
# A damaged targets file is not a platform without the target.
raise
except ValueError as e:
if is_all:
notes.append(f"INFO: Skipping {p}: {e}")
@@ -3530,7 +3608,7 @@ def _unknown_target_message(target: str, platforms: list[str], platforms_dir: st
if not os.path.exists(target_file):
continue
with open(target_file) as f:
targets = (yaml_load(f) or {}).get("targets", {})
targets = (source_yaml(f) or {}).get("targets", {})
overrides = _target_overrides(platform, targets_dir)
for name in _target_alias_index(targets, overrides):
owners.setdefault(name, []).append(platform)
@@ -3624,10 +3702,18 @@ def run_cli(main: Callable[[], int | None]) -> None:
for name in ("SIGTERM", "SIGHUP"):
if hasattr(signal, name):
signal.signal(getattr(signal, name), _exit_on_signal)
# Redirected, stdout is block-buffered and stderr is not: the --strict
# verdict landed on the first line of the log, before the report it
# closes. Line buffering keeps the two in the order they were written.
# A stream a caller swapped in (a test's StringIO) has nothing to set.
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(line_buffering=True)
try:
code = main()
sys.stdout.flush()
except (InputMissing, ArtifactLockBusy, ArtifactDirectoryMissing) as exc:
except (InputMissing, SourceUnreadable, ArtifactLockBusy,
ArtifactDirectoryMissing) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)
except BrokenPipeError:
+8
View File
@@ -40,6 +40,14 @@ from collections.abc import Iterable, Iterator, Sequence
from dataclasses import dataclass, field
from pathlib import Path
# The probe tools do not import common.py, which states the 3.9 floor for the
# other scripts. Under 3.8 they stopped on a str.removesuffix AttributeError.
if sys.version_info < (3, 9):
sys.exit(
f"{Path(sys.argv[0]).name} needs Python 3.9 or newer, "
f"this is {sys.version.split()[0]}"
)
ROOT = Path(__file__).resolve().parent.parent
MIN_BENCH_REPETITIONS = 5
DEFAULT_WORK_DIR = ROOT / "tmp" / "core_probe"
+3
View File
@@ -1191,6 +1191,9 @@ def main() -> None:
"--list-emulators", action="store_true", help="List available emulators"
)
args = parser.parse_args()
from common import work_from_repo_root # noqa: PLC0415
work_from_repo_root(args, parser, ["emulators_dir", "platforms_dir", "db"])
if args.list_emulators:
ignored = [flag for flag, on in (
("--emulator", args.emulator), ("--platform", args.platform),
+21 -8
View File
@@ -221,12 +221,19 @@ class Exporter(BaseExporter):
ours = str(entry.get("path") or entry.get("name", "")).split("/")[:-1]
return not {d.casefold() for d in theirs} & {d.casefold() for d in ours}
def _bios_element(self, fe: NativeFile, native_id: str) -> str:
def _bios_element(
self, fe: NativeFile, native_id: str, also: tuple[NativeFile, ...] = ()
) -> str:
"""``also`` are further additions at fe's path: Bios.cpp reads one
element per path, and its md5 and core lists take every one of them."""
group = (fe, *also)
md5 = dict.fromkeys(h for f in group for h in f.hashes("md5"))
cores = dict.fromkeys(c for f in group for c in f.cores())
attrs = [f"path={quoteattr(self._path(fe))}"]
attrs.append(f'md5={quoteattr(",".join(fe.hashes("md5")))}')
attrs.append(f'core={quoteattr(",".join(fe.cores()))}')
attrs.append(f'md5={quoteattr(",".join(md5))}')
attrs.append(f'core={quoteattr(",".join(cores))}')
if not self.required_value(fe):
if not any(self.required_value(f) for f in group):
attrs.append('mandatory="false"')
elif fe.native("mandatory_declared", None) is True:
attrs.append('mandatory="true"')
@@ -343,11 +350,17 @@ class Exporter(BaseExporter):
}
ours: dict[str, tuple[NativeSystem, dict[str, tuple[NativeFile, str]]]] = {}
for system in systems.values():
writable = [fe for fe in system.files if self.writable(fe)]
if writable:
# Two additions at one path (two cores, two dumps) are one
# element: keyed by path, the second replaced the first and its
# md5 left the file while both were counted added.
at_path: dict[str, list[NativeFile]] = {}
for fe in system.files:
if self.writable(fe):
at_path.setdefault(self._path(fe), []).append(fe)
if at_path:
ours[system.native_id] = (system, {
self._path(fe): (fe, self._bios_element(fe, system.native_id))
for fe in writable
path: (group[0], self._bios_element(group[0], system.native_id, tuple(group[1:])))
for path, group in at_path.items()
})
def element(attrs: dict[str, str]) -> str:
+15 -4
View File
@@ -215,6 +215,7 @@ class Exporter(BaseExporter):
ours: list[OrderedDict],
additions: list[OrderedDict] | None = None,
appended: set[tuple[str, str]] | None = None,
held_back: list[int] | None = None,
) -> list[OrderedDict]:
"""Correct the component's own list, never replace it.
@@ -301,13 +302,19 @@ class Exporter(BaseExporter):
for e in merged
for system in (_systems_of(e) or [""])
}
for entry in additions or []:
for index, entry in enumerate(additions or []):
key = (str(entry.get("filename", "")), str(entry.get("system", "")), _paths_key(entry))
if key[0] and key not in held:
held.add(key)
merged.append(entry)
elif held_back is not None:
held_back.append(index)
return merged
def lands(self, fe: NativeFile) -> bool:
"""An addition the list already held is not written a second time."""
return self.writable(fe) and id(fe) not in getattr(self, "_held_back", set())
def render(
self,
systems: dict[str, NativeSystem],
@@ -317,6 +324,7 @@ class Exporter(BaseExporter):
) -> dict[str, str]:
self._place_additions(systems)
self._appended: set[int] = set()
self._held_back: set[int] = set()
grouped = self._by_component(systems)
produced: dict[str, str] = {}
@@ -331,25 +339,28 @@ class Exporter(BaseExporter):
continue
entries = [self._entry(fe) for fe in files if fe.platform is not None]
additions = [self._entry(fe) for fe in files if fe.platform is None]
added = [fe for fe in files if fe.platform is None]
additions = [self._entry(fe) for fe in added]
appended: set[tuple[str, str]] = set()
held_back: list[int] = []
for component_value in manifest.values():
if not isinstance(component_value, dict):
continue
holder = self._bios_holder(component_value)
if holder is None:
component_value["bios"] = self._merge(
None, entries, additions, appended
None, entries, additions, appended, held_back
)
else:
container, key = holder
written = container.get(key)
merged = self._merge(written, entries, additions, appended)
merged = self._merge(written, entries, additions, appended, held_back)
# A single object stays one while nothing is added to it.
container[key] = (
merged[0] if isinstance(written, dict) and len(merged) == 1 else merged
)
break
self._held_back.update(id(added[index]) for index in held_back)
# A declared entry whose revision the platform does not hold is
# written beside theirs: a new line, not a corrected value.
self._appended.update(
+20 -9
View File
@@ -336,6 +336,8 @@ class Exporter(BaseExporter):
systems_of.setdefault(str(name).lower(), set()).add(fe.native_system)
known = set(systems_of)
self._skipped: dict[str, list[str]] = {}
# Rewritten, with a file of a system no clause lists left out.
self._partial: list[str] = []
self._named: set[int] = set()
produced: dict[str, str] = {}
@@ -400,12 +402,11 @@ class Exporter(BaseExporter):
homes.setdefault(owners[0], []).append(fe)
else:
homeless = True
if homeless:
# No enumeration of that system to extend, and a sentence we
# would have to write ourselves is a documentation change,
# not a correction.
skip("whose help text lists no files of that system to add to", module_id)
# No enumeration of that system to extend, and a sentence we
# would have to write ourselves is a documentation change, not a
# correction. With nothing placed the package is left as is.
if not homes:
skip("whose help text lists no files of that system to add to", module_id)
continue
added = sum(len(self._in_search_order(fes)) for fes in homes.values())
@@ -428,6 +429,8 @@ class Exporter(BaseExporter):
f'rp_module_help="{new_help}"',
1,
)
if homeless:
self._partial.append(module_id)
return produced
@@ -454,12 +457,20 @@ class Exporter(BaseExporter):
) -> str:
"""Packages are the unit here, not file entries."""
skipped: dict[str, list[str]] = getattr(self, "_skipped", {})
def shown(modules: list[str]) -> str:
names = ", ".join(sorted(modules)[:4])
return names + (f" and {len(modules) - 4} more" if len(modules) > 4 else "")
parts = [f"{len(produced)} packages updated"]
partial = getattr(self, "_partial", [])
if partial:
parts.append(
f"Updated in part: {shown(partial)}, whose help text lists no files "
"of one of their systems to add to"
)
for reason, modules in sorted(skipped.items()):
shown = ", ".join(sorted(modules)[:4])
if len(modules) > 4:
shown += f" and {len(modules) - 4} more"
parts.append(f"Left as is: {shown}, {reason}")
parts.append(f"Left as is: {shown(modules)}, {reason}")
return ". ".join(parts)
def validate(
+13 -2
View File
@@ -175,6 +175,9 @@ class Exporter(BaseExporter):
ours: dict[str, dict[str, str]] = {}
added: dict[str, set[str]] = {}
# A rom name is one line: an addition whose name an earlier entry
# took is not written, and was counted added.
self._shadowed: set[int] = set()
for system, files in self.exportable(systems):
rendered: dict[str, str] = {}
for fe in files:
@@ -194,7 +197,11 @@ class Exporter(BaseExporter):
sha1 = fe.record_hash("sha1")
if sha1:
parts.append(f"sha1 {sha1}")
rendered.setdefault(name, f"\trom ( {' '.join(parts)} )")
if name in rendered:
if fe.platform is None:
self._shadowed.add(id(fe))
continue
rendered[name] = f"\trom ( {' '.join(parts)} )"
if fe.platform is None:
added.setdefault(system.native_id, set()).add(name)
if rendered:
@@ -241,7 +248,11 @@ class Exporter(BaseExporter):
return {self.native_filename(): "\n".join(lines)}
def lands(self, fe: NativeFile) -> bool:
return self.writable(fe) and id(fe) not in getattr(self, "_untitled", set())
return (
self.writable(fe)
and id(fe) not in getattr(self, "_untitled", set())
and id(fe) not in getattr(self, "_shadowed", set())
)
def validate(
self,
+3
View File
@@ -247,6 +247,9 @@ def main() -> int:
help="profiles the rules are checked against (default emulators)")
parser.add_argument("--json", action="store_true", help="print the summary as JSON")
args = parser.parse_args()
from common import work_from_repo_root # noqa: PLC0415
work_from_repo_root(args, parser, ["regions", "extras", "headers", "emulators_dir"])
regions = load_yaml(args.regions)
extras = load_yaml(args.extras)
+17 -7
View File
@@ -43,6 +43,7 @@ from common import (
require_directory,
target_listing_line,
suggest_names,
system_index,
require_platform,
load_target_config,
archive_entry,
@@ -164,7 +165,9 @@ class PackRecord:
self.sources: dict[str, str] = {}
def _add_pack_member(zf: zipfile.ZipFile, source_path: str, arcname: str) -> None:
def _add_pack_member(
zf: zipfile.ZipFile | PackRecord, source_path: str, arcname: str
) -> None:
"""Add a file to the pack with metadata that does not depend on the build.
ZipFile.write copies the source file's mtime into the member. For a file
@@ -197,7 +200,9 @@ def _add_pack_member(zf: zipfile.ZipFile, source_path: str, arcname: str) -> Non
shutil.copyfileobj(source, target, 1024 * 1024)
def _write_generated_member(zf: zipfile.ZipFile, arcname: str, text: str) -> None:
def _write_generated_member(
zf: zipfile.ZipFile | PackRecord, arcname: str, text: str
) -> None:
"""Write a pack member the builder composes rather than copies.
READMEs, instruction notes and the manifest are produced at build time,
@@ -581,7 +586,7 @@ def _data_directory_members(
def _pack_data_directories(
zf: zipfile.ZipFile,
zf: zipfile.ZipFile | PackRecord,
pack_systems: dict,
data_registry: dict | None,
platform_name: str,
@@ -1476,7 +1481,9 @@ def generate_pack(
def _extract_zip_to_archive(
source_zip: str, dest_prefix: str, target_zf: zipfile.ZipFile
source_zip: str,
dest_prefix: str,
target_zf: zipfile.ZipFile | PackRecord,
) -> None:
"""Extract contents of a source ZIP into target ZIP under dest_prefix."""
with zipfile.ZipFile(source_zip, "r") as src:
@@ -1507,7 +1514,7 @@ def _extract_zip_to_archive(
def _add_zip_to_pack(
source_zip: str,
dest_path: str,
target_zf: zipfile.ZipFile,
target_zf: zipfile.ZipFile | PackRecord,
file_entry: dict | None = None,
) -> None:
"""Write a source ZIP into the pack, byte for byte.
@@ -1555,7 +1562,7 @@ class DamagedArchiveError(OSError):
def _pack_data_tree(
zf: zipfile.ZipFile,
zf: zipfile.ZipFile | PackRecord,
local_cache: str,
dd_dest: str,
seen_destinations: set[str],
@@ -2053,7 +2060,10 @@ def generate_system_pack(
f"No emulators found for system(s): {', '.join(system_ids)}",
file=sys.stderr,
)
print(suggest_names(system_ids, sorted(all_systems), "--list-systems"),
# --list-systems merges spellings that differ in case or separator:
# the hint counted 579 spellings for the 532 lines it prints.
print(suggest_names(system_ids, sorted(all_systems), "--list-systems",
listed=len(system_index(profiles))),
file=sys.stderr)
return None
+7 -2
View File
@@ -14,7 +14,6 @@ Usage:
from __future__ import annotations
from siterender import SECTION_LABELS, WIKI_SRC_DIR
import argparse
import csv
import hashlib
@@ -56,6 +55,7 @@ from common import (
load_platform_config,
load_provenance_snapshots,
native_layer,
require_directory,
require_yaml,
resolution_is_hash_exact,
resolve_local_file,
@@ -64,6 +64,7 @@ from common import (
unique_emulator_profiles,
yaml_load,
)
from siterender import SECTION_LABELS, WIKI_SRC_DIR
from artifacts import ArtifactLockBusy, artifact_lock
from artifacts import json_text as _json_text
from cross_reference import FileConsumers, entry_target, held_copy
@@ -2330,7 +2331,7 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
f"({REPO_URL}/blob/main/scripts/check_platform_folder.py). A test runs it "
"against the verifier on a sample folder, so the two give the same answer. "
"It exits 1 when a file is wrong or missing. For an unknown platform it "
"prints the known ones.",
"prints the known ones, and it names a folder that does not exist.",
"",
"`install.py --check` answers another question by default: whether the folder "
"holds what the pack installs, the manifest above. With `--declared-only`, or "
@@ -12393,6 +12394,10 @@ def main() -> None:
args = parser.parse_args()
if args.docs_dir is None:
args.docs_dir = str(Path(args.site_dir) / STARLIGHT_CONTENT)
# Before any write: the public assets and the bootstraps went out first,
# and a mistyped folder stopped the run only after them.
require_directory(args.emulators_dir, "--emulators-dir")
require_directory(args.platforms_dir, "--platforms-dir")
try:
# Two runs over one docs/ delete each other's pages and scratch
# files in the sweep, so the second is refused instead.
+3
View File
@@ -146,6 +146,9 @@ def main() -> int:
"--strict", action="store_true", help="exit non-zero when any is found"
)
args = parser.parse_args()
from common import work_from_repo_root # noqa: PLC0415
work_from_repo_root(args, parser, ["bios_dir"])
from common import require_directory # noqa: PLC0415
require_directory(args.bios_dir)
+4 -1
View File
@@ -26,7 +26,10 @@ def list_platforms() -> list[str]:
def main() -> None:
parser = argparse.ArgumentParser(allow_abbrev=False, description="List available platforms")
parser.parse_args()
args = parser.parse_args()
from common import work_from_repo_root # noqa: PLC0415
work_from_repo_root(args, parser, [])
platforms = list_platforms()
+123 -54
View File
@@ -20,6 +20,7 @@ import re
import subprocess
import sys
import tempfile
import time
from collections import Counter
from collections.abc import Callable, Iterable, Iterator, Sequence
from dataclasses import asdict, dataclass, field, replace
@@ -80,6 +81,9 @@ from upstream import CompareResult, find_renamed
DEFAULT_CACHE = upstream.CACHE_DIR
ANON_QUOTA = 60
TRIAGE_PATH_SAMPLE = 5
# A pass that asks the forges says where it is at this interval: an online
# --all --triage ran 15 minutes without a word and read as hung.
PROGRESS_SECONDS = 30
STATUS_ORDER = (
"ANCHORED", "UNCHECKED", "EXTERNAL", "BINARY", "SHIFTED", "RENAMED", "MOVED",
@@ -4991,35 +4995,40 @@ def _print_extras_from(
print(f" {line}")
class _Progress:
"""A stderr line every PROGRESS_SECONDS while a pass asks the forges."""
def __init__(self, total: int, doing: str, quiet: bool) -> None:
self.total, self.doing, self.quiet = total, doing, quiet
self.last = time.monotonic()
def step(self, done: int, name: str) -> None:
now = time.monotonic()
if not self.quiet and now - self.last >= PROGRESS_SECONDS:
print(f"{self.doing}: {done} of {self.total}, at {name}", file=sys.stderr, flush=True)
self.last = now
def _interrupted(done: int, total: int, what: str) -> None:
print(f"interrupted after {done} of {total} profiles: {what}", file=sys.stderr, flush=True)
def _print_triage(
args: argparse.Namespace, selected: dict, reports: list[ProfileReport]
) -> None:
"""Profiles ranked by drift, worst first."""
) -> bool:
"""Profiles ranked by drift, worst first. False when Ctrl-C cut the
scoring short: the ranking then covers the profiles scored."""
ranked = []
sampled = 0
for report in reports:
profile = selected[report.name]
version = None
commits = 0
refused = ""
repo = select_repo(profile)
if report.head and repo is not None:
paths = _cited_paths(report)
try:
version = check_version(profile, repo, args.cache_dir, args.offline)
for path in paths[:TRIAGE_PATH_SAMPLE]:
commits += upstream.commits_touching(
repo, path, report.pin, args.cache_dir, args.offline,
head=report.head,
)
except upstream.UpstreamError as exc:
# One forge refusing a request (Eden's answers 403 to commit
# lookups) leaves this profile unscored, not the whole ranking.
refused = "commit history unreachable"
print(f"{report.name}: {exc}", file=sys.stderr)
if len(paths) > TRIAGE_PATH_SAMPLE and not refused:
sampled += 1
ranked.append((drift_score(report, version, commits), report, refused))
progress = _Progress(len(reports), "scored", args.offline or len(reports) < 2)
complete = True
try:
for done, report in enumerate(reports):
progress.step(done, report.name)
sampled += _score(args, selected, report, ranked)
except KeyboardInterrupt:
_interrupted(len(ranked), len(reports), "the ranking covers those scored")
complete = False
if sampled:
# One line for the run: a notice per profile buried the ranking
# under 254 lines of stderr.
@@ -5037,6 +5046,37 @@ def _print_triage(
pending += bool(report.needs_review() or report.uncheckable())
print(f"{score:5d} {report.name:30s} {state}")
print(f"{len(ranked)} profiles, {pending} with something to review")
return complete
def _score(
args: argparse.Namespace, selected: dict, report: ProfileReport, ranked: list
) -> bool:
"""Append one profile's drift score to ``ranked``. True when its commit
count was read on a sample of the paths it cites."""
profile = selected[report.name]
version = None
commits = 0
refused = ""
sampled = False
repo = select_repo(profile)
if report.head and repo is not None:
paths = _cited_paths(report)
try:
version = check_version(profile, repo, args.cache_dir, args.offline)
for path in paths[:TRIAGE_PATH_SAMPLE]:
commits += upstream.commits_touching(
repo, path, report.pin, args.cache_dir, args.offline,
head=report.head,
)
except upstream.UpstreamError as exc:
# One forge refusing a request (Eden's answers 403 to commit
# lookups) leaves this profile unscored, not the whole ranking.
refused = "commit history unreachable"
print(f"{report.name}: {exc}", file=sys.stderr)
sampled = len(paths) > TRIAGE_PATH_SAMPLE and not refused
ranked.append((drift_score(report, version, commits), report, refused))
return sampled
def _refuse_flag_combinations(args: argparse.Namespace) -> None:
@@ -5260,30 +5300,24 @@ def main() -> None:
return
reports = []
for name, profile in selected.items():
profile_path = Path(args.emulators_dir) / f"{name}.yml"
try:
report = _build_full_report(args, name, profile, profile_path, failed)
except upstream.RateLimitError as exc:
# A quota is one forge's: git.libretro.com answering 429 said
# nothing about the 600 profiles on GitHub. upstream stops asking
# that host, so the rest of the pass costs it nothing.
print(f"{name}: {exc}", file=sys.stderr)
report = ProfileReport(
name=name, entries=[], counts={},
skipped=f"rate limited: {exc.host or exc}",
)
failed.add(name)
reports.append(report)
continue
reports.append(report)
if writes:
try:
_apply_writes(args, name, profile, report)
except YamlWriteError as exc:
# The guard already left the file untouched, so keep going.
print(f"{name}: write refused: {exc}", file=sys.stderr)
failed.add(name)
quiet = args.offline or len(selected) < 2
if not quiet:
print(
f"reading {len(selected)} profiles against their forges, a line every "
f"{PROGRESS_SECONDS} s. --offline answers from the cache alone.",
file=sys.stderr, flush=True,
)
progress = _Progress(len(selected), "read", quiet)
complete = True
try:
for name, profile in selected.items():
progress.step(len(reports), name)
_read_one(args, name, profile, reports, failed, writes)
except KeyboardInterrupt:
# What was read is still worth printing: a ranking of 200 profiles
# beats none after 15 minutes.
_interrupted(len(reports), len(selected), "the output covers those read")
complete = False
# --changed-only narrows every output, not the text report alone: the
# documented `--all --changed-only --json` listed every profile.
@@ -5291,14 +5325,49 @@ def main() -> None:
report for report in reports
if not args.changed_only or report.needs_review() or report.name in failed
]
_emit(args, selected, reports, shown)
complete = _emit(args, selected, reports, shown) is not False and complete
_refresh_source_paths(args)
if not complete:
raise SystemExit(130)
if failed or (args.strict and any(
report.needs_review() or report.uncheckable() for report in reports
)):
raise SystemExit(1)
def _read_one(
args: argparse.Namespace,
name: str,
profile: dict,
reports: list[ProfileReport],
failed: set[str],
writes: bool,
) -> None:
"""Build one profile's report into ``reports``, then apply the writes."""
profile_path = Path(args.emulators_dir) / f"{name}.yml"
try:
report = _build_full_report(args, name, profile, profile_path, failed)
except upstream.RateLimitError as exc:
# A quota is one forge's: git.libretro.com answering 429 said
# nothing about the 600 profiles on GitHub. upstream stops asking
# that host, so the rest of the pass costs it nothing.
print(f"{name}: {exc}", file=sys.stderr)
reports.append(ProfileReport(
name=name, entries=[], counts={},
skipped=f"rate limited: {exc.host or exc}",
))
failed.add(name)
return
reports.append(report)
if writes:
try:
_apply_writes(args, name, profile, report)
except YamlWriteError as exc:
# The guard already left the file untouched, so keep going.
print(f"{name}: write refused: {exc}", file=sys.stderr)
failed.add(name)
# Profiles this run wrote. Their new pins name trees the site's record of
# cited source paths does not hold yet.
_WRITTEN: set[str] = set()
@@ -5400,8 +5469,9 @@ def _emit(
selected: dict,
reports: list[ProfileReport],
shown: list[ProfileReport],
) -> None:
"""Write the reports in the form the flags ask for."""
) -> bool | None:
"""Write the reports in the form the flags ask for. False when the
triage was cut short."""
if args.as_json:
print(json.dumps([report_to_dict(r) for r in shown], indent=2))
return
@@ -5417,8 +5487,7 @@ def _emit(
print(url)
return
if args.triage:
_print_triage(args, selected, shown)
return
return _print_triage(args, selected, shown)
for report in shown:
print(format_report(report, args.changed_only))
_print_extras(args, selected[report.name], report)
+3
View File
@@ -384,6 +384,9 @@ def main() -> int:
"(the pipeline writes derivable.json)",
)
args = parser.parse_args()
from common import work_from_repo_root # noqa: PLC0415
work_from_repo_root(args, parser, ["db", "provenance_dir", "emulators_dir", "write_derivable"])
if args.write_derivable and not args.derive:
parser.error("--write-derivable records what --derive finds: pass both")
+19 -9
View File
@@ -148,18 +148,28 @@ def _run_check_freshness(areas: tuple[str, ...], extra: list[str]) -> list[dict]
if areas and set(areas) != set(AUTO_AREAS + MANUAL_AREAS):
cmd += ["--only", ",".join(areas)]
cmd += extra
proc = subprocess.run(
cmd, cwd=REPO_ROOT, capture_output=True, text=True, check=False, timeout=1800
)
if proc.returncode not in (0, 1):
try:
proc = subprocess.run(
cmd, cwd=REPO_ROOT, capture_output=True, text=True, check=False, timeout=1800
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"check_freshness.py did not finish in {exc.timeout:.0f} s: run "
f"`{' '.join(cmd[1:])} > report.json` alone, then pass --findings report.json"
) from exc
# Exit 1 is the STALE rows, and also a crash: only a JSON list on stdout
# tells them apart, and the crash's own message is on stderr.
try:
findings = json.loads(proc.stdout) if proc.returncode in (0, 1) else None
except json.JSONDecodeError:
findings = None
if not isinstance(findings, list):
tail = (proc.stderr or proc.stdout).strip().splitlines()[-3:]
raise RuntimeError(
"check_freshness.py failed:\n" + "\n".join(tail or ["(no output)"])
f"check_freshness.py failed (exit {proc.returncode}):\n"
+ "\n".join(tail or ["(no output)"])
)
try:
return json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"check_freshness.py returned unreadable JSON: {exc}") from exc
return findings
def load_findings(path: Path, areas: tuple[str, ...]) -> list[dict]:
+3
View File
@@ -201,6 +201,9 @@ def main() -> int:
)
parser.add_argument("--json", action="store_true", help="Machine-readable output")
args = parser.parse_args()
from common import work_from_repo_root # noqa: PLC0415
work_from_repo_root(args, parser, ["db", "emulators_dir"])
db = load_database(args.db)
profiles = load_emulator_profiles(args.emulators_dir)
+8
View File
@@ -36,6 +36,14 @@ import urllib.request
from collections.abc import Callable, Iterable
from pathlib import Path
# The probe tools do not import common.py, which states the 3.9 floor for the
# other scripts. Under 3.8 they stopped on a str.removesuffix AttributeError.
if sys.version_info < (3, 9):
sys.exit(
f"{Path(sys.argv[0]).name} needs Python 3.9 or newer, "
f"this is {sys.version.split()[0]}"
)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import yaml # noqa: E402
+2 -2
View File
@@ -1122,7 +1122,7 @@ def main() -> int:
which.add_argument("--all", action="store_true", help="every profile with an arcade source")
parser.add_argument("--emulators-dir", default="emulators",
help="profiles whose BIOS sets are read (default emulators)")
parser.add_argument("--database", default="database.json",
parser.add_argument("--db", default="database.json",
help="collection index, to name BIOS sets the collection lacks")
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT,
help=f"where the parent maps are written (default {DEFAULT_OUTPUT})")
@@ -1133,7 +1133,7 @@ def main() -> int:
args = parser.parse_args()
profiles = load_emulator_profiles(args.emulators_dir, skip_aliases=False)
names = collection_names(Path(args.database))
names = collection_names(Path(args.db))
failed = False
for profile in sorted(PROFILES) if args.all else args.profile:
if profile not in profiles:
+8 -2
View File
@@ -14,6 +14,9 @@ games/catalogs/ and never in provenance/.
No-Intro comes from the auto-datfile-generator release asset (Dat-o-Matic
bans automated clients), Redump from redump.info's per-system datfile route.
The mirror is dantob's fork: hugo19941994's original has failed its daily
rebuild since Dat-o-Matic's 2026-07 re-layout (no-intro.zip last rebuilt
2026-07-08), and the fork carries the fix (d4a9d769, 2026-07-31).
Every download is cached under .cache/game-dats/ with what the server said
about it, so a second run reads nothing from the network and writes the same
bytes. A DAT's region tag is a market label, never the value a core computes.
@@ -49,8 +52,11 @@ SOURCES = ("no-intro", "redump")
CACHE_DIR = Path(".cache") / "game-dats"
OUTPUT_DIR = Path("games") / "catalogs"
NOINTRO_MIRROR = "https://github.com/dantob/auto-datfile-generator"
NOINTRO_RELEASE = "Daily_Rebuild"
NOINTRO_RELEASE_API = (
"https://api.github.com/repos/hugo19941994/auto-datfile-generator/releases/tags/Daily_Rebuild"
NOINTRO_MIRROR.replace("https://github.com/", "https://api.github.com/repos/")
+ f"/releases/tags/{NOINTRO_RELEASE}"
)
NOINTRO_ASSET = "no-intro.zip"
REDUMP_DATFILE_URL = "https://redump.info/datfile/{code}"
@@ -120,7 +126,7 @@ LICENCES: dict[str, dict] = {
"website or its operators is required for such use."
),
"mirror": {
"repo": "https://github.com/hugo19941994/auto-datfile-generator",
"repo": NOINTRO_MIRROR,
"licence": "MIT",
"scope": "the mirror's scripts only, the DATs it republishes stay under the terms above",
},
@@ -16,6 +16,7 @@ import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
import yaml
@@ -32,6 +33,7 @@ STEAMOS_API = (
WINDOWS_API = (
"https://api.github.com/repos/EmuDeck/emudeck-we/contents/functions/EmuScripts"
)
RETROARCH_TARGETS = Path(__file__).resolve().parents[3] / "platforms" / "targets" / "retroarch.yml"
def _fetch(url: str) -> str:
headers = {"User-Agent": "retrobios-scraper/1.0"}
@@ -75,7 +77,7 @@ class Scraper(BaseTargetScraper):
return self._revision
def _fetch_cores_for_target(
self, api_url: str, label: str, arch: str = "x86_64"
self, api_url: str, label: str, retroarch_target: str
) -> list[str]:
print(f" fetching {label} EmuScripts...", file=sys.stderr)
scripts = _list_emuscripts(api_url)
@@ -94,7 +96,7 @@ class Scraper(BaseTargetScraper):
standalone_count = len(cores)
# EmuDeck ships RetroArch = all its libretro cores are available
if has_retroarch:
ra_cores = self._load_retroarch_cores(arch)
ra_cores = self._load_retroarch_cores(retroarch_target)
for c in ra_cores:
if c not in seen:
seen.add(c)
@@ -108,24 +110,21 @@ class Scraper(BaseTargetScraper):
return sorted(cores)
@staticmethod
def _load_retroarch_cores(arch: str) -> list[str]:
"""Load RetroArch target cores for given architecture."""
import os
target_path = os.path.join("platforms", "targets", "retroarch.yml")
with open(target_path) as f:
def _load_retroarch_cores(target: str, path: Path = RETROARCH_TARGETS) -> list[str]:
"""The buildbot's libretro cores for one RetroArch target, by name:
linux-x86_64 and windows-x86_64 share an architecture, not a list."""
with path.open(encoding="utf-8") as f:
data = yaml_load(f) or {}
# Find a target matching the architecture
for tinfo in data.get("targets", {}).values():
if tinfo.get("architecture") == arch and tinfo.get("cores"):
return tinfo["cores"]
raise RuntimeError(f"no {arch} core list in {target_path}")
cores = ((data.get("targets") or {}).get(target) or {}).get("cores")
if not cores:
raise RuntimeError(f"no {target} core list in {path}")
return cores
def fetch_targets(self) -> dict:
steamos_cores = self._fetch_cores_for_target(
f"{STEAMOS_API}?ref={self.revision}", "SteamOS"
f"{STEAMOS_API}?ref={self.revision}", "SteamOS", "linux-x86_64"
)
windows_cores = self._fetch_cores_for_target(WINDOWS_API, "Windows")
windows_cores = self._fetch_cores_for_target(WINDOWS_API, "Windows", "windows-x86_64")
targets: dict[str, dict] = {}
if steamos_cores:
+3
View File
@@ -1009,6 +1009,9 @@ def main() -> int:
"without it the run reports them and exits 0",
)
args = parser.parse_args()
from common import work_from_repo_root # noqa: PLC0415
work_from_repo_root(args, parser, ["db", "platforms_dir", "emulators_dir"])
import contextlib
+41 -38
View File
@@ -41,8 +41,10 @@ from common import (
_foreign_tree,
_owner_tokens,
platform_arg,
work_from_repo_root,
target_listing_line,
suggest_names,
system_index,
require_platform,
archive_entry,
archive_is_optional,
@@ -129,29 +131,6 @@ DEFAULT_EMULATORS_DIR = "emulators"
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def work_from_repo_root(
args: argparse.Namespace, parser: argparse.ArgumentParser, path_flags: list[str]
) -> None:
"""Run from the repository root, whichever directory the command started in.
database.json records every file from the root and the data registry
names its caches the same way, so a run from scripts/ ended on a
FileNotFoundError traceback. A path the caller typed is read from where
they typed it, a default from the root.
"""
if os.path.realpath(os.getcwd()) == os.path.realpath(REPO_ROOT):
return
for flag in path_flags:
value = getattr(args, flag, None)
if value and value != parser.get_default(flag):
setattr(args, flag, os.path.abspath(value))
os.chdir(REPO_ROOT)
if getattr(args, "platform", None):
# Read before the move, the registry was not there to map
# "RetroArch" to its slug.
args.platform = platform_arg(args.platform)
# Status model: aligned with Batocera BiosStatus (batocera-systems:967-969)
@@ -1808,7 +1787,8 @@ def _print_undeclared_entry(
ref_part = f" [{u['source_ref']}]" if u.get("source_ref") else ""
print(f" validates {checks_label}{ref_part}")
else:
print(f" [{'+'.join(checks)}]")
# A bare "[size]" under the entry said nothing to a reader.
print(f" validates {'+'.join(checks)}")
def _split_undeclared(undeclared: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
@@ -1920,10 +1900,15 @@ def _print_undeclared_section(result: dict, verbose: bool) -> None:
u for u in bundled
if u["required"] and u.get("category", "bios") != "game_data"
]
names = ", ".join(sorted({u["name"] for u in shown}))
named = sorted({u["name"] for u in shown})
# The required firmware alone read as the whole count: 14 names
# for 154 entries, nothing saying the rest existed.
hidden = len({u["name"] for u in bundled}) - len(named)
more = f"{hidden} more, " if named else ""
items = named + ([f"{more}--verbose lists them"] if hidden else [])
print(
f" Bundled with their emulator, not collected: {len(bundled)}"
+ (f" ({names})" if names else "")
+ (f" ({', '.join(items)})" if items else "")
)
if unplaced:
print(f" Core files held but not packed: {len(unplaced)} "
@@ -2611,7 +2596,10 @@ def verify_system(
f"No emulators found for system(s): {', '.join(system_ids)}",
file=sys.stderr,
)
print(suggest_names(system_ids, sorted(all_systems), "--list-systems"),
# --list-systems merges spellings that differ in case or separator:
# the hint counted 579 spellings for the 532 lines it prints.
print(suggest_names(system_ids, sorted(all_systems), "--list-systems",
listed=len(system_index(profiles))),
file=sys.stderr)
sys.exit(1)
@@ -2821,7 +2809,7 @@ def _main(held: contextlib.ExitStack) -> None:
parser = argparse.ArgumentParser(allow_abbrev=False, description="Platform-native BIOS verification")
parser.add_argument("--platform", "-p", type=platform_arg, help="Platform name")
parser.add_argument(
"--all", action="store_true", help="Verify all active platforms"
"--all", action="store_true", help="Verify every registered platform"
)
parser.add_argument(
"--emulator", "-e", help="Emulator profile name(s), comma-separated"
@@ -3121,6 +3109,13 @@ def _overall_summary(group_results: list[tuple[dict, list[str]]]) -> str:
named = [f"{name} {count}" for name, count in counts.items() if count]
return f" ({', '.join(named)})" if named else ""
def on(groups: tuple[str, ...]) -> str:
"""The platforms a file is missing on, by the shorter side."""
if len(group_results) < 2 or len(groups) * 2 <= len(group_results):
return ", ".join(groups)
others = [" / ".join(g) for _, g in group_results if " / ".join(g) not in groups]
return "every platform" + (f" but {', '.join(others)}" if others else "")
parts = []
for severity, label in ((Severity.CRITICAL, "critical"), (Severity.WARNING, "warning"),
(Severity.INFO, "info")):
@@ -3139,18 +3134,26 @@ def _overall_summary(group_results: list[tuple[dict, list[str]]]) -> str:
parts.append(f"{total} {'discrepancy' if total == 1 else 'discrepancies'}{where(counts)}")
lines = [f"{platforms} platforms: {', '.join(parts)}"]
missing = {
" / ".join(group): sorted({
# Grouped by file, not by platform: sfiii2a.zip missing everywhere was
# counted and printed once per platform, 24 files for 10 distinct ones.
holders: dict[str, list[str]] = {}
for r, group in group_results:
for name in sorted({
u["name"] for u in _core_required_missing(_split_undeclared(
r.get("undeclared_files", []))[1])
})
for r, group in group_results
}
total = sum(len(names) for names in missing.values())
if total:
detail = "; ".join(f"{group}: {', '.join(names)}"
for group, names in missing.items() if names)
lines.append(f"{total} core file(s) a core cannot run without are missing ({detail})")
}):
holders.setdefault(name, []).append(" / ".join(group))
if holders:
by_platforms: dict[tuple[str, ...], list[str]] = {}
for name, groups in sorted(holders.items()):
by_platforms.setdefault(tuple(groups), []).append(name)
detail = "; ".join(
f"{', '.join(names)} ({on(groups)})"
for groups, names in sorted(by_platforms.items(),
key=lambda item: (-len(item[0]), item[1]))
)
lines.append(f"{len(holders)} core file(s) a core cannot run without are "
f"missing: {detail}")
for category, label in _NOT_FIRMWARE.items():
counts = {
" / ".join(group): len((r.get("missing_not_firmware") or {}).get(category, []))