mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
195 lines
7.7 KiB
Python
195 lines
7.7 KiB
Python
"""Rom folders of each setup: where its games live and what each folder holds.
|
|
|
|
`platforms/romfolders/<setup>.yml` records what a frontend's own source says
|
|
about its game folders, read at a pinned revision: the roms root relative to
|
|
the BIOS folder, each folder with the system name the OS gives it, the
|
|
extensions it lists and the core it starts by default. Those files hold the
|
|
OS's facts only. Which repository system a folder is, and which profile its
|
|
core is, is decided here, through the one alias table in common.py
|
|
(`SYSTEM_ALIASES`, `_norm_system_id`) and the profiles' `cores:` lists, so a
|
|
change to either reaches every setup without a rescrape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from common import (
|
|
_norm_system_id,
|
|
list_registered_platforms,
|
|
load_emulator_profiles,
|
|
load_platform_config,
|
|
native_layer,
|
|
preferred_profile,
|
|
upstream_profile_index,
|
|
)
|
|
from safeparse import yaml_load
|
|
|
|
ROMFOLDERS_DIR = Path("platforms") / "romfolders"
|
|
SOURCES_DIR = ROMFOLDERS_DIR / "_sources"
|
|
|
|
# "<source key> <path>:<line>[-<line>]", several joined by "; ".
|
|
_REF_PART = re.compile(r"^(?P<key>[a-z0-9][a-z0-9-]*) (?P<path>\S+?)(?::(?P<lines>\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?$")
|
|
|
|
|
|
def split_refs(source_ref: str) -> list[tuple[str, str, str | None]]:
|
|
"""Each (source key, path, lines) a source_ref names, or ValueError."""
|
|
parts: list[tuple[str, str, str | None]] = []
|
|
for raw in source_ref.split("; "):
|
|
match = _REF_PART.match(raw.strip())
|
|
if not match:
|
|
raise ValueError(f"source_ref part {raw!r} is not '<source> <path>:<line>'")
|
|
parts.append((match["key"], match["path"], match["lines"]))
|
|
return parts
|
|
|
|
|
|
def setup_paths(directory: Path = ROMFOLDERS_DIR) -> list[Path]:
|
|
"""The setup files of a directory, without the sources file."""
|
|
return sorted(p for p in Path(directory).glob("*.yml") if not p.name.startswith("_"))
|
|
|
|
|
|
def load_setup(path: Path) -> dict:
|
|
with Path(path).open(encoding="utf-8") as handle:
|
|
return yaml_load(handle)
|
|
|
|
|
|
def load_setups(directory: Path = ROMFOLDERS_DIR) -> dict[str, dict]:
|
|
"""Every setup document of a directory, keyed by its `setup:` id."""
|
|
return {doc["setup"]: doc for doc in (load_setup(p) for p in setup_paths(directory))}
|
|
|
|
|
|
@dataclass
|
|
class Resolution:
|
|
"""What a folder resolved to, and how."""
|
|
|
|
systems: list[str]
|
|
basis: str
|
|
|
|
|
|
@dataclass
|
|
class SystemResolver:
|
|
"""Folder and system names to repository system ids.
|
|
|
|
The universe is every system a profile or a platform file names. A name
|
|
resolves, in this order, as one of those ids, as a name a platform's own
|
|
file gives a system (`native_system`, the folder Batocera calls `psx`),
|
|
or through `_norm_system_id`, which applies SYSTEM_ALIASES then drops the
|
|
maker prefix and separators. A name nothing matches stays unresolved:
|
|
a new abbreviation goes into SYSTEM_ALIASES, never into a table here.
|
|
|
|
Ids that normalize alike are one machine spelled several ways
|
|
(`sega-megadrive`, `sega-mega-drive`, `mega-drive`). Each class answers
|
|
with one canonical id, the spelling most profiles use, and
|
|
`equivalents()` gives the whole class back to whoever matches a
|
|
profile's `systems:` or a manifest's `systems`.
|
|
"""
|
|
|
|
ids: set[str]
|
|
profile_use: dict[str, int]
|
|
native: dict[str, dict[str, set[str]]]
|
|
by_norm: dict[str, set[str]] = field(default_factory=dict)
|
|
canonical_of: dict[str, str] = field(default_factory=dict)
|
|
|
|
def __post_init__(self) -> None:
|
|
for sid in self.ids:
|
|
self.by_norm.setdefault(_norm_system_id(sid), set()).add(sid)
|
|
for members in self.by_norm.values():
|
|
best = min(members, key=lambda s: (-self.profile_use.get(s, 0), -len(s), s))
|
|
for sid in members:
|
|
self.canonical_of[sid] = best
|
|
|
|
@classmethod
|
|
def from_repo(
|
|
cls, emulators_dir: str = "emulators", platforms_dir: str = "platforms"
|
|
) -> SystemResolver:
|
|
profiles = load_emulator_profiles(emulators_dir)
|
|
use: dict[str, int] = {}
|
|
for profile in profiles.values():
|
|
for sid in profile.get("systems") or []:
|
|
use[str(sid)] = use.get(str(sid), 0) + 1
|
|
ids = set(use)
|
|
native: dict[str, dict[str, set[str]]] = {}
|
|
for platform in list_registered_platforms(platforms_dir, include_archived=True):
|
|
config = load_platform_config(platform, platforms_dir)
|
|
ids.update(config.get("systems", {}))
|
|
names: dict[str, set[str]] = {}
|
|
for sys_id, system in (native_layer(config) or {}).get("systems", {}).items():
|
|
for entry in system.get("files", []):
|
|
name = entry.get("native_system")
|
|
if isinstance(name, str) and name.strip():
|
|
names.setdefault(name.strip().lower(), set()).add(sys_id)
|
|
native[platform] = names
|
|
return cls(ids=ids, profile_use=use, native=native)
|
|
|
|
def canonical(self, sid: str) -> str:
|
|
return self.canonical_of.get(sid, sid)
|
|
|
|
def equivalents(self, sid: str) -> set[str]:
|
|
"""Every id spelling the same machine as sid, sid included."""
|
|
return set(self.by_norm.get(_norm_system_id(sid), ())) | {sid}
|
|
|
|
def _answer(self, found: set[str], basis: str) -> Resolution:
|
|
return Resolution(sorted({self.canonical(s) for s in found}), basis)
|
|
|
|
def resolve(self, names: list[str], platform: str = "") -> Resolution:
|
|
"""The systems the first name that resolves designates."""
|
|
for name in names:
|
|
if not name:
|
|
continue
|
|
lowered = name.strip().lower()
|
|
if lowered in self.ids:
|
|
return self._answer({lowered}, "id")
|
|
own = self.native.get(platform, {}).get(lowered)
|
|
if own and len(own) == 1:
|
|
return self._answer(own, f"native name of {platform}")
|
|
norm = self.by_norm.get(_norm_system_id(lowered))
|
|
if norm:
|
|
return self._answer(norm, "normalized id")
|
|
claimed = [
|
|
(plat, owners) for plat, table in sorted(self.native.items())
|
|
if (owners := table.get(lowered)) and len(owners) == 1
|
|
]
|
|
if claimed:
|
|
plat, owners = claimed[0]
|
|
return self._answer(owners, f"native name of {plat}")
|
|
return Resolution([], "unresolved")
|
|
|
|
|
|
@dataclass
|
|
class ProfileResolver:
|
|
"""An OS's core name to the repository profile that documents it."""
|
|
|
|
index: dict[str, set[str]]
|
|
|
|
@classmethod
|
|
def from_repo(cls, emulators_dir: str = "emulators") -> ProfileResolver:
|
|
return cls(upstream_profile_index(load_emulator_profiles(emulators_dir)))
|
|
|
|
def resolve(self, core: str | None) -> str | None:
|
|
if not core:
|
|
return None
|
|
# RetroPie names a libretro package lr-<core> (scriptmodules/libretrocores).
|
|
bare = core.removeprefix("lr-")
|
|
spellings = (core, core.lower(), bare, bare.replace("-", "_"), bare.lower().replace("-", "_"))
|
|
for name in dict.fromkeys(spellings):
|
|
found = preferred_profile(self.index, name)
|
|
if found:
|
|
return found
|
|
return None
|
|
|
|
|
|
_TAG = re.compile(r"\(([^()]+)\)\s*$")
|
|
|
|
|
|
def folder_names(row: dict) -> list[str]:
|
|
"""The names a folder row is known by, the OS's system name first.
|
|
|
|
A folder named for people and tagged for the frontend, `Sony PlayStation
|
|
(PS)` on CrossMix-OS, is also known by its tag.
|
|
"""
|
|
names = [row.get("name") or "", row["folder"], *(row.get("aliases") or [])]
|
|
names += [m.group(1).strip() for n in list(names) if (m := _TAG.search(n))]
|
|
return [n for n in dict.fromkeys(names) if n]
|