Files
libretro/scripts/region_check.py
T
Abdessamad Derraz 356a101c53 fix: call a region claim ambiguous on every file
PCSX-ReARMed opens the three regional BIOS files whatever the disc, and the rule called that a confirmation because one of them held the region. A run that opened its own region's file and the others too now settles nothing.
2026-10-28 00:29:09 +01:00

401 lines
18 KiB
Python

#!/usr/bin/env python3
"""Region checks: which regional BIOS a core reads for a game, and whether
the game boots without it.
For a system whose profile marks its BIOS entries with `region:`, each test
game of a region (tests/campaign/content.yml) runs through core_probe four
times, each a record in measurements/<emulator>.jsonl:
full every regional BIOS the profile declares
own only the entries whose `region:` holds the game's region
others only the entries of the other regions
pack the files of `generate_pack.py --emulator E --region R`, at
the place the pack puts them
The outcome reads the records: the file the core opened in the full run
(from strace) settles the `region:` claim, the verdicts of `own` and
`others` say whether the BIOS is locked to the region, and `pack` says
whether the pack filtered for the region boots the region's game.
run the four runs for each region game of one core and system
report the outcome of every region record already in measurements/
A maintainer starts a run. Nothing here runs on a timer.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import tempfile
import time
import urllib.request
from collections.abc import Callable, Iterable
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import yaml # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
CONTENT_FILE = ROOT / "tests" / "campaign" / "content.yml"
CORES = ROOT / ".cache" / "region" / "cores"
NIGHTLY = "https://buildbot.libretro.com/nightly/linux/x86_64/latest/{core}_libretro.so.zip"
VARIANTS = ("full", "own", "others", "pack")
SUBJECT = re.compile(r"^region-(?P<core>[a-z0-9_]+)-(?P<game>[a-z0-9-]+)-(?P<variant>full|own|others|pack)$")
# The territory a test game names, as generate_pack --region takes it.
PACK_REGION = {"japan": "jp", "north-america": "us", "europe": "eu"}
REGION_WORDS = {"japan": "Japanese", "north-america": "US", "europe": "European"}
# --------------------------------------------------------------------------
# Pure rules
# --------------------------------------------------------------------------
def regions_of(entry: dict) -> list[str]:
regions = entry.get("region") or []
return [regions] if isinstance(regions, str) else list(regions)
def entry_dest(entry: dict) -> str:
path = entry.get("path")
if isinstance(path, str) and path and not path.endswith("/"):
return path
return entry["name"]
def region_entries(profile: dict, system: str) -> list[dict]:
"""The BIOS entries of a system that a `region:` ties to territories."""
out = []
for entry in profile.get("files") or ():
systems = entry.get("systems") or ([entry["system"]] if entry.get("system")
else profile.get("systems") or [])
if (system in systems and regions_of(entry) and "world" not in regions_of(entry)
and entry.get("category", "bios") == "bios" and entry.get("mode") != "standalone"):
out.append(entry)
return out
def plan_variants(entries: Iterable[dict], region: str,
local: Callable[[dict], Path | None]) -> dict[str, dict[str, Path]]:
"""The staged files of `full`, `own` and `others`, dest -> file. An entry
the collection does not hold is left out of every variant."""
held = [(entry, local(entry)) for entry in entries]
held = [(entry, path) for entry, path in held if path]
full = {entry_dest(e): p for e, p in held}
own = {entry_dest(e): p for e, p in held if region in regions_of(e)}
others = {entry_dest(e): p for e, p in held if region not in regions_of(e)}
return {"full": full, "own": own, "others": others}
def subject(core: str, game: str, variant: str) -> str:
words = re.findall(r"[a-z0-9]+", game.lower())
return f"region-{core.lower().replace('-', '_')}-{'-'.join(words)[:60].strip('-')}-{variant}"
def opened(record: dict) -> set[str]:
return {item["path"] for variant in record["variants"] for rep in variant["repetitions"]
for item in rep.get("files_opened") or ()}
def frames(record: dict) -> list:
rep = record["variants"][0]["repetitions"][0]
return (rep.get("observables") or {}).get("frame_hashes") or []
def verdict(record: dict) -> str:
return record["variants"][0].get("verdict") or "unknown"
def outcome(records: dict[str, dict], entries: list[dict], region: str) -> dict:
"""What the four records of one game on one core settle.
`selected` is the regional file the core opened with every one staged.
The `region:` claim is `confirmed` when every regional file it opened
holds the game's region, `contradicted` when none does, `ambiguous`
when it opened its own region's file and others too, `unread` when it
opened none. Each variant's result is its oracle verdict. While no
reference frame is known for the game, a variant that samples the same
frames as the full run (`as_full`) boots, and one that does not stays
without an answer: frames that differ never make a verdict.
"""
by_dest = {entry_dest(e): e for e in entries}
full = records.get("full")
selected = sorted(opened(full) & set(by_dest)) if full else []
if not full:
claim = "unrun"
elif not selected:
claim = "unread"
elif all(region in regions_of(by_dest[d]) for d in selected):
claim = "confirmed"
elif any(region in regions_of(by_dest[d]) for d in selected):
# Opened every region's file, to hash them or to choose: the open
# alone does not say which one the core runs.
claim = "ambiguous"
else:
claim = "contradicted"
results: dict[str, dict] = {}
for label in VARIANTS:
record = records.get(label)
if not record:
continue
loaded = all(rep.get("loaded") for v in record["variants"] for rep in v["repetitions"])
item = {"record": record["id"], "verdict": verdict(record), "loaded": loaded,
"staged": sorted(record["variants"][0]["system_files"])}
if full and label != "full":
item["as_full"] = loaded and frames(record) == frames(full)
results[label] = item
full_verdict = verdict(full) if full else None
def boots(label: str) -> bool | None:
item = results.get(label)
if not item:
return None
if item["verdict"] != "unknown":
return item["verdict"] == "boots"
# No verdict: the interim rule. The same frames as the full run is a
# boot, while the full run itself is not shown to fail. Other frames
# say nothing: the US disc reaches its game on the Japanese and
# European BIOS through another licence screen.
if full_verdict in ("boots", "unknown") and item.get("as_full"):
return True
return None
return {
"region": region,
"selected": [by_dest[d]["name"] for d in selected],
"claim": claim,
"variants": results,
"own_boots": boots("own"),
"others_boot": boots("others"),
"pack_boots": boots("pack"),
"judged_by": "verdict" if full_verdict not in (None, "unknown") else "full_run_frames",
}
def sentence(game_region: str, result: dict) -> str:
"""The outcome in words, for the site."""
word = REGION_WORDS.get(game_region, game_region)
own, others = result.get("own_boots"), result.get("others_boot")
if own is None:
return f"The {word} game has no run with its own BIOS yet."
first = f"The {word} BIOS {'boots' if own else 'does not boot'} the {word} game"
if others is None:
return first + ". The other regions' BIOS have no verdict yet."
if others:
return first + ", and so do the other regions' BIOS."
return first + ", the other regions' BIOS do not."
# --------------------------------------------------------------------------
# Runs
# --------------------------------------------------------------------------
def load_content(path: Path = CONTENT_FILE) -> dict[str, list[dict]]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
out: dict[str, list[dict]] = {}
for system, spec in (data.get("systems") or {}).items():
for item in spec.get("content") or ():
out.setdefault(system, []).append({**item, "romfetch": spec["romfetch"]})
return out
def content_file(item: dict, content_dir: Path) -> Path:
folder = content_dir / item["romfetch"] / item["name"]
if item.get("load") == "cue":
cues = sorted(folder.glob("*.cue"))
if len(cues) != 1:
raise FileNotFoundError(f"{folder}: {len(cues)} .cue files, one expected")
return cues[0]
if item.get("load"):
return folder / item["load"]
files = [p for p in sorted(folder.iterdir()) if p.is_file() and p.name != "identity.json"]
if len(files) != 1:
raise FileNotFoundError(f"{folder}: {len(files)} files, name one with load:")
return files[0]
def nightly_core(core: str) -> tuple[Path, str]:
"""The buildbot nightly of a core, downloaded once, and its URL."""
import zipfile # noqa: PLC0415
url = NIGHTLY.format(core=core)
target = CORES / f"{core}_libretro.so"
if not target.is_file():
CORES.mkdir(parents=True, exist_ok=True)
archive = CORES / f"{core}.zip"
with urllib.request.urlopen(url, timeout=300) as response, archive.open("wb") as out:
out.write(response.read())
with zipfile.ZipFile(archive) as handle:
member = f"{core}_libretro.so"
if member not in handle.namelist():
raise FileNotFoundError(f"{archive.name}: no {member} inside")
partial = target.with_suffix(".part")
partial.write_bytes(handle.read(member))
partial.replace(target)
archive.unlink()
return target, url
def pack_files(emulator: str, region: str, work: Path) -> dict[str, Path]:
"""The files generate_pack puts in the emulator's pack for one region,
extracted, by their path in the pack."""
from ziptools import safe_extract_zip # noqa: PLC0415
out_dir = work / "pack"
subprocess.run([sys.executable, str(ROOT / "scripts" / "generate_pack.py"), "--emulator", emulator,
"--region", PACK_REGION[region], "--output-dir", str(out_dir)],
cwd=ROOT, check=True, capture_output=True, text=True)
zips = sorted(out_dir.glob("*.zip"))
if len(zips) != 1:
raise FileNotFoundError(f"{out_dir}: {len(zips)} packs, one expected")
tree = work / "pack-tree"
safe_extract_zip(str(zips[0]), str(tree))
return {p.relative_to(tree).as_posix(): p for p in sorted(tree.rglob("*")) if p.is_file()
and not p.name.upper().startswith("README")}
def local_resolver() -> Callable[[dict], Path | None]:
from common import load_database, resolve_local_file # noqa: PLC0415
db = load_database(str(ROOT / "database.json"))
def local(entry: dict) -> Path | None:
hint = entry.get("path") if isinstance(entry.get("path"), str) else ""
path, status = resolve_local_file(entry, db, dest_hint=hint or "")
if not path or status in ("not_found", "hash_mismatch"):
return None
found = Path(path) if Path(path).is_absolute() else ROOT / path
return found if found.is_file() else None
return local
BUSY = 3 # core_probe's exit status when it refuses to record on a busy host
POLL_S = 15
def probe_when_idle(argv: list[str], max_wait: float,
clock: Callable[[], float] = time.monotonic,
pause: Callable[[float], None] = time.sleep,
runner: Callable[..., subprocess.CompletedProcess] = subprocess.run
) -> subprocess.CompletedProcess:
"""core_probe, run again while it refuses a busy host, as the campaign
does: every POLL_S seconds until max_wait seconds have passed. The load
threshold stays core_probe's own."""
started = clock()
while True:
proc = runner(argv, cwd=ROOT, capture_output=True, text=True)
if proc.returncode != BUSY or clock() - started >= max_wait:
return proc
pause(POLL_S)
def run(args: argparse.Namespace) -> int:
from common import load_emulator_profiles # noqa: PLC0415
profile = load_emulator_profiles(str(ROOT / "emulators"))[args.emulator]
entries = region_entries(profile, args.system)
if not entries:
print(f"ERROR: {args.emulator} declares no regional BIOS for {args.system}", file=sys.stderr)
return 2
games = [g for g in load_content().get(args.system, []) if g.get("region") in PACK_REGION]
if not games:
print(f"ERROR: content.yml has no regional game for {args.system}", file=sys.stderr)
return 2
core, url = (Path(args.core), args.core_artifact) if args.core else nightly_core(args.core_name)
local = local_resolver()
failures = 0
for game in games:
load = content_file(game, Path(args.content_dir))
with tempfile.TemporaryDirectory(dir=ROOT / "tmp") as scratch:
variants = plan_variants(entries, game["region"], local)
variants["pack"] = pack_files(args.emulator, game["region"], Path(scratch))
for label in args.variants or VARIANTS:
argv = [sys.executable, str(ROOT / "scripts" / "core_probe.py"), "run",
"--core", str(core), "--content", str(load), "--content-label", game["name"],
"--frames", str(game.get("frames", 3600)),
"--frame-hash-every", str(game.get("frame_hash_every", 10)),
"--record", str(ROOT), "--emulator", args.emulator,
"--subject", subject(core.name.removesuffix("_libretro.so"), game["name"], label),
"--require-sandbox", "--timeout", str(args.timeout)]
if url:
argv += ["--core-artifact", url]
for dest, src in sorted(variants[label].items()):
argv += ["--system-file", f"{dest}={src}"]
proc = probe_when_idle(argv, args.max_load_wait)
status = "recorded" if proc.returncode == 0 else f"exit {proc.returncode}"
print(json.dumps({"game": game["name"], "variant": label, "status": status,
"error": proc.stderr.strip()[-400:] if proc.returncode else None}),
flush=True)
failures += proc.returncode != 0
return 1 if failures else 0
def collect(root: Path = ROOT, stored_records: list | None = None) -> list[dict]:
"""Every region outcome the records under measurements/ hold. A game
listed under several systems (sega-megacd and mega-cd) is judged under
the one whose profile ran it."""
from common import load_emulator_profiles # noqa: PLC0415
from measurement_records import live, read_records # noqa: PLC0415
content: dict[str, list[tuple[str, dict]]] = {}
for system, items in load_content(root / "tests" / "campaign" / "content.yml").items():
for item in items:
content.setdefault(item["name"], []).append((system, item))
profiles = load_emulator_profiles(str(root / "emulators"))
groups: dict[tuple[str, str, str], dict[str, dict]] = {}
for stored in read_records(root) if stored_records is None else stored_records:
match = SUBJECT.match(stored.record.get("subject", ""))
label = ((stored.record.get("inputs") or {}).get("content") or {}).get("label")
if not match or label not in content:
continue
key = (stored.emulator, match["core"], label)
groups.setdefault(key, {})[match["variant"]] = stored.record
out = []
for (emulator, core, game), records in sorted(groups.items()):
records = {k: v for k, v in records.items() if v["id"] in {r["id"] for r in live(records.values())}}
profile = profiles.get(emulator, {})
system, item = next(((s, i) for s, i in content[game] if s in (profile.get("systems") or ())),
content[game][0])
entries = region_entries(profile, system)
result = outcome(records, entries, item["region"])
out.append({"emulator": emulator, "core": core, "system": system, "game": game, **result,
"sentence": sentence(item["region"], result)})
return out
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
go = sub.add_parser("run", allow_abbrev=False, help="the four runs of each region game, recorded")
go.add_argument("--emulator", required=True, help="the profile, e.g. beetle_psx")
go.add_argument("--system", required=True, help="the system, e.g. sony-playstation")
go.add_argument("--core-name", help="buildbot core name, e.g. mednafen_psx (downloads the nightly)")
go.add_argument("--core", help="a core .so to use instead of the nightly")
go.add_argument("--core-artifact", help="where --core was downloaded from")
go.add_argument("--content-dir", default=str(ROOT / ".cache" / "test-content"),
help="where romfetch keeps the test games (default: .cache/test-content)")
go.add_argument("--variants", nargs="*", choices=VARIANTS, help="a subset of the four runs")
go.add_argument("--timeout", type=int, default=900, help="seconds per run")
go.add_argument("--max-load-wait", type=int, default=1800,
help="seconds a run waits for a busy host to settle before it is given up (default 1800)")
sub.add_parser("report", allow_abbrev=False, help="the outcomes the records hold, as JSON")
args = parser.parse_args(argv)
if args.command == "run":
if not (args.core or args.core_name):
parser.error("run needs --core-name or --core")
return run(args)
print(json.dumps(collect(), indent=2))
return 0
if __name__ == "__main__":
from common import run_cli
run_cli(main)