mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 21:43:23 -05:00
246 lines
7.7 KiB
Python
246 lines
7.7 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 _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(f"unknown region: {raw!r}")
|
|
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 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. Entries sharing a
|
|
key have their regions unioned, so an ambiguous lookup keeps more files
|
|
rather than fewer.
|
|
"""
|
|
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
|
|
if not regions:
|
|
continue
|
|
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.
|
|
for key in {path, name} - {""}:
|
|
entry = index.setdefault(key, {"regions": set(), "emulators": []})
|
|
entry["regions"] |= regions
|
|
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(entry["regions"])
|
|
parts = destination.split("/")
|
|
for i in range(1, len(parts)):
|
|
entry = index.get("/".join(parts[i:]))
|
|
if entry:
|
|
return set(entry["regions"])
|
|
entry = index.get(name)
|
|
return set(entry["regions"]) if entry else set()
|
|
|
|
|
|
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
|
|
|
|
|
|
def resolve_region_drops(
|
|
groups: dict[str, list[tuple[str, str]]],
|
|
index: dict[str, dict],
|
|
requested: list[str],
|
|
) -> set[str]:
|
|
"""Destinations to skip for a requested region priority list.
|
|
|
|
Per group, only the best rank actually present survives. A destination kept
|
|
by any group is kept overall.
|
|
"""
|
|
if not requested:
|
|
return set()
|
|
|
|
keep: set[str] = set()
|
|
drop: set[str] = set()
|
|
for members in groups.values():
|
|
ranked = _competing_ranks(members, index, requested)
|
|
competing = {dest for _r, dest in ranked}
|
|
keep |= {dest for dest, _name in members if dest not in competing}
|
|
if not ranked:
|
|
continue
|
|
best = min(r for r, _ in ranked)
|
|
for r, destination in ranked:
|
|
if r == best:
|
|
keep.add(destination)
|
|
else:
|
|
drop.add(destination)
|
|
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."""
|
|
if not requested:
|
|
return []
|
|
out: list[str] = []
|
|
for group_id, members in groups.items():
|
|
ranked = _competing_ranks(members, index, requested)
|
|
if ranked and min(r for r, _ in ranked) == len(requested):
|
|
out.append(group_id)
|
|
return sorted(out)
|