Files
libretro/scripts/region.py
T
Abdessamad Derraz 4231902a6c docs: write prose without dashes or semicolons
Profiles, the wiki, comments, docstrings, command output and schema descriptions carried em-dashes, clause-joining semicolons, unicode arrows, box-drawing banners and check-mark glyphs. Each sentence was rewritten rather than having its character swapped. Hashes, paths, source_ref values and every cited file:line are unchanged, which a comparison of each parsed profile against the previous revision confirms.

validate_pr comments now say PASS and FAIL in words, and source_ref values in a profile's structured blocks render as code. test_prose_style fails if any of these comes back, in tracked files or in the generated site.
2026-10-19 06:47:31 +02:00

395 lines
15 KiB
Python

"""Region vocabulary and ordered-priority selection for pack generation.
A file's region: value is the region the emulator code selects it for, not the
region of the dump. Signal standard has no field of its own. It survives only
inside slugs whose hardware register fuses the two axes (Saturn SMPC areas).
"""
from __future__ import annotations
WORLD = "world"
# Two levels only: super-region -> member territories.
REGION_TREE: dict[str, frozenset[str]] = {
"north-america": frozenset({"canada"}),
"latin-america": frozenset({"brazil", "mexico", "argentina"}),
"europe": frozenset(
{
"uk",
"france",
"germany",
"italy",
"spain",
"netherlands",
"portugal",
"greece",
"poland",
"russia",
"sweden",
"norway",
"denmark",
"finland",
}
),
"asia": frozenset(
{
"japan",
"south-korea",
"china",
"taiwan",
"hong-kong",
"singapore",
"india",
"asia-ntsc",
"asia-pal",
}
),
"oceania": frozenset({"australia", "new-zealand"}),
}
_PARENT: dict[str, str] = {
member: parent for parent, members in REGION_TREE.items() for member in members
}
REGIONS: frozenset[str] = frozenset({WORLD} | set(REGION_TREE) | set(_PARENT))
# Accepted on the CLI and for legacy profile values.
ALIASES: dict[str, str] = {
"jp": "japan",
"ntsc-j": "japan",
"us": "north-america",
"usa": "north-america",
"na": "north-america",
"ntsc-u": "north-america",
"eu": "europe",
"pal": "europe",
"kr": "south-korea",
"korea": "south-korea",
"auto": WORLD,
"gb": "uk",
}
def unknown_region_message(raw: str, aliases: dict[str, str], regions: frozenset[str]) -> str:
"""What to type instead: the closest spellings, then every short form.
`unknown region: 'uss'` left the user to read the source for `us`.
"""
import difflib # noqa: PLC0415
spelled = raw.strip()
close = difflib.get_close_matches(spelled.lower(), [*aliases, *regions], n=3, cutoff=0.5)
hint = f" Did you mean: {', '.join(close)}?" if close else ""
return (
f"unknown region: {spelled}.{hint} Short forms: {', '.join(aliases)}; "
f"regions: {', '.join(sorted(regions))}"
)
def _canonical(raw: str) -> str:
"""Map one token to a canonical region slug."""
key = raw.strip().lower()
key = ALIASES.get(key, key)
if key not in REGIONS:
raise ValueError(unknown_region_message(raw, ALIASES, REGIONS))
return key
def comparable(a: str, b: str) -> bool:
"""True when two regions are equal or in a parent/child relation."""
return a == b or _PARENT.get(a) == b or _PARENT.get(b) == a
def normalize_declared(raw: str | list | None) -> set[str]:
"""Normalise a profile region: value to canonical slugs."""
if raw is None:
return set()
values = raw if isinstance(raw, list) else [raw]
return {_canonical(str(v)) for v in values}
def parse_requested(raw: str) -> list[str]:
"""Parse a comma-separated --region value into an ordered priority list."""
out: list[str] = []
for part in raw.split(","):
if not part.strip():
continue
slug = _canonical(part)
if slug not in out:
out.append(slug)
if not out:
raise ValueError("--region requires at least one region")
return out
def rank(file_regions: set[str], requested: list[str]) -> int:
"""Priority index of a file, lower is better.
Returns len(requested) when no requested region is comparable, the implicit
lowest rank that keeps a group from ever being emptied.
"""
for i, req in enumerate(requested):
if any(comparable(req, fr) for fr in file_regions):
return i
return len(requested)
def region_tag(requested: list[str]) -> str:
"""Pack filename tag for a requested priority list."""
return "_".join(
"".join(part.title() for part in slug.split("-")) for slug in requested
)
def platform_region_index(
config: dict, profiles: dict, target_cores: set[str] | None = None
) -> dict[str, dict]:
"""The region index of the emulators a platform runs, nothing else.
Built over every profile, an emulator foreign to the platform voided the
filter: Pizza Boy SC, an Android app, declares bios_CD_E.bin without a
region, so the Genesis Plus GX and PicoDrive tags never counted and
`--region us` kept both CD BIOSes in the RetroArch pack.
"""
from common import resolve_platform_cores
cores = resolve_platform_cores(config, profiles, target_cores=target_cores)
return build_region_index({n: profiles[n] for n in cores if n in profiles})
def build_region_index(profiles: dict) -> dict[str, dict]:
"""Build a region lookup from emulator profiles.
Keyed by the entry's path when present, by name otherwise. Untagged
declarations are recorded as ambiguity evidence: if the same lookup key is
both tagged and untagged, filtering keeps it instead of inventing a region.
"""
index: dict[str, dict] = {}
for emu_name, profile in sorted(profiles.items()):
if profile.get("type") in ("launcher", "alias"):
continue
for f in profile.get("files") or []:
if not isinstance(f, dict):
continue
try:
regions = normalize_declared(f.get("region"))
except ValueError as exc:
raise ValueError(
f"{emu_name}: {f.get('name', '?')}: {exc}"
) from exc
name = f.get("name", "")
path = f.get("path") or ""
# Path-keyed so same-named entries stay separate (Dolphin declares
# three IPL.bin), name-keyed so a candidate identified by name alone
# sees the union and is never dropped on ambiguity. The standalone
# layout is a path of its own: GC/EUR/IPL.bin fell through to the
# bare name and its five-region union, and was never reduced.
# An alias is another name the core reads for the same file:
# scph5552.bin, the name beetle_psx also opens for its European
# BIOS, stayed in a --region jp pack once scph5502.bin went.
aliases = {str(a) for a in f.get("aliases") or []}
priority = f.get("priority")
for key in {path, f.get("standalone_path") or "", name, *aliases} - {""}:
entry = index.setdefault(
key,
{"regions": set(), "has_untagged": False, "emulators": [],
"priorities": set()},
)
entry["regions"] |= regions
entry["priorities"].add(priority if isinstance(priority, int) else None)
if not regions:
entry["has_untagged"] = True
if emu_name not in entry["emulators"]:
entry["emulators"].append(emu_name)
return index
def lookup_regions(index: dict[str, dict], destination: str, name: str) -> set[str]:
"""Look a candidate file up in the region index.
Tries the full destination, then progressively shorter path suffixes, then
the bare name. Suffix matching bridges a platform entry whose destination
carries an extra prefix to the profile entry that declares the region.
"""
if destination:
entry = index.get(destination)
if entry:
return set() if entry.get("has_untagged") else set(entry["regions"])
parts = destination.split("/")
for i in range(1, len(parts)):
entry = index.get("/".join(parts[i:]))
if entry:
return set() if entry.get("has_untagged") else set(entry["regions"])
entry = index.get(name)
if not entry or entry.get("has_untagged"):
return set()
return set(entry["regions"])
def _index_entry(index: dict[str, dict], destination: str, name: str) -> dict | None:
"""The index entry a candidate resolves to, by the lookup_regions order."""
if destination:
entry = index.get(destination)
if entry:
return entry
parts = destination.split("/")
for i in range(1, len(parts)):
entry = index.get("/".join(parts[i:]))
if entry:
return entry
return index.get(name)
def lookup_priority(index: dict[str, dict], destination: str, name: str) -> int | None:
"""The search rank the code gives a candidate, lower first.
None when no entry declares one, or when two declarations disagree: a
rank in doubt cannot decide what to drop.
"""
entry = _index_entry(index, destination, name)
priorities = (entry or {}).get("priorities", set())
if len(priorities) != 1:
return None
return next(iter(priorities))
def _competing_ranks(
members: list[tuple[str, str]],
index: dict[str, dict],
requested: list[str],
) -> list[tuple[int, str]]:
"""Rank the members of a group that compete regionally.
Files with no declared region, and files declared world, are excluded: they
never compete and never drop.
"""
ranked: list[tuple[int, str]] = []
for destination, name in members:
regions = lookup_regions(index, destination, name)
if not regions or WORLD in regions:
continue
ranked.append((rank(regions, requested), destination))
return ranked
# Marks the group of a file no system names (extra_region_groups and the
# emulator-mode grouping both build it).
FILE_GROUP = ":file:"
def resolve_region_drops(
groups: dict[str, list[tuple[str, str]]],
index: dict[str, dict],
requested: list[str],
held: set[str] | None = None,
) -> set[str]:
"""Destinations to skip for a requested region priority list.
Per group, an exact/parent regional match beats other regional candidates.
A world candidate beats unmatched regional fallbacks the code does not
search before it, while untagged files always survive. If neither a requested nor a world candidate exists, all
regional candidates survive so filtering can never empty a group.
``held`` names the destinations the collection holds. A match it lacks
never displaces a held file: the American BIOS stays in a Japanese pack
when the Japanese one is missing, and the missing one stays reported.
"""
if not requested:
return set()
keep: set[str] = set()
drop: set[str] = set()
for group_id, members in groups.items():
if FILE_GROUP in group_id:
# A file no system names competes with nothing: it drops nothing,
# and keeping it would veto the group that does decide for the
# same path. Dolphin's GC/EUR/IPL.bin, alone as a core extra,
# kept what the GameCube group dropped for --region us. A system
# holding one file still keeps it: a group is never emptied.
continue
regional: list[tuple[int, str]] = []
world: set[str] = set()
untagged: set[str] = set()
for destination, name in members:
regions = lookup_regions(index, destination, name)
if not regions:
untagged.add(destination)
elif WORLD in regions:
world.add(destination)
else:
regional.append((rank(regions, requested), destination))
keep |= untagged | world
if not regional:
continue
matched = [(r, destination) for r, destination in regional if r < len(requested)]
winners, rivals = matched, world
if held is not None:
# A missing file answers the request but takes no held file's
# place: only held files decide what gives way.
winners = [(r, d) for r, d in matched if d in held]
rivals = world & held
if winners:
best = min(r for r, _destination in winners)
keep |= {
destination for r, destination in matched
if r == best or (r < best and destination not in (held or ()))
}
drop |= {destination for _r, destination in regional if destination not in keep}
elif rivals:
# A world file beats an unmatched regional one, unless the code
# provably looks for the regional one first: DuckStation ranks its
# PS1 images 5 to 50 and its world PS2 images 100, so a request
# for Brazil would otherwise leave it only the PS2 fallbacks.
world_ranks = [
lookup_priority(index, destination, name)
for destination, name in members
if destination in rivals
]
known = None not in world_ranks
best_world = min(world_ranks) if known and world_ranks else None
for _r, destination in regional:
name = next(n for d, n in members if d == destination)
ours = lookup_priority(index, destination, name)
if best_world is not None and ours is not None and ours < best_world:
keep.add(destination)
else:
drop.add(destination)
else:
# Preserve every unmatched candidate as a visible fallback.
keep |= {destination for _r, destination in regional}
return drop - keep
def fallback_groups(
groups: dict[str, list[tuple[str, str]]],
index: dict[str, dict],
requested: list[str],
) -> list[str]:
"""Group IDs where no candidate matched, so the whole group was kept.
A world file, or one that declares no region at all, answers every
region: the C64 kernal VICE loads by default is untagged, and a group
holding it beside a Japanese and a Swedish kernal does have a BIOS for
North America. A file no system claims is a group of its own and keeps
nothing as a fallback: Dolphin's GC/EUR/IPL.bin named the GameCube as
lacking a North American BIOS the pack carries.
"""
if not requested:
return []
out: list[str] = []
for group_id, members in groups.items():
if ":file:" in group_id:
continue
ranked = _competing_ranks(members, index, requested)
served = any(
not regions or WORLD in regions
for regions in (
lookup_regions(index, destination, name)
for destination, name in members
)
)
if ranked and not served and min(r for r, _ in ranked) == len(requested):
out.append(group_id)
return sorted(out)