mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-11 05:53:23 -05:00
280 lines
9.3 KiB
Python
280 lines
9.3 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. 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.
|
|
for key in {path, name} - {""}:
|
|
entry = index.setdefault(
|
|
key,
|
|
{"regions": set(), "has_untagged": False, "emulators": []},
|
|
)
|
|
entry["regions"] |= regions
|
|
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 _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, an exact/parent regional match beats other regional candidates.
|
|
A world candidate beats unmatched regional fallbacks, 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.
|
|
"""
|
|
if not requested:
|
|
return set()
|
|
|
|
keep: set[str] = set()
|
|
drop: set[str] = set()
|
|
for members in groups.values():
|
|
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)]
|
|
if matched:
|
|
best = min(r for r, _destination in matched)
|
|
keep |= {destination for r, destination in matched if r == best}
|
|
drop |= {destination for _r, destination in regional if destination not in keep}
|
|
elif world:
|
|
drop |= {destination for _r, destination in regional}
|
|
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.
|
|
"""
|
|
if not requested:
|
|
return []
|
|
out: list[str] = []
|
|
for group_id, members in groups.items():
|
|
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)
|