Files
libretro/scripts/scraper/rocknix_scraper.py
T
Abdessamad Derraz 3395236966 feat: pack the standalone emulators rocknix ships
ROCKNIX was declared libretro-only although it packages duckstation,
dolphin, ppsspp and 33 other emulators standalone, and its BIOS list
was read on the next branch under the latest release's version. The
scraper now reads the release tag and lists RetroArch plus every
standalone package.
2026-10-17 04:45:13 +02:00

255 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Scraper for ROCKNIX BIOS requirements.
Source: projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-systems
Format: Python source, a `systems` mapping of per-system BIOS declarations
Hash: MD5
ROCKNIX verification logic (rocknix-systems, checkBios):
- md5sum() of the file on disk against the declared md5
- checkInsideZip() when zippedFile is set, with an optional altmd5
- an empty md5 means the check is presence only
EmulationStation surfaces the result through ApiSystem::getBiosInformations,
which shells out to `rocknix-systems` (es-app/src/ApiSystem.cpp:486-516).
"""
from __future__ import annotations
import re
try:
from .base_scraper import (
BaseScraper,
BiosRequirement,
fetch_github_latest_version,
requirement_entry,
)
except ImportError:
from base_scraper import (
BaseScraper,
BiosRequirement,
fetch_github_latest_version,
requirement_entry,
)
PLATFORM_NAME = "rocknix"
GITHUB_REPO = "ROCKNIX/distribution"
SOURCE_TEMPLATE = (
"https://raw.githubusercontent.com/ROCKNIX/distribution/{ref}/"
"projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-systems"
)
# The branch a test or a manual run can still name; a scrape reads the tag
# of the release its YAML is labelled with.
SOURCE_URL = SOURCE_TEMPLATE.format(ref="next")
STANDALONE_TREE_URL = (
"https://api.github.com/repos/ROCKNIX/distribution/contents/"
"projects/ROCKNIX/packages/emulators/standalone?ref={ref}"
)
def standalone_emulators(package_names: list[str]) -> list[str]:
"""The standalone emulators ROCKNIX packages, by the names profiles use.
A package is named after its emulator with an ``-sa`` suffix
(``duckstation-sa``), once without its hyphen (``hatarisa``); both
spellings are kept so a profile listing either is found.
"""
names: set[str] = set()
for package in package_names:
names.add(package)
stripped = re.sub(r"-?sa$", "", package)
if stripped and stripped != package:
names.add(stripped)
return sorted(names)
# ROCKNIX system key -> retrobios system ID
SYSTEM_SLUG_MAP = {
"xbox": "microsoft-xbox",
"gamecube": "nintendo-gamecube",
"nds": "nintendo-ds",
"gb": "nintendo-gb",
"gba": "nintendo-gba",
"gbc": "nintendo-gbc",
"dreamcast": "sega-dreamcast",
"gamegear": "sega-game-gear",
"mastersystem": "sega-master-system",
"segacd": "sega-mega-cd",
"saturn": "sega-saturn",
"psx": "sony-playstation",
"ps2": "sony-playstation-2",
"ps3": "sony-playstation-3",
"psp": "sony-psp",
"3do": "3do",
"amiga": "commodore-amiga",
"atari5200": "atari-5200",
"atari7800": "atari-7800",
"atarilynx": "atari-lynx",
"colecovision": "coleco-colecovision",
"intellivision": "mattel-intellivision",
"msx": "microsoft-msx",
"neogeo": "snk-neogeo",
"neogeocd": "snk-neogeo-cd",
"pcengine": "nec-pc-engine",
"pcenginecd": "nec-pc-engine-cd",
"x68000": "sharp-x68000",
"zxspectrum": "sinclair-zx-spectrum",
}
_SYSTEM_HEADER = re.compile(
r'"(?P<key>[\w\-]+)"\s*:\s*\{\s*"name"\s*:\s*"(?P<name>[^"]+)"\s*,'
r'\s*"biosFiles"\s*:\s*\[',
)
_FIELD = re.compile(r'"(\w+)"\s*:\s*"([^"]*)"')
def _slice_bios_block(source: str, start: int) -> tuple[str, int]:
"""Return the biosFiles list body starting at an opening bracket."""
depth = 0
for i in range(start, len(source)):
char = source[i]
if char == "[":
depth += 1
elif char == "]":
depth -= 1
if depth == 0:
return source[start + 1:i], i
raise ValueError("unterminated biosFiles list")
def parse_systems(source: str) -> dict[str, dict]:
"""Extract per-system BIOS declarations from the rocknix-systems source.
Parsed textually rather than evaluated: the file is upstream code, and
the declarations are plain string literals.
"""
source = re.sub(r"#[^\n]*", "", source)
systems: dict[str, dict] = {}
for header in _SYSTEM_HEADER.finditer(source):
body, _ = _slice_bios_block(source, header.end() - 1)
files = []
for raw_entry in re.findall(r"\{[^{}]*\}", body):
fields = dict(_FIELD.findall(raw_entry))
if fields.get("file"):
files.append(fields)
systems[header.group("key")] = {
"name": header.group("name"),
"biosFiles": files,
}
if not systems:
raise ValueError("no system declarations found in rocknix-systems")
return systems
class Scraper(BaseScraper):
"""Scraper for the ROCKNIX rocknix-systems script."""
def __init__(self, url: str | None = None):
self.tag = None if url else fetch_github_latest_version(GITHUB_REPO)
super().__init__(url=url or SOURCE_TEMPLATE.format(ref=self.tag))
def _fetch_standalone(self) -> list[str]:
"""The packages of projects/ROCKNIX/packages/emulators/standalone."""
import json
import urllib.error
import urllib.request
from .base_scraper import github_headers
url = STANDALONE_TREE_URL.format(ref=self.tag or "next")
req = urllib.request.Request(url, headers=github_headers())
try:
with urllib.request.urlopen(req, timeout=30) as resp:
listing = json.loads(resp.read())
except (urllib.error.URLError, json.JSONDecodeError) as e:
raise ConnectionError(f"Failed to list {url}: {e}") from e
packages = [item["name"] for item in listing if item.get("type") == "dir"]
if not packages:
raise RuntimeError(f"no standalone emulator package at {url}")
return standalone_emulators(packages)
def validate_format(self, raw_data: str) -> bool:
"""Validate the rocknix-systems format."""
has_dict = re.search(r"^systems\s*=\s*\{", raw_data, re.MULTILINE) is not None
has_check = "def checkBios" in raw_data
return has_dict and has_check and '"biosFiles"' in raw_data and '"md5"' in raw_data
def fetch_requirements(self) -> list[BiosRequirement]:
"""Parse rocknix-systems and return BIOS requirements."""
if not self.validate_format(self._fetch_raw()):
raise ValueError("rocknix-systems format validation failed")
requirements = []
for key, entry in parse_systems(self._fetch_raw()).items():
system_slug = SYSTEM_SLUG_MAP.get(key, key)
for bios in entry["biosFiles"]:
path = bios["file"]
# Declared paths are relative to the storage root: strip the
# leading bios/ so destinations match base_destination
destination = path[len("bios/"):] if path.startswith("bios/") else path
req = BiosRequirement(
name=destination.rsplit("/", 1)[-1],
system=system_slug,
md5=bios.get("md5", "") or None,
alt_md5=bios.get("altmd5", "") or None,
destination=destination,
required=True,
native_id=key,
native={"native_name": entry["name"]},
)
if bios.get("zippedFile"):
req.zipped_file = bios["zippedFile"]
requirements.append(req)
return requirements
def generate_platform_yaml(self) -> dict:
"""Build the ROCKNIX platform configuration."""
raw = parse_systems(self._fetch_raw())
names = {key: entry["name"] for key, entry in raw.items()}
systems: dict[str, dict] = {}
for req in self.fetch_requirements():
entry = systems.setdefault(
req.system,
{
"files": [],
"native_id": req.native_id,
"name": names.get(req.native_id, req.system),
},
)
entry["files"].append(requirement_entry(req))
standalone = self._fetch_standalone()
return {
"platform": "ROCKNIX",
"version": self.tag or fetch_github_latest_version(GITHUB_REPO),
"homepage": "https://rocknix.org",
"source": self.url,
"base_destination": "bios",
"hash_type": "md5",
"verification_mode": "md5",
# RetroArch with every libretro core, plus the standalone
# emulators ROCKNIX packages beside it.
"cores": ["retroarch", *standalone],
"standalone_cores": standalone,
"systems": systems,
}
def main() -> None:
"""Entry point."""
try:
from .base_scraper import scraper_cli
except ImportError:
from base_scraper import scraper_cli
scraper_cli(Scraper, "Scrape ROCKNIX BIOS requirements")
if __name__ == "__main__":
main()