mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
ES-DE starts standalone MAME with -rompath %GAMEDIR%\;%ROMPATH%/<system> in 78 live commands across 54 systems (resources/systems/linux/ es_systems.xml at retrodeck-main-20261103-162710, 15e1b236; two more are commented out). The command line outranks mame.ini, so RetroDECK's MAME reads its sets in the roms folder each command names and in no BIOS folder. The ES-DE rom folders parser now records, per folder, the folders a standalone command passes as its ROM search path and the machines it starts, or that the game is the machine (%BASENAME%). Rescraped at the pinned revisions: retrodeck, emudeck and emudeck-windows gain these records and nothing else. A command reads the sets its machines load: the machine's own set, the BIOS sets on its romof chain and the device sets with ROMs it instantiates, from games/arcade/mame.json (mame0289 -listxml). That index now also lists the game and device sets of each driver source file, so a %BASENAME% folder reads for its board: neogeo and stv by their BIOS root, cps1-3 and model2 by source file, daphne and laserdisc by the laserdisc player devices, gameandwatch and lcdgames by the handheld sources, any other (arcade, mame, consolearcade) for every machine. The published installer copy of the index leaves the sources out. Manifests: RetroDECK gains 1399 files, 378.2 MB, below roms/. Of the 219 sets named as read outside the BIOS folder, 58 reach 146 files (25.9 MB), 161 stay named, no command's machine reading them. 441 sets RetroDECK's list also declares at bios/ reach 1253 files (352.3 MB), 429 each in arcade and mame and 480 in consolearcade. No other platform changes.
210 lines
8.6 KiB
Python
210 lines
8.6 KiB
Python
"""Scrape each setup's rom folders from its own source, at a pinned revision.
|
|
|
|
`platforms/romfolders/_sources/<setup>.yml` names the repositories and
|
|
the commits to read, the format of the system list, and the facts a parser
|
|
cannot read from a table (roms root, case rule, folders the OS never scans),
|
|
each with its `source_ref`. A format parser reads the system list and returns
|
|
one row per folder. The setup file it writes carries the OS's own names only:
|
|
`scripts/romfolders.py` resolves them to repository systems and profiles.
|
|
|
|
Every fetch goes through `upstream.fetch_file`, cached by revision, so a
|
|
second run reads nothing from the network and writes the same bytes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import pkgutil
|
|
import sys
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
_SCRIPTS_DIR = str(Path(__file__).resolve().parents[2])
|
|
if _SCRIPTS_DIR not in sys.path:
|
|
sys.path.insert(0, _SCRIPTS_DIR)
|
|
|
|
import upstream # noqa: E402
|
|
from safeparse import yaml_load # noqa: E402
|
|
|
|
CACHE_DIR = upstream.CACHE_DIR
|
|
|
|
# Keys a setup file carries, in the order they are written.
|
|
SETUP_KEYS = (
|
|
"setup", "name", "platform", "sources", "roms_root", "case_sensitive",
|
|
"case_ref", "skip", "hidden", "media", "notes", "folders",
|
|
)
|
|
ROW_KEYS = (
|
|
"folder", "name", "aliases", "extensions", "files", "core", "emulator", "source_ref",
|
|
"board_defaults", "arcade", "searched_by", "note",
|
|
)
|
|
|
|
|
|
class SourceError(RuntimeError):
|
|
"""A pinned file is absent, or its content no longer has the shape read."""
|
|
|
|
|
|
@dataclass
|
|
class Fetcher:
|
|
"""Reads pinned files of one setup's declared sources."""
|
|
|
|
sources: dict[str, dict]
|
|
cache_dir: str = CACHE_DIR
|
|
offline: bool = False
|
|
_cached_dirs: dict[str, dict[str, set[str]]] = field(default_factory=dict, init=False, repr=False)
|
|
|
|
def _repo(self, key: str) -> tuple[upstream.Repo, str]:
|
|
if key not in self.sources:
|
|
raise SourceError(f"source {key!r} is not declared")
|
|
spec = self.sources[key]
|
|
repo = upstream.parse_repo(spec["repo"])
|
|
if repo is None:
|
|
raise SourceError(f"source {key!r}: unsupported forge {spec['repo']}")
|
|
return repo, spec["commit"]
|
|
|
|
def text(self, key: str, path: str) -> str:
|
|
"""One file at the pinned commit, or SourceError when absent."""
|
|
repo, sha = self._repo(key)
|
|
lines = upstream.fetch_file(repo, sha, path, self.cache_dir, self.offline)
|
|
if lines is None:
|
|
raise SourceError(f"{repo.slug}@{sha[:8]}:{path} does not exist")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
def optional_text(self, key: str, path: str) -> str | None:
|
|
repo, sha = self._repo(key)
|
|
for directory, held in self._cached_dirs.get(key, {}).items():
|
|
if path.startswith(directory + "/") and path not in held:
|
|
return None
|
|
lines = upstream.fetch_file(repo, sha, path, self.cache_dir, self.offline)
|
|
return None if lines is None else "\n".join(lines) + "\n"
|
|
|
|
def tree(self, key: str, prefix: str = "") -> list[str]:
|
|
"""Every blob path under a prefix at the pinned commit."""
|
|
repo, sha = self._repo(key)
|
|
if repo.family == "gitlab" and prefix.strip("/"):
|
|
return self._gitlab_subtree(repo, sha, prefix)
|
|
paths, truncated = upstream.list_tree(repo, sha, self.cache_dir, self.offline)
|
|
if truncated:
|
|
raise SourceError(f"{repo.slug}@{sha[:8]}: the forge truncated the tree")
|
|
return sorted(p for p in paths if p.startswith(prefix))
|
|
|
|
def cache_directory(self, key: str, directory: str) -> None:
|
|
"""Fetch a whole GitLab directory in one archive request before its
|
|
files are read one by one. A no-op on other forges."""
|
|
repo, sha = self._repo(key)
|
|
held = upstream.cache_gitlab_directory(repo, sha, directory, self.cache_dir, self.offline)
|
|
if held is not None:
|
|
self._cached_dirs.setdefault(key, {})[directory.strip("/")] = held
|
|
|
|
def entries(self, key: str, directory: str) -> list[str]:
|
|
"""Paths of the files and folders directly under one directory."""
|
|
repo, sha = self._repo(key)
|
|
held = self._cached_dirs.get(key, {}).get(directory.strip("/"))
|
|
if held is not None:
|
|
prefix = directory.strip("/") + "/"
|
|
return sorted({prefix + p[len(prefix):].split("/", 1)[0] for p in held})
|
|
if repo.family != "gitlab":
|
|
prefix = directory.strip("/") + "/"
|
|
return sorted({prefix + p[len(prefix):].split("/", 1)[0] for p in self.tree(key, prefix)})
|
|
return self._gitlab_listing(repo, sha, directory, recursive=False, blobs=False)
|
|
|
|
def _gitlab_subtree(self, repo: upstream.Repo, sha: str, prefix: str) -> list[str]:
|
|
"""GitLab pages its tree 100 entries at a time and Recalbox's whole
|
|
tree outruns the page cap: list only the directory asked for."""
|
|
paths = self._gitlab_listing(repo, sha, prefix, recursive=True, blobs=True)
|
|
return sorted(p for p in paths if p.startswith(prefix))
|
|
|
|
def _gitlab_listing(
|
|
self, repo: upstream.Repo, sha: str, directory: str, *, recursive: bool, blobs: bool
|
|
) -> list[str]:
|
|
directory = directory.strip("/")
|
|
paths: list[str] = []
|
|
for page in range(1, upstream.MAX_TREE_PAGES + 1):
|
|
url = (
|
|
f"{repo.api_base}/projects/{upstream._project(repo)}/repository/tree"
|
|
f"?ref={sha}&path={directory}&recursive={'true' if recursive else 'false'}"
|
|
f"&per_page={upstream.TREE_PAGE_SIZE}&page={page}"
|
|
)
|
|
payload = upstream._api(url, self.cache_dir, self.offline)
|
|
if not isinstance(payload, list):
|
|
raise SourceError(f"{repo.slug}@{sha[:8]}: {directory} could not be listed")
|
|
paths.extend(
|
|
entry["path"] for entry in payload
|
|
if isinstance(entry, dict) and entry.get("path")
|
|
and (entry.get("type") == "blob" or not blobs)
|
|
)
|
|
if len(payload) < upstream.TREE_PAGE_SIZE:
|
|
return sorted(paths)
|
|
raise SourceError(f"{repo.slug}@{sha[:8]}: {directory} outruns the page cap")
|
|
|
|
|
|
Parser = Callable[[dict, Fetcher], list[dict]]
|
|
FORMATS: dict[str, Parser] = {}
|
|
|
|
|
|
def register(name: str) -> Callable[[Parser], Parser]:
|
|
def wrap(func: Parser) -> Parser:
|
|
if name in FORMATS:
|
|
raise ValueError(f"format {name!r} registered twice")
|
|
FORMATS[name] = func
|
|
return func
|
|
|
|
return wrap
|
|
|
|
|
|
def load_formats() -> dict[str, Parser]:
|
|
"""Import every parser module of this package once."""
|
|
if not FORMATS:
|
|
for info in pkgutil.iter_modules([str(Path(__file__).parent)]):
|
|
if not info.name.startswith("_"):
|
|
importlib.import_module(f"{__name__}.{info.name}")
|
|
return FORMATS
|
|
|
|
|
|
def load_sources(directory: Path) -> dict[str, dict]:
|
|
"""Each setup's declared sources and facts, keyed by its file name."""
|
|
out: dict[str, dict] = {}
|
|
for path in sorted(Path(directory).glob("*.yml")):
|
|
with path.open(encoding="utf-8") as handle:
|
|
out[path.stem] = yaml_load(handle) or {}
|
|
return out
|
|
|
|
|
|
def _clean_row(row: dict) -> dict:
|
|
unknown = set(row) - set(ROW_KEYS)
|
|
if unknown:
|
|
raise ValueError(f"folder {row.get('folder')!r}: unknown keys {sorted(unknown)}")
|
|
out = {key: row[key] for key in ROW_KEYS if row.get(key) not in (None, [], {}, "")}
|
|
if out.get("name") == out["folder"]:
|
|
del out["name"]
|
|
if "extensions" in out:
|
|
out["extensions"] = sorted({e.lower() for e in out["extensions"]})
|
|
if "aliases" in out:
|
|
out["aliases"] = sorted(set(out["aliases"]) - {out["folder"]})
|
|
if not out["aliases"]:
|
|
del out["aliases"]
|
|
return out
|
|
|
|
|
|
def scrape_setup(setup: str, spec: dict, fetcher: Fetcher) -> dict:
|
|
"""The setup document: declared facts, then the parsed folders."""
|
|
formats = load_formats()
|
|
fmt = spec.get("format")
|
|
rows: list[dict] = []
|
|
if fmt:
|
|
if fmt not in formats:
|
|
raise SourceError(f"{setup}: no parser for format {fmt!r}")
|
|
rows = formats[fmt](spec, fetcher)
|
|
if not rows and not spec.get("allow_empty"):
|
|
raise SourceError(f"{setup}: format {fmt} read no folder")
|
|
folders: dict[str, dict] = {}
|
|
for row in rows:
|
|
clean = _clean_row(row)
|
|
key = clean["folder"]
|
|
if key in folders:
|
|
raise SourceError(f"{setup}: folder {key!r} read twice")
|
|
folders[key] = clean
|
|
doc = {"setup": setup, **{k: spec[k] for k in SETUP_KEYS[1:-1] if k in spec}}
|
|
doc["folders"] = [folders[k] for k in sorted(folders, key=lambda f: (f.lower(), f))]
|
|
return doc
|