Files
libretro/scripts/scraper/retrodeck_scraper.py
T
Abdessamad Derraz 16f6ef8cbf feat: record the commit a branch scrape read
Six platforms are read from a branch: System.dat, batocera-systems.json, checkBIOS.sh, the MiSTer BiosDB, the RetroPie scriptmodules and the RetroDECK component manifests. Their YAML named the branch, so the native export patched whatever the branch had become since the scrape. Each scraper now resolves the branch head once, reads every file at that commit and writes it into source. The export follows a recorded tarball and a recorded tree as it already followed a raw file URL.
2026-10-19 22:50:45 +02:00

495 lines
17 KiB
Python

#!/usr/bin/env python3
"""Scraper for RetroDECK BIOS requirements.
Platform contributed by @monster-penguin (#36).
Source: https://github.com/RetroDECK/components
Format: component_manifest.json per component directory
Hash: MD5 (primary), SHA256 for some entries (melonDS DSi)
RetroDECK stores BIOS requirements in component_manifest.json files,
one per emulator component. BIOS entries can appear in three locations:
- top-level 'bios' key
- preset_actions.bios (duckstation, dolphin, pcsx2)
- cores.bios (retroarch)
Path tokens: $bios_path, $saves_path, $roms_path map to
~/retrodeck/bios/, ~/retrodeck/saves/, ~/retrodeck/roms/ respectively.
$saves_path entries are directory placeholders (excluded).
$roms_path entries (neogeo.zip etc.) get roms/ prefix in destination.
Entries with no paths key default to bios/ (RetroDECK's default BIOS dir).
Verification logic (api_data_processing.sh:289-405):
- md5sum per file, compared against known_md5 (comma-separated list)
- envsubst resolves path tokens at runtime
- Multi-threaded on system_cpu_max_threads
"""
from __future__ import annotations
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
try:
from .base_scraper import (
BaseScraper,
BiosRequirement,
fetch_branch_head,
requirement_entry,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).parent.parent))
from scraper.base_scraper import (
BaseScraper,
BiosRequirement,
fetch_branch_head,
requirement_entry,
)
PLATFORM_NAME = "retrodeck"
COMPONENTS_REPO = "RetroDECK/components"
COMPONENTS_BRANCH = "main"
COMPONENTS_API_URL = "https://api.github.com/repos/" + COMPONENTS_REPO + "/git/trees/{rev}"
RAW_BASE = "https://raw.githubusercontent.com/" + COMPONENTS_REPO + "/{rev}"
SKIP_DIRS = {"archive_later", "archive_old", "automation-tools", ".github"}
NON_EMULATOR_COMPONENTS = {
"framework",
"es-de",
"steam-rom-manager",
"flips",
"portmaster",
}
# RetroDECK system ID -> retrobios slug.
# None = skip (system not relevant for BIOS packs).
# Missing key = pass through as-is.
SYSTEM_SLUG_MAP: dict[str, str | None] = {
# Nintendo
"nes": "nintendo-nes",
"snes": "nintendo-snes",
"snesna": "nintendo-snes",
"n64": "nintendo-64",
"n64dd": "nintendo-64dd",
"gc": "nintendo-gamecube",
"wii": "nintendo-wii",
"wiiu": "nintendo-wii-u",
"switch": "nintendo-switch",
"gb": "nintendo-gb",
"gbc": "nintendo-gbc",
"gba": "nintendo-gba",
"nds": "nintendo-ds",
"3ds": "nintendo-3ds",
"n3ds": "nintendo-3ds",
"fds": "nintendo-fds",
"sgb": "nintendo-sgb",
"virtualboy": "nintendo-virtual-boy",
# Sony
"psx": "sony-playstation",
"ps2": "sony-playstation-2",
"ps3": "sony-playstation-3",
"psp": "sony-psp",
"psvita": "sony-playstation-vita",
# Sega
"megadrive": "sega-mega-drive",
"genesis": "sega-mega-drive",
"megacd": "sega-mega-cd",
"megacdjp": "sega-mega-cd",
"segacd": "sega-mega-cd",
"saturn": "sega-saturn",
"saturnjp": "sega-saturn",
"dreamcast": "sega-dreamcast",
"naomi": "sega-dreamcast-arcade",
"naomi2": "sega-dreamcast-arcade",
"atomiswave": "sega-dreamcast-arcade",
"gamegear": "sega-game-gear",
"mastersystem": "sega-master-system",
"sms": "sega-master-system",
# NEC
"pcengine": "nec-pc-engine",
"pcenginecd": "nec-pc-engine",
"turbografx16": "nec-pc-engine",
"pcfx": "nec-pc-fx",
"pc98": "nec-pc-98",
"pc9800": "nec-pc-98",
"pc88": "nec-pc-88",
"pc8800": "nec-pc-88",
# Other
"3do": "3do",
"amstradcpc": "amstrad-cpc",
"arcade": "arcade",
"mame": "arcade",
"fbneo": "arcade",
"atari800": "atari-400-800",
"atari5200": "atari-5200",
"atari7800": "atari-7800",
"atarijaguar": "atari-jaguar",
"atarilynx": "atari-lynx",
"atarist": "atari-st",
"atarixe": "atari-400-800",
"c64": "commodore-c64",
"amiga": "commodore-amiga",
"cdimono1": "philips-cdi",
"channelf": "fairchild-channel-f",
"colecovision": "coleco-colecovision",
"intellivision": "mattel-intellivision",
"msx": "microsoft-msx",
"xbox": "microsoft-xbox",
"doom": "doom",
"j2me": "j2me",
"mac2": "apple-macintosh-ii",
"macintosh": "apple-macintosh-ii",
"apple2": "apple-ii",
"apple2gs": "apple-iigs",
"enterprise": "enterprise-64-128",
"gamecom": "tiger-game-com",
"gmaster": "hartung-game-master",
"pokemini": "nintendo-pokemon-mini",
"scv": "epoch-scv",
"supervision": "watara-supervision",
"wonderswan": "bandai-wonderswan",
"neogeocd": "snk-neogeo-cd",
"neogeocdjp": "snk-neogeo-cd",
# xroar files the CoCo 1/2 ROMs under coco and the Dragon 64's under
# dragon64, the ids Batocera uses too.
"coco": "coco",
"trs80": "tandy-trs-80",
"dragon": "dragon64",
"tanodragon": "dragon64",
"pico8": "pico8",
"vsmile": "vtech-vsmile",
"wolfenstein": "wolfenstein-3d",
"zxspectrum": "sinclair-zx-spectrum",
}
def _sanitize_path(p: str) -> str:
"""Fix upstream typos in path tokens."""
return re.sub(r"\$saves_\w+", "$saves_path", p)
def _resolve_path(p: str) -> str:
"""Resolve RetroDECK path tokens to pack-relative paths."""
p = _sanitize_path(p)
p = p.replace("$bios_path", "bios")
p = p.replace("$saves_path", "saves")
p = p.replace("$roms_path", "roms")
return p.strip("/")
def _extract_bios_entries(component_val: dict) -> list[dict]:
"""Extract BIOS entries from all three possible locations in a component.
No dedup here -dedup is done in fetch_requirements() with full
(system, filename) key to avoid dropping valid same-filename entries
across different systems.
"""
entries: list[dict] = []
def collect(bios_data: list | dict) -> None:
if isinstance(bios_data, dict):
bios_data = [bios_data]
if not isinstance(bios_data, list):
return
for entry in bios_data:
if isinstance(entry, dict) and entry.get("filename", "").strip():
entries.append(entry)
if "bios" in component_val:
collect(component_val["bios"])
pa = component_val.get("preset_actions", {})
if isinstance(pa, dict) and "bios" in pa:
collect(pa["bios"])
cores = component_val.get("cores", {})
if isinstance(cores, dict) and "bios" in cores:
collect(cores["bios"])
return entries
def _map_system(raw_system: str) -> str | None:
"""Map RetroDECK system ID to retrobios slug.
Returns None for systems explicitly excluded from the map.
Unknown systems pass through as-is.
"""
if raw_system in SYSTEM_SLUG_MAP:
return SYSTEM_SLUG_MAP[raw_system]
return raw_system
class Scraper(BaseScraper):
"""RetroDECK BIOS scraper from component manifests."""
platform_name = PLATFORM_NAME
def __init__(self, manifests_dir: str = "") -> None:
super().__init__()
self.manifests_dir = manifests_dir
self._manifests: list[tuple[str, dict]] | None = None
# The manifests are read from main: one commit for the tree and for
# every file, and that commit is the one recorded.
self.revision = (
"" if manifests_dir else fetch_branch_head(COMPONENTS_REPO, COMPONENTS_BRANCH)
)
def _get_manifests(self) -> list[tuple[str, dict]]:
"""Fetch manifests once, cache for reuse."""
if self._manifests is None:
self._manifests = (
self._fetch_local_manifests()
if self.manifests_dir
else self._fetch_remote_manifests()
)
return self._manifests
def _fetch_remote_manifests(self) -> list[tuple[str, dict]]:
"""Fetch component manifests via GitHub API."""
token = os.environ.get("GITHUB_TOKEN", "")
headers = {"User-Agent": "retrobios-scraper/1.0"}
if token:
headers["Authorization"] = f"token {token}"
try:
req = urllib.request.Request(
COMPONENTS_API_URL.format(rev=self.revision), headers=headers
)
with urllib.request.urlopen(req, timeout=30) as resp:
tree = json.loads(resp.read().decode())
except (urllib.error.HTTPError, urllib.error.URLError) as e:
raise ConnectionError(f"Failed to fetch component tree: {e}") from e
if tree.get("truncated"):
# A partial tree lists some components: writing the YAML from it
# drops the others' systems without a word.
raise ConnectionError("GitHub tree response truncated")
component_dirs = [
item["path"]
for item in tree.get("tree", [])
if item["type"] == "tree" and item["path"] not in SKIP_DIRS
]
manifests: list[tuple[str, dict]] = []
for comp in sorted(component_dirs):
url = f"{RAW_BASE.format(rev=self.revision)}/{comp}/component_manifest.json"
print(f" {comp} ...", file=sys.stderr, end="", flush=True)
# Only a 404 says the component has no manifest. A 429, a 503 or
# a dropped connection is no answer: skipping it wrote
# retrodeck.yml without that component's systems, exit code 0.
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
if e.code == 404:
print(" no manifest", file=sys.stderr)
continue
raise ConnectionError(f"{url}: HTTP {e.code}") from e
except urllib.error.URLError as e:
raise ConnectionError(f"{url}: {e.reason}") from e
except json.JSONDecodeError as e:
raise ValueError(f"{url}: not JSON: {e}") from e
manifests.append((comp, data))
print(" ok", file=sys.stderr)
return manifests
def _fetch_local_manifests(self) -> list[tuple[str, dict]]:
"""Read manifests from local RetroDECK install."""
root = Path(self.manifests_dir)
manifests: list[tuple[str, dict]] = []
for d in sorted(root.iterdir()):
if not d.is_dir() or d.name in SKIP_DIRS or d.name.startswith("."):
continue
mf = d / "component_manifest.json"
if not mf.exists():
continue
try:
with open(mf) as f:
manifests.append((d.name, json.load(f)))
except (json.JSONDecodeError, OSError) as e:
print(f" WARNING: {mf}: {e}", file=sys.stderr)
return manifests
def validate_format(self, raw_data: str) -> bool:
try:
return isinstance(json.loads(raw_data), dict)
except (json.JSONDecodeError, TypeError):
return False
def fetch_requirements(self) -> list[BiosRequirement]:
manifests = self._get_manifests()
requirements: list[BiosRequirement] = []
seen: set[tuple[str, str]] = set()
for comp_name, manifest in manifests:
for comp_key, comp_val in manifest.items():
if not isinstance(comp_val, dict):
continue
default_system = comp_val.get("system", comp_key)
if isinstance(default_system, list):
default_system = default_system[0] if default_system else comp_key
for entry in _extract_bios_entries(comp_val):
filename = entry["filename"].strip()
raw_system = entry.get("system", default_system)
if isinstance(raw_system, list):
raw_system = raw_system[0] if raw_system else default_system
system = _map_system(str(raw_system))
if system is None:
continue
# Resolve path
paths_raw = entry.get("paths")
if isinstance(paths_raw, str):
resolved = _resolve_path(paths_raw)
elif isinstance(paths_raw, list):
resolved = ""
for p in paths_raw:
rp = _resolve_path(str(p))
if not rp.startswith("saves"):
resolved = rp
break
if not resolved:
continue
else:
resolved = ""
# Skip saves-only entries
if resolved.startswith("saves"):
continue
# Build destination -default to bios/ if no path specified
if resolved:
destination = f"{resolved}/{filename}"
else:
destination = f"bios/{filename}"
# MD5 handling -sanitize upstream errors
md5_raw = entry.get("md5", "")
if isinstance(md5_raw, list):
parts = [str(m).strip().lower() for m in md5_raw if m]
elif md5_raw:
parts = [str(md5_raw).strip().lower()]
else:
parts = []
# Keep only valid 32-char hex MD5 hashes
valid = [p for p in parts if re.fullmatch(r"[0-9a-f]{32}", p)]
md5 = ",".join(valid)
required_raw = entry.get("required", "")
required = bool(required_raw) and str(required_raw).lower() not in (
"false",
"no",
"optional",
"",
)
key = (system, filename.lower())
if key in seen:
existing = next(
(
r
for r in requirements
if (r.system, r.name.lower()) == key
),
None,
)
if existing and md5 and existing.md5 and md5 != existing.md5:
print(
f" WARNING: {filename} ({system}): MD5 conflict "
f"({existing.md5[:12]}... vs {md5[:12]}...)",
file=sys.stderr,
)
continue
seen.add(key)
native: dict[str, object] = {"component": comp_key}
description = str(entry.get("description", "")).strip()
if description:
native["description"] = description
if required_raw not in (None, ""):
native["required_label"] = str(required_raw)
sha256 = str(entry.get("sha256", "")).strip().lower()
requirements.append(
BiosRequirement(
name=filename,
system=system,
destination=destination,
md5=md5,
sha256=sha256 or None,
required=required,
native_id=str(raw_system),
native=native,
)
)
return requirements
def generate_platform_yaml(self) -> dict:
reqs = self.fetch_requirements()
manifests = self._get_manifests()
cores = sorted(
{
comp_name
for comp_name, _ in manifests
if comp_name not in SKIP_DIRS
and comp_name not in NON_EMULATOR_COMPONENTS
}
)
systems: dict[str, dict] = {}
for req in reqs:
sys_entry = systems.setdefault(req.system, {"files": []})
sys_entry["files"].append(requirement_entry(req))
try:
from .base_scraper import fetch_github_latest_version
except ImportError:
from scraper.base_scraper import fetch_github_latest_version
version = fetch_github_latest_version("RetroDECK/RetroDECK")
return {
"platform": "RetroDECK",
"version": version,
"homepage": "https://retrodeck.net",
"source": (
f"https://github.com/{COMPONENTS_REPO}/tree/{self.revision}"
if self.revision
else f"https://github.com/{COMPONENTS_REPO}"
),
"base_destination": "",
"hash_type": "md5",
"verification_mode": "md5",
"cores": cores,
# Each component is one emulator RetroDECK ships and launches on
# its own, and retroarch is the one that runs cores.
"standalone_cores": [c for c in cores if c != "retroarch"],
"systems": systems,
}
def main() -> None:
try:
from .base_scraper import scraper_cli
except ImportError:
from scraper.base_scraper import scraper_cli
scraper_cli(Scraper, "Scrape RetroDECK BIOS requirements")
if __name__ == "__main__":
main()