mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
The games importers wrote through fixed .part names, the new options had no help text, and romfolders stopped on the first setup the offline cache lacked. RetroArch's rom folder map is regenerated from its source spec (path null, as the schema says). The installer's Python 3.8 job imports the games tests without pyyaml, so the region rules load lazily.
274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""Load and check games/regions.yml and games/extras.yml against the profiles.
|
|
|
|
python scripts/game_rules.py # check, print a summary
|
|
python scripts/game_rules.py --json # the summary as JSON
|
|
|
|
Both files name profiles and the files those profiles declare. A rule is
|
|
only as good as that link: a profile renamed, a file dropped from it, a
|
|
region slug the vocabulary does not know, a citation at a revision the
|
|
profile is not pinned to, or a header trigger asking a reader of
|
|
games/headers.yml for a value it never returns, and the installer would act
|
|
on a rule nothing backs. Each of those is an error here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from common import load_emulator_profiles
|
|
from region import REGIONS, normalize_declared
|
|
from safeparse import yaml_load
|
|
|
|
REGIONS_FILE = Path("games") / "regions.yml"
|
|
EXTRAS_FILE = Path("games") / "extras.yml"
|
|
HEADERS_FILE = Path("games") / "headers.yml"
|
|
|
|
_REF_REPO = re.compile(r"(?:^|; )([\w.-]+/[\w.-]+)@([0-9a-f]{8,40}) ")
|
|
|
|
|
|
def load_yaml(path: Path) -> dict:
|
|
with Path(path).open(encoding="utf-8") as handle:
|
|
return yaml_load(handle) or {}
|
|
|
|
|
|
def profile_pins(profile: dict) -> set[str]:
|
|
"""Every commit a profile is pinned to, a build-mode dict included."""
|
|
pins: set[str] = set()
|
|
for key in ("source_commit", "upstream_commit"):
|
|
value = profile.get(key)
|
|
values = value.values() if isinstance(value, dict) else [value]
|
|
pins.update(str(v) for v in values if v)
|
|
return pins
|
|
|
|
|
|
def declared_names(profile: dict) -> set[str]:
|
|
"""The names and aliases a profile's files answer to."""
|
|
names: set[str] = set()
|
|
for entry in profile.get("files") or []:
|
|
if isinstance(entry, dict) and entry.get("name"):
|
|
names.add(str(entry["name"]))
|
|
names.update(str(a) for a in entry.get("aliases") or [])
|
|
return names
|
|
|
|
|
|
def tagged_regions(profile: dict, name: str) -> set[str]:
|
|
"""The regions a profile tags a file with, over all its declarations."""
|
|
out: set[str] = set()
|
|
for entry in profile.get("files") or []:
|
|
if isinstance(entry, dict) and (
|
|
entry.get("name") == name or name in (entry.get("aliases") or [])
|
|
):
|
|
out |= normalize_declared(entry.get("region"))
|
|
return out
|
|
|
|
|
|
def every_tag(profile: dict) -> set[str]:
|
|
return {
|
|
region
|
|
for entry in profile.get("files") or []
|
|
if isinstance(entry, dict)
|
|
for region in normalize_declared(entry.get("region"))
|
|
}
|
|
|
|
|
|
def header_readers(headers: dict) -> dict[str, dict]:
|
|
"""Every reader of games/headers.yml by id, over all its systems."""
|
|
readers: dict[str, dict] = {}
|
|
for system in (headers.get("systems") or {}).values():
|
|
readers.update(system.get("readers") or {})
|
|
return readers
|
|
|
|
|
|
def reader_answers(reader: dict, field: str | None = None) -> set[object] | None:
|
|
"""The labels and values a reader can return, None when any string can
|
|
be (a serial or product id read as it stands)."""
|
|
if field:
|
|
spec = (reader.get("fields") or {}).get(field) or {}
|
|
return {v for k, v in spec.items() if k not in ("encoding",) and isinstance(v, str)}
|
|
decode = reader.get("decode") or {}
|
|
if decode.get("kind") == "serial":
|
|
return None
|
|
answers: set[object] = set()
|
|
for row in decode.get("values") or []:
|
|
answers.update(row[k] for k in ("value", "label") if k in row)
|
|
for key in ("then", "otherwise"):
|
|
branch = decode.get(key)
|
|
if isinstance(branch, dict) and "label" in branch:
|
|
answers.add(branch["label"])
|
|
elif isinstance(branch, str):
|
|
answers.add(branch)
|
|
return answers
|
|
|
|
|
|
@dataclass
|
|
class Checker:
|
|
profiles: dict[str, dict]
|
|
readers: dict[str, dict] | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
self.errors: list[str] = []
|
|
|
|
def _profile(self, where: str, name: str, system: str) -> dict | None:
|
|
profile = self.profiles.get(name)
|
|
if profile is None:
|
|
self.errors.append(f"{where}: no profile {name}")
|
|
return None
|
|
if system not in (profile.get("systems") or []):
|
|
self.errors.append(f"{where}: {name} does not declare system {system}")
|
|
return profile
|
|
|
|
def _files(self, where: str, name: str, profile: dict, files: list[str]) -> None:
|
|
known = declared_names(profile)
|
|
for file in files:
|
|
if file not in known:
|
|
self.errors.append(f"{where}: {name} declares no file {file}")
|
|
|
|
def _region(self, where: str, value: str) -> None:
|
|
if value not in REGIONS:
|
|
self.errors.append(f"{where}: unknown region {value}")
|
|
|
|
def _ref(self, where: str, profile: dict, ref: str | None) -> None:
|
|
if not ref:
|
|
self.errors.append(f"{where}: no source_ref")
|
|
return
|
|
pins = profile_pins(profile)
|
|
cited = _REF_REPO.findall(ref)
|
|
if not cited:
|
|
self.errors.append(f"{where}: source_ref names no owner/repo@sha")
|
|
for repo, sha in cited:
|
|
if not any(pin.startswith(sha) for pin in pins):
|
|
self.errors.append(f"{where}: {repo}@{sha} is not a pin of the profile")
|
|
|
|
def regions(self, doc: dict) -> None:
|
|
cases = doc.get("cases") or {}
|
|
for system, cores in (doc.get("systems") or {}).items():
|
|
for name, rule in cores.items():
|
|
where = f"regions {system}/{name}"
|
|
profile = self._profile(where, name, system)
|
|
if profile is None:
|
|
continue
|
|
self._ref(where, profile, rule.get("source_ref"))
|
|
tags = every_tag(profile)
|
|
for src, dst in (rule.get("routes") or {}).items():
|
|
self._region(where, src)
|
|
self._region(where, dst)
|
|
if dst not in tags:
|
|
self.errors.append(f"{where}: route {src} -> {dst}, but no file is tagged {dst}")
|
|
for value in [rule.get("default_region"), *(rule.get("preference") or [])]:
|
|
if value:
|
|
self._region(where, value)
|
|
free = rule.get("region_free") or []
|
|
self._files(where, name, profile, free)
|
|
for file in free:
|
|
specific = tagged_regions(profile, file) - {"world"}
|
|
if specific:
|
|
self.errors.append(
|
|
f"{where}: region_free {file} is tagged {sorted(specific)} in the profile"
|
|
)
|
|
for keep in rule.get("keep_all_regions") or []:
|
|
kwhere = f"{where} case {keep.get('case')}"
|
|
if keep.get("case") not in cases:
|
|
self.errors.append(f"{kwhere}: case not described under cases")
|
|
self._ref(kwhere, profile, keep.get("source_ref"))
|
|
self._files(kwhere, name, profile, keep.get("files") or [])
|
|
|
|
def extras(self, doc: dict) -> None:
|
|
for index, entry in enumerate(doc.get("extras") or []):
|
|
where = f"extras[{index}] {entry.get('system')}/{entry.get('profile')}"
|
|
profile = self._profile(where, entry["profile"], entry["system"])
|
|
if profile is None:
|
|
continue
|
|
self._ref(where, profile, entry.get("source_ref"))
|
|
self._files(where, entry["profile"], profile, entry.get("files") or [])
|
|
header = (entry.get("trigger") or {}).get("header")
|
|
if header and self.readers is not None:
|
|
self._header(where, header)
|
|
|
|
def _header(self, where: str, header: dict) -> None:
|
|
reader = self.readers.get(header["reader"])
|
|
if reader is None:
|
|
self.errors.append(f"{where}: games/headers.yml has no reader {header['reader']}")
|
|
return
|
|
field = header.get("field")
|
|
if field and field not in (reader.get("fields") or {}):
|
|
self.errors.append(f"{where}: reader {header['reader']} has no field {field}")
|
|
return
|
|
answers = reader_answers(reader, field)
|
|
if answers is None:
|
|
return
|
|
for value in header.get("values") or []:
|
|
if value not in answers:
|
|
self.errors.append(
|
|
f"{where}: reader {header['reader']} never returns {value!r}"
|
|
)
|
|
|
|
|
|
def check(
|
|
regions: dict, extras: dict, profiles: dict[str, dict], headers: dict | None = None
|
|
) -> list[str]:
|
|
"""Errors over both files. Without headers, header triggers go unchecked."""
|
|
checker = Checker(profiles, header_readers(headers) if headers is not None else None)
|
|
checker.regions(regions)
|
|
checker.extras(extras)
|
|
return checker.errors
|
|
|
|
|
|
def summary(regions: dict, extras: dict) -> dict:
|
|
keeps = [
|
|
{"case": keep["case"], "system": system, "profile": name, "condition": keep["condition"]}
|
|
for system, cores in sorted((regions.get("systems") or {}).items())
|
|
for name, rule in sorted(cores.items())
|
|
for keep in rule.get("keep_all_regions") or []
|
|
]
|
|
systems = regions.get("systems") or {}
|
|
return {
|
|
"region_systems": len(systems),
|
|
"region_cores": sum(len(c) for c in systems.values()),
|
|
"cases": sorted(regions.get("cases") or {}),
|
|
"case_entries": keeps,
|
|
"extras": len(extras.get("extras") or []),
|
|
"extra_systems": sorted({e["system"] for e in extras.get("extras") or []}),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.splitlines()[0])
|
|
parser.add_argument("--regions", type=Path, default=REGIONS_FILE,
|
|
help=f"region rules to check (default {REGIONS_FILE})")
|
|
parser.add_argument("--extras", type=Path, default=EXTRAS_FILE,
|
|
help=f"extra-file rules to check (default {EXTRAS_FILE})")
|
|
parser.add_argument("--headers", type=Path, default=HEADERS_FILE,
|
|
help=f"header readers the triggers name (default {HEADERS_FILE})")
|
|
parser.add_argument("--emulators-dir", default="emulators",
|
|
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()
|
|
|
|
regions = load_yaml(args.regions)
|
|
extras = load_yaml(args.extras)
|
|
errors = check(regions, extras, load_emulator_profiles(args.emulators_dir), load_yaml(args.headers))
|
|
info = summary(regions, extras)
|
|
if args.json:
|
|
print(json.dumps({**info, "errors": errors}, indent=2))
|
|
else:
|
|
print(
|
|
f"Region rules: {info['region_cores']} cores over {info['region_systems']} systems, "
|
|
f"{len(info['cases'])} cases in {len(info['case_entries'])} entries"
|
|
)
|
|
for row in info["case_entries"]:
|
|
print(f" {row['case']:26} {row['system']:20} {row['profile']:22} {row['condition']}")
|
|
print(f"Per-game extras: {info['extras']} entries over {len(info['extra_systems'])} systems")
|
|
for error in errors:
|
|
print(f"ERROR {error}")
|
|
return 1 if errors else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
from common import run_cli
|
|
|
|
run_cli(main)
|