Files
libretro/scripts/region.py
T
Abdessamad Derraz e8ee8b0954 fix: decide hash mismatch by native mode
A declared hash that the local dump contradicts is not one situation. An
existence platform never reads the bytes, so withholding the file lets an
upstream list error remove something the frontend would have loaded; a
hash platform would reject it, so shipping it is pointless.

The mode now decides, at every point that had an opinion: pack building,
core complement, emulator packs, manifests, conformance and
_intentional_hash_exclusion. verify.find_undeclared_files follows, since
verify and generate_pack must agree file for file.

Also here: resolution reports which evidence matched rather than a flat
"exact", a path or filename can no longer override a declared hash, and
safe_extract_zip treats a Windows backslash as the separator it is
instead of refusing the archive.
2026-08-10 13:36:03 +02:00

271 lines
8.9 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."""
if not requested:
return []
out: list[str] = []
for group_id, members in groups.items():
ranked = _competing_ranks(members, index, requested)
has_world = any(
WORLD in lookup_regions(index, destination, name)
for destination, name in members
)
if ranked and not has_world and min(r for r, _ in ranked) == len(requested):
out.append(group_id)
return sorted(out)